SVG, JS, CSS로 만드는 라이언 로그인 폼

많은 노력이 들어간 작고 귀여운 애니메이션

시작하기에 앞서: '라이언'의 저작권은 (주)카카오에 있습니다. 이 코드는 학습용으로만 보고 실제 서비스에는 사용하지 마시기 바랍니다.

먼저 아래 동영상을 재생하여 실제 사용 영상을 살펴보세요. 페이지 제일 아래에는 실제 사용해 볼 수 있는 폼과 코드가 공개되어 있습니다.

얼마 전 LoginCritter라는 재밌는 프로젝트를 봤다. 로그인 정보를 입력하는 동안 귀여운 곰이 커서를 따라 눈길을 주는 것이었다. 그 프로젝트는 iOS 기반이었기 때문에 웹으로 구현해봐도 재미있겠다는 생각이 들어 며칠 시간을 들여 로그인 폼을 작성해보았다. 자바스크립트 개발자로 살고 있음에도 SVG와 CSS3를 사용해 이런 애니메이션을 만들 일은 별로 없었는데 덕분에 배운 것도 많아서 여기에 정리해보려고 한다.

SVG로 라이언 그리기

처음엔 CSS만으로 그리는 것도 고려해봤는데 선 끝의 라운딩 처리나 코 주변 부분의 형태때문에 SVG로 구현하기로 결정했다. SVG도 공부해볼겸 해서 그래픽 도구를 일체 사용하지 않고 한땀한땀 코딩해서 만들었다.

이미지는 120x120 크기를 가정하고 만들었지만 실제 이미지의 크기는 이보다 크게 표현되어 있다. SVG에는 viewBox라는 속성을 통해 SVG 이미지의 가로/세로 비율을 설정할 뿐만 아니라 내부에서 사용하는 좌표계의 기준 크기를 설정한다. 예를 들어 라이언 이미지의 SVG 선언은 다음과 같이 되어있다.

<svg id="ryan" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">

이제 실제 표현되는 크기에 상관없이 SVG 내에서 좌표(120,120)은 언제나 이미지 오른쪽 하단 꼭지점을 가리킨다. 그리고 얼굴의 바탕이 되는 동그라미는 다음과 같이 작성했다.

<circle cx="60" cy="60" r="40" fill="#e0a243" stroke="#000" stroke-width="2.5" />

여기서 cxcy는 원의 중심을 나타내고 r은 반지름을 의미한다. 따라서 위 코드는 SVG 이미지의 한 가운데를 중심으로 하는 반지름이 40인 원을 그린다. 또한 fill 속성으로 얼굴 색상을 표현하고 stroke로 외곽선의 색상을, 그리고 stroke-width로 외곽선의 굵기를 설정하면 얼굴의 바탕을 얻을 수 있다. CSS를 사용해 SVG 엘리먼트의 배경색을 하얀색으로 설정하고 border-radius 속성을 통해 원 형태로 만들고 나면 다음과 같은 이미지가 나타난다.

눈과 눈썹은 선과 작은 원으로도 표현할 수 있는 단순한 형태이므로 먼저 작업했다. 다만, 얼굴 바탕과는 달리 눈 부위는 나중에 애니메이션을 위해 움직여야 할 부분이기 때문에 별도의 그룹으로 묶어서 표현했다. SVG에서는 <g> 엘리먼트를 사용해서 다른 엘리먼트의 그룹을 만들 수 있다.

<g class="eyes">
  <!-- 왼쪽 눈과 눈썹-->
  <line x1="37" x2="50" y1="46" y2="46" stroke="#000" stroke-width="3" stroke-linecap="round" />
  <circle cx="44" cy="55" r="3" fill="#000" />
  <!-- 오른쪽 눈과 눈썹 -->
  <line x1="70" x2="83" y1="46" y2="46" stroke="#000" stroke-width="3" stroke-linecap="round" />
  <circle cx="76" cy="55" r="3" fill="#000" />
</g>

코 부분과 귀도 각각 그룹을 만들게 했다. 최종적으로 라이언 이미지에는 코 부분(muzzle), 귀(ears), 눈(eyes)이라는 세 그룹이 포함되어 있는데 한 그룹으로 만들지 않고 굳이 세 개로 나눈 이유는 잠시 후에 설명하겠다.

얼굴 바탕과 눈, 눈썹을 제외한 나머지 부위는 조금 복잡한 형태라서 임의의 선을 따라 이미지를 그릴 수 있는 <path>엘리먼트를 사용해 그렸다. SVG의 <path> 엘리먼트에는 d 속성을 사용해 시작점과 커브 또는 라인 등의 경로를 정해줄 수 있다. 예를 들어, 몸의 바탕을 그린 다음 코드를 보자.

