CSS 카드 애니메이션 호버 효과 9종 — 한 줄 복붙으로
CSS 카드 애니메이션은 카드 컴포넌트가 마우스나 포커스에 반응해 뜨거나 빛나거나 기울어지는 짧은 시각 변화를 말합니다. 카드 호버 효과 아홉 가지를 한 번에 모았고, 커서 좌표가 필요한 두 개는 JS를 10줄 안팎만 씁니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기 순위가 아니라 화면에서 카드를 고르는 사람의 눈에 걸리는 순서로 놓았습니다. 먼저 존재감을 드러내고(01~02), 기울고 내용을 더 보여주고(03~05), 눌러보라고 몸짓하고(06~08), 마지막으로 실제로 눌렀을 때 답합니다(09). 아래 코드는 핵심만 옮겼고 완결본은 zip에 있습니다.
01들림+그림자
카드에 마우스를 올리면 8px 위로 떠오르며 그림자가 깊어져, 눌리기 전인데도 그리드 안에서 먼저 도드라집니다. 상품 카드나 글 목록처럼 여러 장이 나란히 놓이는 자리에 씁니다.
.card {
transform: translateY(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:hover,
.card:focus-visible,
.card:active {
transform: translateY(-8px);
box-shadow: $shadow-raised;
}
02테두리 빛 회전
카드 테두리 안쪽에 conic-gradient 빛 한 줄기를 두고 카드에 overflow: hidden을 걸어 가둔 채 2초마다 한 바퀴 돌립니다. 각도는 @property로 등록한 --angle을 keyframes로 0deg에서 360deg까지 옮겨 매끄럽게 이어지게 했습니다.
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.card__ring {
background: conic-gradient(from var(--angle), transparent 0deg, $color 40deg, transparent 100deg);
opacity: 0;
}
.card:hover .card__ring,
.card.is-demo .card__ring {
opacity: 1;
animation: spin $duration linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
033D 틸트 시차
커서가 카드 위 어디 있는지에 따라 rotateX·rotateY로 기울고, 배경 레이어는 반대 방향으로 살짝 밀려 층이 갈라진 것처럼 보입니다. 좌표 계산은 JS 여덟 줄이 맡고 CSS는 그 값을 변수로만 받습니다.
card.addEventListener('pointermove', function (e) {
var r = card.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width - .5;
var py = (e.clientY - r.top) / r.height - .5;
card.style.setProperty('--rx', (-py * 14) + 'deg');
card.style.setProperty('--ry', (px * 16) + 'deg');
card.style.setProperty('--pz', (px * -12) + 'px');
});
04내용 슬라이드업
이미지 아래 숨어 있던 안내 패널이 호버하면 이미지 위로 슬라이드업해, 카드 크기는 그대로 둔 채 정보만 하나 더 보여줍니다.
.card__panel {
transform: translateY(70%);
transition: transform $duration $easing;
}
.card:hover .card__panel,
.card.is-demo .card__panel {
transform: translateY(0);
}
05배경 이미지 확대
카드를 덮은 배경 전체가 1.12배 확대되고, overflow: hidden이 카드의 둥근 모서리 밖을 잘라냅니다. 매끈한 그러데이션만으로는 확대가 잘 안 보여서 해와 언덕 모양의 도형을 더했습니다.
.card { overflow: hidden; }
.card__bg {
transform: scale(1);
transition: transform $duration $easing;
}
.card:hover .card__bg,
.card.is-demo .card__bg {
transform: scale(1.12);
}
06아이콘 튀어오름
카드 모서리의 원형 화살표 배지가 호버하면 공처럼 두 번 튀어 올라, 눌러도 된다는 걸 몸짓으로 먼저 보여줍니다.
.card__icon { transform: translateY(0); }
.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
animation: bounce $duration $easing 2;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
07그라데이션 테두리
평소엔 얇은 회색 테두리인 카드가 호버하면 두 색 그라데이션 테두리로 바뀝니다. mask-composite로 안쪽만 파낸 층의 opacity만 0에서 1로 바꿔, 레이아웃은 전혀 흔들리지 않습니다.
.card__border {
background: linear-gradient(135deg, $color, $subject-cream);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity $duration $easing;
}
.card:hover .card__border,
.card.is-demo .card__border {
opacity: 1;
}
08커서 스포트라이트
커서 좌표를 CSS 변수 두 개로 넘기면 radial-gradient 빛이 그 지점에서 카드 표면을 비춥니다. --mx --my --sp 세 값을 @property로 등록해 스포트라이트가 순간 이동 없이 미끄러지듯 움직이게 했습니다.
card.addEventListener('pointermove', function (e) {
var r = card.getBoundingClientRect();
card.style.setProperty('--mx', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--my', ((e.clientY - r.top) / r.height * 100) + '%');
card.style.setProperty('--sp', 1);
});
09눌림 피드백
카드를 실제로 누르면 scale(1)이 scale(.97)로 살짝 줄고 그림자도 얕아져, 손끝에 닿았다는 확인을 줍니다. 호버로는 반응하지 않고 누르는 동안에만 움직입니다.
.card {
transform: scale(1);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.card:active {
transform: scale(.97);
box-shadow: $shadow-press;
}
어디서 깨지는가 — 함정 하나
05 배경 확대를 처음 만들 때는 파란색에서 크림색으로 흐르는 매끈한 linear-gradient 하나만 배경에 깔고 scale(1.12)만 걸었습니다. 렌더한 뒤 측정값을 열어 보니 누적면적 0.00%·강도 0.0, 스물세 프레임이 전부 똑같았습니다. 프레임 두 장을 직접 픽셀 단위로 비교하자 실제로는 최대 11만큼 색이 바뀌고 있었는데, 설정의 픽셀문턱이 12라서 전부 "변화 없음"으로 걸러진 것이었습니다. MDN의 linear-gradient() 문서를 다시 읽어 보면 알 수 있듯, 매끈한 그러데이션은 확대해도 이웃 픽셀끼리 색이 너무 가까워 문턱을 못 넘습니다. 경계가 뚜렷한 원과 곡선 도형을 그러데이션 위에 얹었더니 같은 배율에서 강도 41.3·누적면적 0.68%로 정상 통과했습니다. 이 기록이 담긴 압축 파일의 비밀번호는 45c9tna8이고, 몰랐다면 05는 지금도 "안 움직이는 카드"로 남아 있었을 겁니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 움직임만 끄고 마지막 피드백은 남깁니다. 01은 뜨지 않고 그림자만 짙게, 02는 빛이 돌지 않고 옅게 고정, 03은 기울지 않되 :focus-visible에서 기본 각도만 보여주고, 04는 패널이 처음부터 펼쳐진 채로, 05는 확대 없이 정지, 06은 배지가 튀지 않고, 07은 전환만 빠져 테두리가 즉시 켜지고 꺼지고, 08은 빛이 꺼진 채 멈추고, 09는 그림자만 얕아집니다. :hover뿐 아니라 :focus-visible·:active에도 같은 최종 상태를 걸어 키보드·터치 사용자가 마우스 사용자와 같은 결과를 봅니다. @property를 쓴 02·08의 각도·좌표 보간은 caniuse의 @property 지원표에서 Chrome·Safari·Firefox 모두 확인할 수 있습니다. 카드 말고 커서·타이핑처럼 다른 피드백 효과가 궁금하면 분류 페이지에서 더 찾을 수 있고, 이 사이트가 무엇을 주는지는 소개 페이지에 적어 두었습니다.
FAQ
08 스포트라이트는 터치 화면에서도 커서를 따라갑니까?
아니요, pointermove는 터치에서 좌표를 계속 주지 않아 08은 --sp 기본값 0으로 멈춰 있습니다. 대신 :focus-visible·:active에서 카드 중앙에 고정 스포트라이트를 켜서 손가락으로 눌러도 반응은 남게 했습니다.
아홉 개를 한 화면에 다 같이 써도 됩니까?
됩니다만 02·08처럼 계속 도는 애니메이션은 한 화면에 한둘만 두는 게 낫습니다. 나머지 일곱 개는 호버·포커스·프레스 같은 사용자 동작에 걸려 있어 여러 장을 함께 둬도 화면이 산만해지지 않습니다.
React 판에서 03·08의 커서 좌표는 어떻게 옮겼습니까?
두 컴포넌트 모두 useRef로 카드 엘리먼트를 잡고 useCallback으로 감싼 pointermove 핸들러 안에서 style.setProperty로 CSS 변수를 직접 씁니다. state를 거치지 않아 좌표가 바뀔 때마다 컴포넌트가 다시 렌더되지 않습니다.