가져다 쓰세요: hover하면 긴 스크린샷이 카드 안에서 스크롤되는 포트폴리오 카드 (React + CSS)
발행
요약
긴 웹페이지 캡처를 고정 비율 카드 안에 넣고, 마우스를 올리거나 키보드로 포커스하면 CSS transform으로 맨 아래까지 내려가는 React 컴포넌트입니다. 의존성은 React뿐이고 색과 크기는 모두 CSS 사용자 지정 속성으로 바꿀 수 있습니다. 모션 줄이기 설정을 켠 사용자에게는 움직이지 않고, 카드 비율을 먼저 고정해 이미지가 늦게 와도 레이아웃이 밀리지 않습니다.
포트폴리오 카드에 긴 웹페이지 캡처를 넣고, 마우스를 올리면 카드 안에서 맨 아래까지 천천히 내려가는 컴포넌트입니다. JavaScript 애니메이션 없이 CSS transform 하나로 움직이고, 키보드 포커스에도 같은 동작을 하며, 모션 줄이기 설정을 존중합니다. 아래 코드는 헤드리스 Chromium에서 실제로 렌더해 확인한 코드 그대로입니다. 가져다 쓰세요.
동작 원리
카드 창은 aspect-ratio로 비율을 고정하고 넘치는 부분을 숨깁니다. 그 안의 이미지를 위로 끌어올리면 됩니다. 끌어올릴 거리는 "이미지 높이 빼기 창 높이"입니다.
- 이미지 높이:
translateY의 퍼센트는 요소 자신의 높이를 기준으로 하므로-100%가 이미지 높이만큼입니다. - 창 높이: 창을 container로 지정하면
100cqi가 창의 가로 길이가 됩니다. 이것을 비율로 나누면 창 높이입니다.
그래서 이동 거리는 calc(-100% + 100cqi / (var(--shot-ratio))) 한 줄로 정해집니다. 스크린샷 길이가 카드마다 달라도 같은 CSS로 끝까지 내려갑니다.
transform은 레이아웃을 다시 계산하지 않는 속성입니다. web.dev는 레이아웃 이동 없이 애니메이션하려면 높이나 위치 대신 transform을 쓰라고 안내합니다.
컴포넌트 코드
ScrollShotCard.tsx
import type { CSSProperties } from "react";
import "./scroll-shot-card.css";
type ScrollShotCardProps = {
href: string;
src: string;
/** 스크린샷 원본 가로 픽셀 */
width: number;
/** 스크린샷 원본 세로 픽셀 (카드 창보다 길어야 합니다) */
height: number;
title: string;
meta?: string;
/** 링크 이름은 title 이 맡으므로 기본값은 빈 문자열입니다 */
alt?: string;
/** 카드 창 비율, 예: "4 / 3" */
ratio?: string;
/** 맨 아래까지 내려가는 시간, 예: "6s" */
duration?: string;
};
export function ScrollShotCard({
href,
src,
width,
height,
title,
meta,
alt = "",
ratio = "4 / 3",
duration = "6s",
}: ScrollShotCardProps) {
const style = {
"--shot-ratio": ratio,
"--shot-duration": duration,
} as CSSProperties;
return (
<a className="shot-card" href={href} style={style}>
<span className="shot-card__frame">
<img
className="shot-card__img"
src={src}
width={width}
height={height}
alt={alt}
loading="lazy"
decoding="async"
/>
</span>
<span className="shot-card__body">
<span className="shot-card__title">{title}</span>
{meta ? <span className="shot-card__meta">{meta}</span> : null}
</span>
</a>
);
}
스타일
scroll-shot-card.css
.shot-card {
/* 크기와 시간 */
--shot-ratio: 4 / 3;
--shot-duration: 6s;
--shot-return: 0.6s;
--shot-radius: 12px;
--shot-pad: 16px;
--shot-gap: 4px;
--shot-title-size: 1rem;
--shot-meta-size: 0.875rem;
--shot-focus-width: 3px;
--shot-focus-offset: 3px;
/* 색 */
--shot-bg: #ffffff;
--shot-fg: #15181d;
--shot-muted: #5a6270;
--shot-border: #d8dce2;
--shot-frame-bg: #eef0f3;
--shot-focus: #1f5fd6;
display: block;
color: var(--shot-fg);
text-decoration: none;
background: var(--shot-bg);
border: 1px solid var(--shot-border);
border-radius: var(--shot-radius);
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
.shot-card {
--shot-bg: #16191e;
--shot-fg: #eceef1;
--shot-muted: #a3abb7;
--shot-border: #2c3139;
--shot-frame-bg: #0f1114;
--shot-focus: #7aa7ff;
}
}
.shot-card:focus-visible {
outline: var(--shot-focus-width) solid var(--shot-focus);
outline-offset: var(--shot-focus-offset);
}
/* 비율을 먼저 고정해 이미지가 늦게 와도 자리가 흔들리지 않게 합니다 */
.shot-card__frame {
display: block;
aspect-ratio: var(--shot-ratio);
overflow: hidden;
container-type: inline-size;
background: var(--shot-frame-bg);
}
.shot-card__img {
/* 이동 거리 = 이미지 높이 - 창 높이(창 너비 / 비율) */
--shot-shift: calc(-100% + 100cqi / (var(--shot-ratio)));
display: block;
width: 100%;
height: auto;
transform: translateY(0);
transition: transform var(--shot-return) ease-out;
}
.shot-card__body {
display: flex;
flex-direction: column;
gap: var(--shot-gap);
padding: var(--shot-pad);
}
.shot-card__title {
font-size: var(--shot-title-size);
font-weight: 600;
}
.shot-card__meta {
font-size: var(--shot-meta-size);
color: var(--shot-muted);
}
/* 움직임은 사용자가 줄이기를 켜지 않았을 때만 붙입니다 */
@media (prefers-reduced-motion: no-preference) {
.shot-card:focus-visible .shot-card__img {
transform: translateY(var(--shot-shift));
transition: transform var(--shot-duration) ease-in-out;
}
}
/* 터치 화면에는 hover 가 없으므로 마우스가 있을 때만 hover 로 움직입니다 */
@media (prefers-reduced-motion: no-preference) and (hover: hover) and (pointer: fine) {
.shot-card:hover .shot-card__img {
transform: translateY(var(--shot-shift));
transition: transform var(--shot-duration) ease-in-out;
}
}
사용 예
작업 목록을 그리드로 그리는 예입니다. 스크린샷의 원본 크기를 width, height로 꼭 넘겨 주세요.
import { ScrollShotCard } from "./ScrollShotCard";
const works = [
{ slug: "brand-site", title: "브랜드 사이트 리뉴얼", meta: "웹 · Next.js", src: "/shots/brand-site.svg", height: 4200 },
{ slug: "shop", title: "자사몰 상세 페이지", meta: "커머스 · 디자인", src: "/shots/shop.svg", height: 5400 },
{ slug: "saas", title: "SaaS 랜딩", meta: "랜딩 · SEO", src: "/shots/saas.svg", height: 3600 },
];
export function WorkGrid() {
return (
<ul className="work-grid">
{works.map((w) => (
<li key={w.slug}>
<ScrollShotCard
href={`/work/${w.slug}`}
src={w.src}
width={1200}
height={w.height}
title={w.title}
meta={w.meta}
/>
</li>
))}
</ul>
);
}
그리드 스타일은 사이트에 맞게 정하면 됩니다. 확인할 때는 아래 스타일을 썼습니다.
.work-grid {
--grid-gap: 24px;
--grid-min: 280px;
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--grid-gap);
grid-template-columns: repeat(auto-fill, minmax(var(--grid-min), 1fr));
}
접근성과 모션 처리
- 키보드: 카드 전체가
<a>링크라서 Tab으로 포커스됩니다.:focus-visible일 때 hover와 같은 이동이 일어나고, 3px 외곽선이 보입니다. - 모션 줄이기: MDN은
prefers-reduced-motion을 기기에서 불필요한 움직임을 줄이는 설정을 감지하는 기능으로 설명하고, 큰 객체의 확대나 이동이 전정기관 장애가 있는 사람에게 불편을 줄 수 있다고 적습니다. 이 컴포넌트는 움직임 규칙을no-preference조건 안에만 둡니다. WCAG 2.2의 2.3.3(AAA)은 상호작용으로 생기는 모션 애니메이션을 끌 수 있어야 한다고 요구합니다. - 터치 기기:
(hover: hover) and (pointer: fine)조건으로 마우스 환경에서만 hover 이동을 켭니다. - 대체 텍스트: 링크의 이름은 제목 텍스트가 맡습니다. 그래서
alt기본값을 빈 문자열로 두어 스크린리더가 같은 내용을 두 번 읽지 않게 했습니다. 캡처 자체에 꼭 전달할 정보가 있다면alt를 넘기세요.
CLS를 막는 장치
web.dev는 크기를 모르는 이미지와 영상이 레이아웃 이동의 원인이 된다고 설명합니다. 이 카드는 두 겹으로 막습니다.
- 카드 창에
aspect-ratio를 지정해 이미지가 오기 전에도 창 크기가 정해집니다. 창에overflow: hidden이 있어 긴 이미지가 창을 늘리지 않습니다. <img>에 원본width,height를 넣어 이미지 상자 높이도 로딩 전에 정해집니다. 그래서 이동 거리 계산도 로딩 전후가 같습니다.
aspect-ratio는 MDN 기준 2021년 9월부터 주요 브라우저에서 쓸 수 있는 속성입니다. cqi는 MDN 정의상 쿼리 컨테이너 인라인 크기의 1%입니다. 이 단위를 지원하지 않는 브라우저에서는 이동 계산식이 무효가 되어 카드가 움직이지 않고 캡처 맨 위만 보입니다.
확인한 것
Playwright 1.58.2의 헤드리스 Chromium에서 위 코드를 그대로 번들해 렌더하고 아래 항목을 측정했습니다. 이미지는 서버에서 1.5초 늦게 보내 CLS를 일부러 유발해 봤습니다.
| 확인 항목 | 결과 |
|---|---|
| 콘솔 오류와 경고, 페이지 오류, 실패한 요청 | 0건 |
| 이미지 로딩 전후 카드 창 크기 | 284 x 213px로 같음 (4:3) |
| 누적 CLS (로딩, hover 이후 포함) | 0 |
| hover 1초 뒤 이미지 위치 | translateY 약 -44px, 이동 중 |
| hover 약 7초 뒤 이미지 위치 | -781px, 계산값(이미지 994px 빼기 창 213px)과 일치 |
| 마우스를 뗀 뒤 | 0으로 복귀 |
| Tab 포커스 | :focus-visible 적용, 외곽선 3px, 같은 거리만큼 이동 |
| 모션 줄이기 켬 | hover와 포커스 모두 이동 0 |
| 390px 폭 터치 환경 | hover 조건 false, 가로 스크롤 없음 |
서버 렌더링(renderToString) |
링크, 이미지 크기, CSS 변수가 HTML에 포함 |
바꿔 쓰는 법
색과 크기는 모두 .shot-card의 사용자 지정 속성으로 모았습니다. 디자인 토큰이 있다면 이 값만 연결하면 됩니다.
| 속성 | 기본값 | 역할 |
|---|---|---|
--shot-ratio |
4 / 3 |
카드 창 비율. ratio prop으로도 바꿉니다 |
--shot-duration |
6s |
맨 아래까지 내려가는 시간. duration prop으로도 바꿉니다 |
--shot-return |
0.6s |
손을 뗐을 때 위로 돌아오는 시간 |
--shot-radius |
12px |
카드 모서리 |
--shot-pad, --shot-gap |
16px, 4px |
제목 영역 여백과 간격 |
--shot-bg, --shot-fg, --shot-muted |
밝은 배경 기준 | 배경, 제목, 보조 글자 색 |
--shot-border, --shot-frame-bg |
회색 계열 | 테두리와 이미지 로딩 전 배경 |
--shot-focus, --shot-focus-width |
파랑, 3px |
포커스 외곽선 |
몇 가지 팁입니다.
- 캡처가 아주 길면
duration을 늘리세요. 같은 시간에 더 먼 거리를 움직이면 너무 빠르게 느껴집니다. - 첫 화면에 바로 보이는 카드는
loading="lazy"를 빼는 편이 LCP에 유리할 수 있습니다. 해당 줄만 지우면 됩니다. - 다크 모드는
prefers-color-scheme: dark에서 색 속성만 바꿉니다. 사이트에 테마 전환 버튼이 있다면 그 선택자로 옮기세요.
출처
- web.dev, Cumulative Layout Shift (CLS): https://web.dev/articles/cls
- MDN, prefers-reduced-motion: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion
- MDN, aspect-ratio: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/aspect-ratio
- MDN, length (container query 단위): https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length
- W3C, Understanding SC 2.3.3 Animation from Interactions: https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html
자주 묻는 질문
JavaScript로 스크롤 위치를 계산하지 않아도 되나요?
필요 없습니다. 카드 창 높이는 container query 단위(cqi)와 비율로, 이미지 높이는 translateY의 퍼센트로 계산합니다. 이동 거리 계산이 모두 CSS 안에서 끝나므로 이벤트 핸들러가 없습니다.
휴대폰에서는 어떻게 보이나요?
터치 화면에는 hover가 없으므로 hover 이동은 마우스가 있는 환경에서만 켜집니다. 휴대폰에서는 캡처의 맨 위 부분이 고정 이미지처럼 보이고, 카드를 누르면 링크로 이동합니다.
모션 줄이기를 켠 사용자에게는 어떻게 동작하나요?
움직임 규칙을 prefers-reduced-motion: no-preference 조건 안에만 넣었습니다. 설정을 켠 사용자에게는 hover나 포커스를 해도 이미지가 움직이지 않습니다.
스크린샷이 카드보다 짧으면 어떻게 되나요?
이동 거리가 양수가 되어 이미지가 아래로 내려가 버립니다. 이 카드는 카드 창보다 세로로 긴 캡처를 넣는 용도로 만들었습니다. 짧은 이미지에는 일반 이미지 카드를 쓰세요.
Next.js에서도 쓸 수 있나요?
상태와 이벤트 핸들러가 없는 컴포넌트라 서버 렌더링 결과에도 그대로 들어갑니다. renderToString으로 렌더한 HTML에 링크, 이미지 크기, CSS 변수가 모두 들어가는 것을 확인했습니다. CSS는 프로젝트 방식에 맞게 전역 스타일이나 CSS 모듈로 옮겨 쓰면 됩니다.
이 글과 관련된 작업이 필요하다면 알려 주세요.