<path d="M0,150 C0,65 120,65 120,150" fill="#e0a243" stroke="#000" stroke-width="2.5" />

이 경로는 (0,150) 좌표에서 시작해서 (120,150) 좌표에서 끝나는 커브를 그리는데 커브의 형태는 중간에 있는 (0,65)(120,65) 좌표가 정한다. 네 개의 점이 이루는 베지에 곡선을 그린다. 다음 이미지를 보면 조금 더 이해가 쉬울 것이다. 각 곡선에서 하얀색 점이 양끝에 있는 점을 의미하고, 회색 점이 중간에 설정한 두 점을 의미한다.

위에 나온 d 속성에 Cx1,y1 x2,y2 x3,y3와 같은 곡선 부분을 더 추가하면 더 복잡한 곡선도 그릴 수 있다. 이를 통해 귀와 코 주변부를 그렸다. 수작업으로 경로를 일일이 작성하는 건 시간이 많이 걸리는 작업이다. 더군다나 시각적인 부분에 대한 감각이 부족한 경우라면 더 그렇다. 그래서 코 부분은 마름모꼴의 다각형을 그린 후 직선이 만나는 부분을 완만하게 처리하도록 설정하여 그렸다.

<polygon
  points="59,63.5,60,63.4,61,63.5,60,65"
  fill="#000"
  stroke="#000"
  stroke-width="5"
  stroke-linejoin="round"
/>

여러 라이언의 이미지를 보면 대체로 귀가 머리 뒤쪽으로 숨겨져있는 걸 알 수 있다. 비슷한 형태의 캐릭터인 라인의 브라운은 얼굴 바탕의 외곽선이 귀까지 이어지고 있어 정면에서 봤을 때 귀 아래쪽이 완전히 보이는 구조라면, 라이언은 얼굴이 귀 아래쪽을 가리는 형태이다.

브라운과 라인의 귀 위치 비교

그래서 <path>를 사용해 골무 형태를 그리고 살짝 기울여서 얼굴 바탕 뒤에 두도록 그렸다. 몸도 비슷한 방식으로 그렸는데 완만한 라인을 위해 다소 크게 그렸다. 귀의 형태를 볼 수 있도록 얼굴의 바탕색을 투명으로 만들고 화면에 나타나지 않은 부분까지 표현해보면 다음과 같이 보일 것이다.

실제 형태는 이렇게 생겼다.

다시금 깨닫는 크롭의 중요성! 사진과 마찬가지로 이미지도 크롭이 중요하다. 위 이미지에 얼굴 배경색을 입히고 적절하게 크롭하면 다음과 같이 라이언 이미지가 만들어진다.

CSS로 애니메이션 만들기

이제 그림은 다 됐으니 살아 움직이게 만들 때다. 움직임을 구현하기 위해 주로 사용한 기능은 rotate3d 변환(transform)이다. 비록 이미지 자체는 2D이지만 자연스러운 움직임을 위해서는 3D 좌표에 맞추어 눈, 코, 귀를 움직여야 했고 각도에 따라 적절하게 이미지의 변형도 일어나야했기 때문이다.

먼저 얼굴은 반지름이 40인 완전한 구형태이며 여러 부위는 그 구의 "표면"에 붙어있다고 가정했다. 따라서 고개를 숙이거나 들 때 얼굴은 구의 중심, 다시 말해 (60,60,-40)을 기준으로 움직인다. 회전의 중심은 transform-origin 속성을 사용해 설정해주었다.

.eyes {
    transform-origin: 50% 50% -40px;
}

앞에서 부터 x, y, z 위치를 의미하는데 z에 관해서만은 너비와 높이에 비례적인 %값을 사용할 수 없다. 여기서 설정한 -40px는 SVG 내부 좌표계에 대응하므로 실제 화면에 표현되는 SVG의 크기가 커지거나 작아지더라도 너비와 높이가 120px일 때와 같은 위치를 가리킨다. z축 좌표는 사용자 쪽에 가까워 질수록 커지기 때문에 -40px의 의미는 사용자가 보는 화면 표면으로부터 40px만큼 들어간 위치이다.  앞서 말한대로 얼굴을 표현하는 구의 중심이다.

