CSS 흔들리는 애니메이션 9종 — 폼 에러 표시
비밀번호를 잘못 입력했을 때 입력창이 좌우로 흔들리는 걸 본 적 있으신가요? 그 동작이 바로 css 흔들리는 애니메이션입니다 — 자바스크립트 없이 CSS 코드 몇 줄만으로 폼이 틀렸다는 걸 눈에 띄게 알려줍니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 좌우 흔들림
- 02 비틀거림 회전
- 03 젤리처럼 출렁
- 04 오류 테두리 플래시
- 05 입력창 헤드셰이크
- 06 경고 아이콘 바운스
- 07 :user-invalid 자동 흔들림
- 08 1회 재생 리셋 패턴
- 09 모션 축소 시 아웃라인 대체
아홉 개는 인기 순이 아니라 오류가 어디서 나느냐를 따라간 순서입니다. 입력값 자체가 틀렸을 때(01~03)에서 화면 요소만 반짝이는 절제된 신호(04)로, 그다음은 비밀번호처럼 더 무거운 실패(05~06)를 거쳐, 브라우저가 스스로 처리하는 경우(07)와 같은 실패가 반복될 때(08), 마지막은 움직임을 줄인 사람에게도 신호가 남아야 하는 문제(09) 순입니다.
01좌우 흔들림
이메일 칸에 형식이 틀린 값을 넣고 다른 곳을 클릭하면 입력창 전체가 좌우로 짧게 흔들리다 멈춥니다. translateX 값을 -7px→7px→-5px→3px→0px 순서로 나열해 진폭이 갈수록 줄어드는 @keyframes 하나가 전부라, 실제 떨림처럼 보입니다.
@keyframes sh-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-7px); }
4% { transform: translateX(7px); }
6% { transform: translateX(-7px); }
8% { transform: translateX(7px); }
10% { transform: translateX(-5px); }
12% { transform: translateX(5px); }
14% { transform: translateX(-3px); }
16% { transform: translateX(3px); }
}
.sh__input.sh--invalid { animation: sh-shake $duration $easing; }
02비틀거림 회전
약관 체크박스를 누르지 않고 제출을 누르면 체크박스와 문구가 함께 담긴 줄 전체가 좌우로 기우뚱거립니다. rotate 각도만 바꾸는 @keyframes 라 체크박스 모양 자체는 전혀 건드리지 않습니다.
@keyframes wg-wiggle {
0%, 100% { transform: rotate(0); }
15% { transform: rotate(-4deg); }
30% { transform: rotate(4deg); }
45% { transform: rotate(-3deg); }
60% { transform: rotate(3deg); }
75% { transform: rotate(-1.5deg); }
90% { transform: rotate(1.5deg); }
}
.wg__row.wg--wiggle { animation: wg-wiggle $duration $easing; }
03젤리처럼 출렁
저장 버튼을 눌렀는데 실패하면 버튼이 젤리처럼 출렁이다 가라앉습니다. skewX와 skewY를 같은 키프레임 안에서 함께 틀어 비스듬한 두 방향으로 동시에 찌그러지는 게 핵심입니다.
@keyframes je-jello {
0%, 100% { transform: none; }
15% { transform: skewX(-9deg) skewY(-9deg); }
30% { transform: skewX(7deg) skewY(7deg); }
45% { transform: skewX(-5deg) skewY(-5deg); }
60% { transform: skewX(3deg) skewY(3deg); }
75% { transform: skewX(-2deg) skewY(-2deg); }
90% { transform: skewX(1deg) skewY(1deg); }
}
.je__btn.je--jello { animation: je-jello $duration $easing; }
04오류 테두리 플래시
카드 번호 칸 자체는 움직이지 않고, 뒤에 겹쳐 둔 빨간 테두리 레이어만 두 번 반짝입니다. 입력창까지 흔들리면 옆에 나란히 있는 다른 칸들도 함께 흔들리는 것처럼 보일 수 있어, opacity만 바꿔 레이아웃은 그대로 둡니다.
.bf__ring {
position: absolute;
inset: -$sp-1;
border: 2px solid $color;
border-radius: $r-sm;
opacity: 0;
}
@keyframes bf-flash {
0%, 100% { opacity: 0; }
15% { opacity: 1; }
30% { opacity: 0; }
45% { opacity: 1; }
60% { opacity: 0; }
}
.bf__ring.bf--flash { animation: bf-flash $duration $easing; }
05입력창 헤드셰이크
비밀번호 확인 칸이 일치하지 않으면 입력창이 마치 고개를 젓듯 입체적으로 살짝 돌아갑니다. translateX와 rotateY를 같은 키프레임에서 함께 쓰고, 부모에 perspective를 줘야 이 입체감이 삽니다.
.hs { perspective: 500px; }
@keyframes hs-shake {
0% { transform: translateX(0); }
6.5% { transform: translateX(-6px) rotateY(-9deg); }
18.5% { transform: translateX(5px) rotateY(7deg); }
31.5% { transform: translateX(-3px) rotateY(-5deg); }
43.5% { transform: translateX(2px) rotateY(3deg); }
50%, 100% { transform: translateX(0); }
}
.hs__input.hs--shake { animation: hs-shake $duration $easing; }
06경고 아이콘 바운스
느낌표가 그려진 원형 아이콘이 튀어 올랐다가 착지하며 시선을 오류 메시지 쪽으로 끕니다. scale과 translateY를 같이 걸어 통통 튀는 인상을 만들고, 아이콘은 장식이라 aria-hidden으로 처리했습니다.
@keyframes ib-bounce {
0%, 100% { transform: translateY(0) scale(1); }
20% { transform: translateY(-10px) scale(1.15); }
40% { transform: translateY(0) scale(.95); }
55% { transform: translateY(-4px) scale(1.05); }
70% { transform: translateY(0) scale(1); }
}
.ib__icon.ib--bounce { animation: ib-bounce $duration $easing; }
07:user-invalid 자동 흔들림
CSS 쪽 코드는 JS를 한 줄도 안 쓰고, 사용자가 이메일 칸에 값을 넣고 벗어난 뒤 형식이 틀렸을 때만 브라우저가 스스로 흔들어 줍니다. input:user-invalid 의사 클래스 하나에 animation을 걸면 끝이고, 값을 만지기 전이나 페이지가 막 열렸을 때는 조용합니다.
.ui__input:user-invalid {
border-color: $color;
animation: ui-shake $duration $easing;
}
@keyframes ui-shake {
0%, 22%, 100% { transform: translateX(0); }
2% { transform: translateX(-7px); }
4% { transform: translateX(7px); }
6% { transform: translateX(-7px); }
8% { transform: translateX(7px); }
10% { transform: translateX(-5px); }
12% { transform: translateX(5px); }
14% { transform: translateX(-3px); }
16% { transform: translateX(3px); }
}
081회 재생 리셋 패턴
"다시 확인"을 연달아 눌러도 매번 처음부터 새로 흔들립니다. 같은 클래스를 지우고 곧바로 다시 붙이면 브라우저가 재생 중이던 애니메이션으로 착각해 무시하기 때문에, 지운 뒤 offsetWidth를 한 번 읽어 강제로 리플로우를 일으키고 나서 다시 붙입니다.
var input = document.getElementById('rs-pw');
document.getElementById('rs-check').addEventListener('pointerdown', function () {
input.classList.remove('rs--shake');
void input.offsetWidth;
input.classList.add('rs--shake');
});
09모션 축소 시 아웃라인 대체
평소에는 01과 같은 방식으로 흔들리지만, 시스템에서 동작 줄이기를 켜면 흔들림 대신 빨간 테두리 링이 두 번 깜빡이는 것으로 바뀝니다. 움직임을 꺼도 오류가 있다는 신호까지 함께 사라지지 않게 하려는 것입니다.
@media (prefers-reduced-motion: reduce) {
.rm__textarea.rm--invalid { animation: none !important; }
.rm__ring.rm--invalid { animation: rm-ring-pulse $dur-loop ease-in-out; }
}
@keyframes rm-ring-pulse {
0%, 100% { opacity: 0; }
25% { opacity: 1; }
50% { opacity: 0; }
75% { opacity: 1; }
}
어디서 깨지는가 — 함정 하나
여덟 개(01~07·09)는 CSS 애니메이션이 한 번 재생되면 끝이라는 공통 함정을 가지고 있습니다. 같은 값으로 또 틀려도 클래스가 이미 붙어 있으면 브라우저는 "이미 재생 중이던 애니메이션"으로 보고 다시 틀어 주지 않습니다. 08번 항목이 이 문제를 classList.remove() → offsetWidth 강제 읽기 → classList.add() 세 단계로 고친 코드를 담고 있고, 나머지 항목에도 같은 패턴을 그대로 옮겨 쓸 수 있습니다. :user-invalid(07)를 쓴다면 이 문제 자체가 없습니다 — 값이 바뀔 때마다 브라우저가 상태를 다시 계산해 스스로 재생하기 때문입니다. 아홉 개 전체 vanilla·React 코드가 든 압축 파일은 3v34eb5k으로 열립니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce에서 animation: none !important으로 움직임을 끕니다. 다만 04·09처럼 움직임이 유일한 오류 신호인 항목은 애니메이션만 끄면 오류 자체가 안 보일 수 있어, 09는 흔들림 대신 opacity로 깜빡이는 테두리 링을 대신 켜 두고 나머지 항목은 문구 텍스트가 항상 함께 보이게 했습니다. :user-invalid 같은 네이티브 검증 의사 클래스는 MDN 문서에 따르면 Chrome/Edge 119+·Firefox 88+·Safari 16.4+에서 동작하고, 그 이전 버전은 조용히 무시되니 결제·회원가입처럼 놓치면 안 되는 폼은 01·05 같은 클래스 기반 방식을 기본으로 두는 걸 권합니다.
탭이 바뀔 때 인디케이터가 미끄러지는 동작이 궁금하면 CSS 탭 메뉴 전환 애니메이션 9종을, 이 사이트가 오류 신호 말고 또 뭘 다루는지는 CSS 애니메이션 카테고리에서 볼 수 있습니다.
FAQ
흔들림 애니메이션은 왜 opacity나 transform만 써야 하나요?
margin이나 left 같은 속성을 흔들면 브라우저가 매 프레임 레이아웃을 다시 계산해야 해서 버벅이고, 흔들리는 요소 옆 칸까지 따라 밀립니다. transform과 opacity는 레이아웃을 다시 계산하지 않고 화면에 이미 그려진 픽셀만 옮기거나 흐리게 해서 끝나 훨씬 매끄럽습니다.
:user-invalid랑 :invalid는 뭐가 다른가요?
:invalid는 값이 비어 있거나 형식이 틀리면 페이지가 열리자마자 곧바로 매치됩니다. :user-invalid는 사용자가 실제로 값을 만지고 포커스를 벗어난 뒤에만 매치되어서, 아직 아무것도 입력 안 한 빈칸을 열자마자 빨갛게 흔드는 실수를 막아 줍니다.
흔들림을 한 번만 재생하려면 어떻게 하나요?
08번처럼 클래스를 지우고 offsetWidth를 한 번 읽어 강제로 리플로우를 일으킨 뒤 다시 클래스를 붙이면 같은 클래스라도 매번 처음부터 재생됩니다. React라면 이 대신 요소의 key를 바꿔 통째로 새로 마운트하는 쪽이 더 자연스럽습니다.