애니메이션 디테일

높이가 변하는 애니메이션

디자인 엔지니어링에서 자주 나오는 팁 중 하나가 이거다. 못생긴 확장 애니메이션의 범인은 대부분 height: auto다.

.panel {
  height: 0;
  overflow: hidden;
  transition: height 0.4s ease;
}
 
.panel.open {
  height: auto;
}

이렇게 써놓고 열어보면 부드럽게 펼쳐지는 게 아니라 그냥 순간적으로 툭 튀어나온다. transition은 시작값과 끝값이 둘 다 숫자일 때만 중간값을 계산해서 보간할 수 있는데, auto는 숫자가 아니라 브라우저가 알아서 계산하는 키워드다. 그래서 0px → auto로 바뀌는 순간 중간 단계 없이 바로 최종 높이로 점프해버린다.

height: auto

Brand Refresh Request

Planning next steps...

1. Review the current website structure
2. Suggest a cleaner page flow
3. Improve homepage sections

grid-template-rows: 0fr → 1fr

Brand Refresh Request

Planning next steps...

1. Review the current website structure
2. Suggest a cleaner page flow
3. Improve homepage sections

왼쪽(height: auto)과 오른쪽(grid-template-rows)을 눌러보면 차이가 바로 느껴진다. 왼쪽은 트랜지션 코드가 있어도 사실상 무효고, 오른쪽은 진짜로 부드럽게 펼쳐진다.

해결책은 height 대신 grid 트랙 크기를 조절하는 거다.

.panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.4s ease;
}
 
.panel.open {
  grid-template-rows: 1fr;
}
 
.panel > .inner {
  overflow: hidden;
}

fr은 CSS Grid에만 정의된 단위라 display: grid인 컨테이너 안에서만 유효하다. 대신 0fr, 1fr 둘 다 숫자 값이라 진짜로 트랜지션 보간이 된다. 콘텐츠 높이가 얼마든 상관없이 자동으로 대응되고, JS로 scrollHeight 재는 작업도 필요 없다.

한 가지 주의할 점은, fr 애니메이션은 grid row 자체가 잘리는 게 아니라 그 안의 콘텐츠가 잘려야 하니까 내부에 overflow: hidden wrapper가 한 겹 더 필요하다.

hover뿐 아니라 근접성(proximity)으로 반응하기

인터랙션을 hover에만 의존하면 반응이 이진적이다. 마우스가 요소 위에 있으면 100%, 벗어나면 0%. 커서가 가까워질수록 근처 요소들이 거리에 따라 미묘하게 스케일되면, 인터페이스가 훨씬 더 반응적이고 생동감 있게 느껴진다. macOS 독(dock)이 대표적인 예다.

onpointermove = (e) => {
  document.querySelectorAll('.dock > *').forEach((el) => {
    const r = el.getBoundingClientRect();
    const t = Math.max(
      0,
      1 - Math.abs(e.clientX - r.x - r.width / 2) / 120,
    );
    el.style.scale = 1 + t * 0.5;
  });
};

핵심은 t 값이다. 커서와 요소 중심 사이의 거리를 계산해서, 120px 이내면 가까울수록 1에 가까운 값을, 멀어질수록 0에 가까운 값을 만든다. 이 tscale에 곱해주면 커서 근처 요소만 커지고 멀어진 요소는 원래 크기로 자연스럽게 돌아온다.

Using proximity

Direct scaling

Direct scaling(하나만 반응)과 Using proximity(근처까지 같이 반응) 두 방식을 비교해보면, 근접성 기반이 훨씬 부드럽고 예측 가능하게 느껴진다. 아이템 하나가 갑자기 커지는 게 아니라 주변이 함께 물결치듯 반응하기 때문이다.

블러로 트랜지션의 어색함 메우기

easing이나 거리를 조절해도 뭔가 어색한 느낌이 남을 때가 있다. 그럴 때 filter: blur()를 살짝 얹으면 두 상태 사이의 시각적 간격이 메워져서 훨씬 자연스러워진다. 상태가 그냥 교차되면 서로 다른 오브젝트처럼 뚝뚝 끊겨 보이는데, blur가 그 사이를 블렌딩해서 눈을 속이는 원리다.

숫자 pop-in

@keyframes num-pop-in {
  0% {
    opacity: 0;
    transform: translateY(8px);
    filter: blur(2px);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
    filter: blur(0);
  }
}

숫자가 위치·투명도만 바뀌면 딱딱하게 튀어나오는 느낌이 나는데, filter: blur(2px) → blur(0)를 같이 걸어주면 초점이 맞아가는 것처럼 보여서 훨씬 부드럽다. 자릿수마다 animation-delay를 살짝 스태거링해주면 왼쪽부터 순서대로 맺히는 느낌까지 더해진다.

128

텍스트 reveal

여러 줄 텍스트가 등장할 때도 같은 원리다. 진입할 때는 아래에서 위로 이동 + blur + fade가 같이 걸리고, 두 번째 줄은 살짝 지연을 줘서 시선이 먼저 줄에 머무르게 한다.

.text-reveal-line {
  opacity: 0;
  transform: translateY(12px);
  filter: blur(3px);
  transition: opacity 500ms, transform 500ms, filter 500ms;
}
 
.text-reveal-wrap.shown .text-reveal-line {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}
새로운 업데이트더 빨라진 반응 속도

한 가지 포인트는 사라질 때(exit)와 나타날 때(enter)를 같은 애니메이션의 역재생으로 처리하지 않는 것이다. 나타날 때는 blur + 이동 + fade가 같이 걸리지만, 사라질 때는 이동과 blur 없이 순수 fade만 짧게(200ms) 걸어주는 게 더 깔끔하다. 등장은 천천히 초점이 맞는 느낌이 좋지만, 퇴장은 굳이 그 과정을 거꾸로 보여줄 필요가 없기 때문이다.

인터랙션 디자인

인터랙션 디자인은 결국 사용자가 화면과 주고받는 대화를 설계하는 일이다. 버튼을 누르면 살짝 눌리는 느낌, 패널이 자연스럽게 펼쳐지는 움직임, 커서가 다가올수록 미묘하게 반응하는 UI. 이런 디테일 하나하나는 작아 보여도 쌓이면 "이 서비스 뭔가 손에 잘 붙는다"는 인상을 만든다. 잘 만든 인터랙션은 사용자가 의식하지 못한다. 오히려 뭔가 어색할 때만 눈에 띈다.

인터랙션에 관한 좋은 글이 있다. Rauno Freiberg의 Invisible Details of Interaction Design인데 iOS/macOS 제스처들을 하나씩 뜯어보면서 왜 좋게 느껴지는지 설명해 준다. 한번 읽어보면 좋을 것 같다.