하지만 실제로 (0,0,-40)을 기준으로 회전하는 건 눈 부분 뿐이고 귀는 (0,0,5)를 기준으로 움직이고 코 부분은 (0,0,-44)을 기준으로 움직이게 했다. 귀는 위에서 보다시피 원래 약간 기울어져있는데다 애니메이션을 만들어보니 눈 보다 조금 덜 움직이도록 만드는 게 오히려 자연스러웠기 때문이다. 이모티콘에서는 평면처럼 표현되어 있지만 코 부분은 조금 앞으로 튀어나와있는 형태라고 보아야 맞다. 측면에서 표현된 라이언 캐릭터를 보면 코 부분이 눈의 중심보다 살짝 더 한쪽으로 이동해 있는데 바로 이런 이유때문이다. 그래서 똑같은 각도를 회전해도 다른 부위보다 더 많이 회전할 수 있도록 회전 반경을 4px 정도 늘려주었다.

회전에는 rotate3d 변환(transform) 속성이 사용되었고 애니메이션은 transition으로 구현했다. rotate3d에는 순서대로 x, y, z의 가중치와 각도를 순서대로 입력한다. 여기서 x, y, z의 의미는 "해당 축을 기준으로 얼마만큼 회전할 것인가"를 의미한다. 양수면 시계 방향으로, 음수면 시계 반대 방향으로 이동한다. 예를 들어 x축을 기준으로 10도 만큼 회전시키려고 할 때는 다음과 같이 입력한다.

transform: rotate3d(1, 0, 0, 10deg);

각도에 해당하는 값에는 각도(deg) 단위나 라디안(rad) 또는 회전(turn)단위를 사용할 수 있다. 완전한 원은 360deg 또는 2π rad 또는 1turn으로 표현할 수 있다. 원래 x, y, z 각 값은 0~1까지의 실수로 표현되고 결과는 0벡터이거나 x^2+y^2+z^2=1을 만족하는 단위 벡터여야 하지만 단위 벡터가 아닌 경우 내부적으로 알아서 단위 벡터로 만들어준다. 이 때문에 다음의 두 선언은 사실 결과가 똑같다.

transform: rotate3d(0.5, 0.5, 0.5, 15deg);
transform: rotate3d(1, 1, 1, 15deg);

라이언 얼굴을 정면에서 보면 x축이 가로로 지나고 y축은 세로로 지나며 두 선과 직교하는 z축이 있다.

라이언 얼굴을 지나는 세 축

rotate3d 함수는 각 축을 중심으로 시계 방향으로 얼마나 회전할 것인지 정해주므로 고개를 숙이려면 x축을 기준으로 음수만큼 회전하면 된다. 반면 y축은 좌우로 얼마나 움직일 것인지 정해준다. 화면 왼쪽을 볼 때는 음수만큼 회전하고  문제는 회전의 정도인데 이 부분은 삼각함수를 이용해 계산했다. 간단한 테스트를 통해 입력 상자와 얼굴 중심까지의 y좌표 거리를 distInput이라고 할 때 입력 상자와 얼굴 중심까지의 z거리는 distInput의 세 배 정도라고 가정하는 편이 적당하다는 결론을 얻었다.

DOM API의 getBoundingClientRect()를 사용하여 두 값을 얻은 후 다음은 얼굴 중심의 x좌표에서 커서가 얼마나 떨어져있는지 구한다. 커서의 위치를 구하는 방법은 다음 섹션에서 설명해두었다. 커서 위치까지 구하고 나면 x, y, z 좌표를 알 수 있는 세 가지 값을 모두 얻은 것이다. 직각 삼각형에서는 두 변의 길이를 알면 어느쪽 각이든 구할 수 있다. x좌표와 z좌표에 아크 탄젠트를 적용하여 좌우 기울기를 얻고 이 값을 사용하면 상하 기울기를 다음과 같이 얻을 수 있다.

/* distCaret = 얼굴 중심으로부터 커서의 픽셀 x 위치 */

// 좌우 기울기
const y = Math.atan2(-distCaret, Math.abs(distInput)*3);

// 상하 기울기
const x = Math.atan2(distInput, Math.abs(distInput)*3 / Math.cos(y));

여기까지 만들고 스크립트를 작성하면 커서를 따라 눈길을 옮기는 라이언을 볼 수 있다. 커서는 좌우로만 움직이기 때문에 앞서 이미지에서 보듯 z 회전은 이 스크립트에서 그리 중요하지 않다. 그러나 애니메이션을 만들어 봤을 때 고개를 조금 기울이는 편이 더 자연스러워보였기 때문에 방향에 영향을 주지 않는 선에서 z축 회전도 조금 가미해주었다.

위 이미지에서 왼쪽이 z 회전을 주지 않은 것이고 오른쪽이 회전을 준 애니메이션이다. 일부러 작은 값을 사용했기 때문에 큰 차이는 없지만 개인적으로는 오른쪽의 이미지가 더 마음에 들어 이를 선택했다.

고개를 숙일 때 원래대로라면 귀가 얼굴 바탕 앞으로 이동했어야 한다. 하지만 CSS, JS, SVG로는 이를 구현하기가 어려워 고개를 숙여도 귀는 항상 머리 뒤쪽에 위치하게 되었다. 충분히 괜찮게 보인다고 생각하면서도 조금 마음에 걸렸는데, 그러다 우연히 라이언이 의외로 이마가 넓은 캐릭터라는 걸 다른 이모티콘에서 확인하게 됐다. 브라보! 덕분에 마음의 평화를 얻어 귀는 계속 머리 뒤쪽에 두기로 했다.

출처: 카카오 이모티콘 샵

이제 패스워드 입력시의 애니메이션을 만들어 볼 차례이다. 원래 봤던 프로젝트에서는 패스워드를 입력할 때 곰이 눈을 가리고 있었다.

귀여운 동작이긴 했지만 실제로 작성을 해보니 문제가 있었다. 라이언은 원래 팔이 짧은 캐릭터라 저 동작이 너무 안 어울렸기 때문이다. 머리는 크고 팔이 짧아서 위와 같은 형태의 애니메이션은 굉장히 어색한 모습이 되었다.

이미지 출처: 카카오 이모티콘샵

고민 끝에 결국 패스워드를 입력할 때는 고개를 반대쪽으로 돌리고 외면하도록 만들어보았다. 다음 섹션에서 설명하겠지만 사용자 입력에 반응하도록 만들려면 패스워드를 다른 엘리먼트로 복사해야 하는 보안 상의 이유도 있어서 패스워드를 입력할 때는 사용자의 입력을 모니터링 하지 않는다. 다만, 그냥 고개를 들고 있으면 심심할 수 있으므로 @keyframes를 사용해 고개를 좌우로 천천히 계속 움직이도록 만들었다.

커서 위치 구하기

현재 브라우저에서는 <input> 엘리먼트 내에 있는 캐럿(caret)의 픽셀 위치를 구할 때 사용할 수 있는 API가 없다. 웹 페이지에서 선택한 텍스트의 픽셀 위치는 보통 다음과 같이 구할 수 있다.

const sel = window.getSelection();
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();

console.log(rect);

그러나 위 코드는 <input> 엘리먼트에서 글을 입력할 때는 동작하지 않는다.  심지어 원래라면 선택된 엘리먼트와 위치를 알려주었어야 할 range 객체에서도 <input> 엘리먼트를 찾아볼 수 없다. 한 가지 희망적인 부분이 있다면 <input> 엘리먼트에는 selectionStartselectionEnd라는 속성이 있어서 이를 통해 커서가 몇 번째 글자에 위치하는 지 알 수 있다.

몇 번째 글자라는 것만 알 수 있으므로 픽셀 위치를 구하기 위해 현재 글자를 입력 중인 <input> 엘리먼트와 스타일이 완전히 똑같은 가짜 입력 엘리먼트를 하나 만들고 입력되는 글자를 그대로 복사해서 넣었다. 글꼴, 글자 크기, 패딩, 마진 등의 스타일은 픽셀 위치를 정하는 데 있어 중요하므로 getComputedStyle을 사용해서 입력 엘리먼트의 스타일을 그대로 복사했다. 다음에 나오는 copyStyles는 전달받은 엘리먼트의 스타일을 fauxInput이라는 <div> 엘리먼트에 복사하는 함수이다.

function copyStyles(el) {
    const props = window.getComputedStyle(el, null);

    if ( fauxInput.parentNode === document.body ) {
        document.body.removeChild(fauxInput);
    }

    fauxInput.style.visibility = 'hidden';
    fauxInput.style.position = 'absolute';
    fauxInput.style.top = Math.min(el.offsetHeight * -2, -999) + 'px';

    for(let i=0; i < props.length; i++) {
        if (['visibility','display','opacity','position','top','left','right','bottom'].indexOf(props[i]) !== -1) {
            continue;
        }
        fauxInput.style[props[i]] = props.getPropertyValue(props[i]);
    }

    document.body.appendChild(fauxInput);
}

fauxInput에는 <span> 엘리먼트가 있어서 여기에 입력되는 텍스트를 복사한 후, <span> 엘리먼트의 픽셀 너비를 구하면 <input> 엘리먼트에 상대적인 커서 위치를 구할 수 있다.

완성된 코드

위에서 설명한 모든 결과물을 하나로 합친 코드는 다음과 같다. Result 탭을 클릭하면 실제로 사용해 볼 수도 있다. 완성된 코드는 GitHub 저장소에서도 볼 수 있다.

댓글을 남겨주세요

This site uses Akismet to reduce spam. Learn how your comment data is processed.