CSS 화면 전환 효과 9종 — 화면이 바뀌는 순간 모음
CSS 화면 전환 효과는 페이지를 새로 불러오지 않고 한 화면 안에서 무엇이 다른 모습으로 바뀔 때, 그 바뀜을 부드럽게 눈에 보여 주는 장치입니다.
01 blinds-slat-reveal
02 polaroid-swing-in
03 view-switch-grid-list
04 product-image-swap
05 chip-slot-fill
06 fly-to-cart
07 ticket-tear-confirm
08 wizard-step-slide
09 split-diagonal-swap
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 브라인드 살 열림 등장
- 02 폴라로이드 그네 등장
- 03 격자↔목록 뷰 전환
- 04 큰 이미지 슬라이드 교체
- 05 필터 칩 슬롯 채움
- 06 장바구니로 날아가는 담기
- 07 티켓 찢김 확인
- 08 단계 방향 슬라이드
- 09 대각선 장면 교체
아홉 개는 인기 순서가 아니라 방문자가 화면에서 바뀜을 만나는 순서로 놓았습니다. 처음 장면이 브라인드처럼 열리고(01) 사진이 그네처럼 걸려 들어오고(02), 둘러보는 방식이 격자와 목록 사이를 오가고(03) 눈길 끄는 큰 사진이 같은 자리에서 교체되고(04), 고른 조건이 칩으로 슬롯에 날아가 채워지고(05), 담은 상품이 점이 되어 장바구니까지 포물선을 그려 날아가고(06), 확인 순간 티켓이 찢어지고(07), 폼이 단계가 바뀌는 방향에 맞춰 흐르다가(08), css 모달 팝업처럼 마지막에 장면이 대각선으로 쓸리듯 통째로 교체됩니다(09). 바꿈의 폭을 탭 몇 개 안으로 좁히면 CSS 탭 메뉴 전환 애니메이션 9종이 되고, 아래쪽 독으로 옮기면 플로팅 내비게이션 독 9종이 됩니다.
| 번호 | 이름 | 무엇이 움직이나 | JS |
|---|---|---|---|
| 01 | 브라인드 살 열림 등장 | 살 6개 scaleY 계단 접힘 |
없음 |
| 02 | 폴라로이드 그네 등장 | 카드 rotate 감쇠 진동 |
없음 |
| 03 | 격자↔목록 뷰 전환 | 카드 translate 좌표 |
없음 |
| 04 | 큰 이미지 슬라이드 교체 | 장면 translateX 교차 |
없음 |
| 05 | 필터 칩 슬롯 채움 | 칩 translate 비행 |
없음 |
| 06 | 장바구니로 날아가는 담기 | 점 이중 translate 포물선 |
없음 |
| 07 | 티켓 찢김 확인 | 두 반 rotate 분리 |
없음 |
| 08 | 단계 방향 슬라이드 | 패널 translateX ±100% |
없음 |
| 09 | 대각선 장면 교체 | skew 판 통과 |
없음 |
01브라인드 살 열림 등장
css 페이지 전환 애니메이션에서 무엇이 보이나
페이지가 열리면 가로 브라인드 여섯 개가 위쪽부터 차례로 접히며 덮고 있던 밝은 문구 패널을 드러냅니다.

<main class="stage">
<section class="reveal is-demo" aria-label="봄 시즌 오픈 문구가 브라인드가 접히며 나타난다">
<div class="reveal__panel">
<p class="reveal__kicker">봄 시즌 오픈</p>
<h2 class="reveal__headline">새 컬렉션 20% 할인</h2>
</div>
<div class="reveal__slats" aria-hidden="true">
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.2s; // 브라인드 한 살이 접히는 시간
$easing: $ease-out;
$color: $subject-blue; // 패널 그라데이션 시작색
@include stage($stage-ink);
.reveal {
position: relative;
width: min(320px, 88vw);
height: clamp(150px, 52vh, 180px);
border-radius: $r-card;
overflow: hidden;
}
.reveal__panel {
position: absolute; inset: 0;
display: grid;
align-content: center;
gap: $sp-2;
padding: $sp-4;
background: linear-gradient(135deg, $color 0%, $stage-yellow 100%);
}
.reveal__kicker {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: $stage-ink;
letter-spacing: 2px;
}
.reveal__headline {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.4vw, 26px);
line-height: 1.25;
font-family: inherit;
color: $stage-paper;
}
.reveal__slats {
position: absolute; inset: 0;
display: flex;
flex-direction: column;
}
.reveal__slat {
flex: 1;
background: $stage-paper;
border-bottom: 2px solid rgba(23, 20, 26, .12);
transform-origin: top center; // 규칙8: 윗변서 접힘
transform: scaleY(1);
}
// 실사용: 페이지 첫 등장에 한 번 — 살마다 90ms 늦게 위→아래로 접힌다
@keyframes slat-open { from { transform: scaleY(1); } to { transform: scaleY(0); } }
@for $i from 1 through 6 {
.reveal__slat:nth-child(#{$i}) {
animation: slat-open $duration $easing (($i - 1) * 90ms) both;
}
}
// 렌더·갤러리용 자동 루프: 닫힘 → 열림 파동 → 다시 닫힘 (2초 주기 안에서 계단 재현)
@for $i from 1 through 6 {
.reveal.is-demo .reveal__slat:nth-child(#{$i}) {
animation: slat-loop-#{$i} $dur-loop $easing infinite;
}
@keyframes slat-loop-#{$i} {
0%, 8% { transform: scaleY(1); }
#{10 + $i * 6}%, 78% { transform: scaleY(0); }
92%, 100% { transform: scaleY(1); }
}
}
@media (prefers-reduced-motion: reduce) {
.reveal__slat,
.reveal.is-demo .reveal__slat {
animation: none;
transform: scaleY(0); // 접힌 최종 상태 — 문구가 바로 보인다
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.reveal {
position: relative;
width: min(320px, 88vw);
height: clamp(150px, 52vh, 180px);
border-radius: 16px;
overflow: hidden;
}
.reveal__panel {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 8px;
padding: 16px;
background: linear-gradient(135deg, #2f6df6 0%, #ffd23f 100%);
}
.reveal__kicker {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: #17141a;
letter-spacing: 2px;
}
.reveal__headline {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.4vw, 26px);
line-height: 1.25;
font-family: inherit;
color: #ffffff;
}
.reveal__slats {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.reveal__slat {
flex: 1;
background: #ffffff;
border-bottom: 2px solid rgba(23, 20, 26, 0.12);
transform-origin: top center;
transform: scaleY(1);
}
@keyframes slat-open {
from {
transform: scaleY(1);
}
to {
transform: scaleY(0);
}
}
.reveal__slat:nth-child(1) {
animation: slat-open 1.2s ease-out 0ms both;
}
.reveal__slat:nth-child(2) {
animation: slat-open 1.2s ease-out 90ms both;
}
.reveal__slat:nth-child(3) {
animation: slat-open 1.2s ease-out 180ms both;
}
.reveal__slat:nth-child(4) {
animation: slat-open 1.2s ease-out 270ms both;
}
.reveal__slat:nth-child(5) {
animation: slat-open 1.2s ease-out 360ms both;
}
.reveal__slat:nth-child(6) {
animation: slat-open 1.2s ease-out 450ms both;
}
.reveal.is-demo .reveal__slat:nth-child(1) {
animation: slat-loop-1 2s ease-out infinite;
}
@keyframes slat-loop-1 {
0%, 8% {
transform: scaleY(1);
}
16%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(2) {
animation: slat-loop-2 2s ease-out infinite;
}
@keyframes slat-loop-2 {
0%, 8% {
transform: scaleY(1);
}
22%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(3) {
animation: slat-loop-3 2s ease-out infinite;
}
@keyframes slat-loop-3 {
0%, 8% {
transform: scaleY(1);
}
28%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(4) {
animation: slat-loop-4 2s ease-out infinite;
}
@keyframes slat-loop-4 {
0%, 8% {
transform: scaleY(1);
}
34%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(5) {
animation: slat-loop-5 2s ease-out infinite;
}
@keyframes slat-loop-5 {
0%, 8% {
transform: scaleY(1);
}
40%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(6) {
animation: slat-loop-6 2s ease-out infinite;
}
@keyframes slat-loop-6 {
0%, 8% {
transform: scaleY(1);
}
46%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.reveal__slat,
.reveal.is-demo .reveal__slat {
animation: none;
transform: scaleY(0);
}
}import type { CSSProperties } from "react";
import styles from "./BlindsSlatReveal.module.scss";
export type BlindsSlatRevealProps = {
kicker: string;
headline: string;
/** 살이 전부 접히는 데 걸리는 시간 (기본 1200ms) */
duration?: number;
easing?: string;
/** 패널 그라데이션 시작색 */
color?: string;
/** 블라인드 살 개수 (기본 6) */
slats?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BlindsSlatReveal({
kicker,
headline,
duration = 1200,
easing = "ease-out",
color = "#2f6df6",
slats = 6,
}: BlindsSlatRevealProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.reveal} style={vars} aria-label={headline}>
<div className={styles.panel}>
<p className={styles.kicker}>{kicker}</p>
<h2 className={styles.headline}>{headline}</h2>
</div>
<div className={styles.slats} aria-hidden="true">
{Array.from({ length: slats }, (_, i) => (
<span key={i} className={styles.slat} style={{ animationDelay: `${i * 90}ms` }} />
))}
</div>
</section>
);
}01 · 브라인드 살 열림 등장 — 측정 누적면적 33.702% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
히어로 문구나 이벤트 공지가 휙 하고 한 번에 바뀌는 게 아니라 살 하나씩 편안하게 열리는 것이 차이입니다.
.reveal__slat {
transform-origin: top center;
transform: scaleY(1);
animation: slat-open $duration $easing both;
}
@keyframes slat-open {
from { transform: scaleY(1); }
to { transform: scaleY(0); }
}
// 살마다 90ms 뒤로 밀어 계단처럼
.reveal__slat:nth-child(2) { animation-delay: 90ms; }
.reveal__slat:nth-child(3) { animation-delay: 180ms; }
02폴라로이드 그네 등장
화면에서 일어나는 일
상단 한 점에 매달린 폴라로이드 사진 카드가 기울어진 채 떨어져 내려와 좌우로 몇 번 흔들리다 반듯하게 멈춥니다.

<main class="stage">
<figure class="swing is-demo" aria-label="폴라로이드 사진 카드가 매달린 채 흔들리며 등장한다">
<span class="swing__pin" aria-hidden="true"></span>
<span class="swing__string" aria-hidden="true"></span>
<div class="swing__photo">
<span class="swing__shot" aria-hidden="true"></span>
<figcaption class="swing__caption">제주, 4월 바다</figcaption>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.1s; // 흔들림이 잦아들 때까지의 시간
$easing: $ease-pop; // 오버슈트 — 그네 감쇠 운동
$color: $subject-blue; // 사진 그라데이션
@include stage($stage-yellow);
.swing {
position: relative;
margin: 0;
width: min(190px, 56vw);
transform-origin: top center; // 한 점에 매달림
}
.swing__pin {
position: absolute;
top: -6px; left: 50%;
width: 12px; height: 12px;
margin-left: -6px;
border-radius: 50%;
background: $stage-ink;
}
.swing__string {
position: absolute;
top: 0; left: 50%;
width: 2px; height: 24px;
margin-left: -1px;
background: rgba(23, 20, 26, .4);
}
.swing__photo {
margin-top: 24px;
padding: $sp-2 $sp-2 $sp-1;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
}
.swing__shot {
display: block;
aspect-ratio: 1 / 1;
border-radius: $r-xs;
background: linear-gradient(160deg, $color 0%, $stage-yellow 90%);
}
.swing__caption {
padding-top: $sp-2;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: $stage-ink;
text-align: center;
}
// 실사용: 첫 등장 1회 — 기울어 떨어지며 좌우로 흔들리다 반듯하게 멈춘다
@keyframes swing-in {
0% { transform: translateY(-70px) rotate(18deg); opacity: 0; }
18% { transform: translateY(0) rotate(14deg); opacity: 1; }
38% { transform: rotate(-11deg); }
56% { transform: rotate(7deg); }
72% { transform: rotate(-4deg); }
86% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
.swing { animation: swing-in $duration $easing both; }
// 렌더·갤러리용 자동 루프: 등장→흔들림→사라짐을 2초 주기로
@keyframes swing-loop {
0% { transform: translateY(-70px) rotate(18deg); opacity: 0; }
12% { transform: translateY(0) rotate(14deg); opacity: 1; }
26% { transform: rotate(-11deg); }
38% { transform: rotate(7deg); }
48% { transform: rotate(-4deg); }
56% { transform: rotate(0deg); }
84%, 100% { transform: rotate(0deg); opacity: 1; }
}
.swing.is-demo { animation: swing-loop $dur-loop $easing infinite; }
@media (max-width: 360px) { // 320×200 아이프레임 안에 whole 카드가 들어오게
.swing { width: 112px; }
.swing__string { height: 16px; }
.swing__photo { margin-top: 16px; padding: 4px; }
.swing__caption { font-size: 10px; padding-top: 4px; }
.swing__pin { width: 8px; height: 8px; margin-left: -4px; top: -4px; }
}
@media (prefers-reduced-motion: reduce) {
.swing,
.swing.is-demo { animation: none; transform: none; } // 반듯하게 걸린 최종 상태
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.swing {
position: relative;
margin: 0;
width: min(190px, 56vw);
transform-origin: top center;
}
.swing__pin {
position: absolute;
top: -6px;
left: 50%;
width: 12px;
height: 12px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
}
.swing__string {
position: absolute;
top: 0;
left: 50%;
width: 2px;
height: 24px;
margin-left: -1px;
background: rgba(23, 20, 26, 0.4);
}
.swing__photo {
margin-top: 24px;
padding: 8px 8px 4px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.swing__shot {
display: block;
aspect-ratio: 1/1;
border-radius: 4px;
background: linear-gradient(160deg, #2f6df6 0%, #ffd23f 90%);
}
.swing__caption {
padding-top: 8px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: #17141a;
text-align: center;
}
@keyframes swing-in {
0% {
transform: translateY(-70px) rotate(18deg);
opacity: 0;
}
18% {
transform: translateY(0) rotate(14deg);
opacity: 1;
}
38% {
transform: rotate(-11deg);
}
56% {
transform: rotate(7deg);
}
72% {
transform: rotate(-4deg);
}
86% {
transform: rotate(2deg);
}
100% {
transform: rotate(0deg);
}
}
.swing {
animation: swing-in 1.1s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes swing-loop {
0% {
transform: translateY(-70px) rotate(18deg);
opacity: 0;
}
12% {
transform: translateY(0) rotate(14deg);
opacity: 1;
}
26% {
transform: rotate(-11deg);
}
38% {
transform: rotate(7deg);
}
48% {
transform: rotate(-4deg);
}
56% {
transform: rotate(0deg);
}
84%, 100% {
transform: rotate(0deg);
opacity: 1;
}
}
.swing.is-demo {
animation: swing-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (max-width: 360px) {
.swing {
width: 112px;
}
.swing__string {
height: 16px;
}
.swing__photo {
margin-top: 16px;
padding: 4px;
}
.swing__caption {
font-size: 10px;
padding-top: 4px;
}
.swing__pin {
width: 8px;
height: 8px;
margin-left: -4px;
top: -4px;
}
}
@media (prefers-reduced-motion: reduce) {
.swing,
.swing.is-demo {
animation: none;
transform: none;
}
}import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import styles from "./PolaroidSwingIn.module.scss";
export type PolaroidSwingInProps = {
caption: string;
/** 흔들림이 잦아들 때까지의 시간 (기본 1100ms) */
duration?: number;
easing?: string;
/** 사진 그라데이션 색 */
color?: string;
/** 다시 흔들려면 이 값을 바꾼다 */
replayKey?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PolaroidSwingIn({
caption,
duration = 1100,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
replayKey = 0,
}: PolaroidSwingInProps) {
const [run, setRun] = useState(0);
useEffect(() => { setRun(replayKey); }, [replayKey]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure key={run} className={styles.swing} style={vars} aria-label={caption}>
<span className={styles.pin} aria-hidden="true" />
<span className={styles.string} aria-hidden="true" />
<div className={styles.photo}>
<span className={styles.shot} aria-hidden="true" />
<figcaption className={styles.caption}>{caption}</figcaption>
</div>
</figure>
);
}02 · 폴라로이드 그네 등장 — 측정 누적면적 47.004% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
사진 갤러리 첫 화면이나 여행 기록 썸네일에 붙이면 정적인 사진 페이지에 온기가 생깁니다.
.swing {
transform-origin: top center; // 핀 한 점이 회전 축
animation: swing-in $duration $easing both;
}
@keyframes swing-in {
0% { transform: translateY(-70px) rotate(18deg); opacity: 0; }
38% { transform: rotate(-11deg); }
56% { transform: rotate(7deg); }
72% { transform: rotate(-4deg); }
100% { transform: rotate(0deg); }
}
03격자↔목록 뷰 전환
어떤 움직임인가
같은 상품 카드 세 장이 가로로 나란히 놓은 격자 배치와 한 줄씩 쌓은 목록 배치 사이를 떠나지 않고 미끄러져 오갑니다.

<main class="stage">
<section class="vs is-demo" aria-label="같은 카드 세 장이 격자 배치와 목록 배치 사이를 오간다">
<input class="vs__radio" type="radio" name="vs-218" id="vs-grid" checked>
<input class="vs__radio" type="radio" name="vs-218" id="vs-list">
<div class="vs__toggle" aria-hidden="true"><span class="vs__knob"></span></div>
<div class="vs__toggle-labels">
<label class="vs__label" for="vs-grid">격자</label>
<label class="vs__label" for="vs-list">목록</label>
</div>
<div class="vs__board">
<article class="vs__card vs__card--1"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">썸머 니트</span></article>
<article class="vs__card vs__card--2"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">린넨 셔츠</span></article>
<article class="vs__card vs__card--3"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">스트로우 햇</span></article>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 카드가 배치를 바꾸는 시간
$easing: $ease-spring;
$color: $subject-blue; // 썸네일 색
@include stage($stage-orange);
.vs { width: 280px; max-width: 92vw; }
.vs__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 토글: 라디오 + 미끄러지는 손잡이 (JS 0줄) — 시각 요소는 형제로
.vs__toggle {
position: relative;
width: 168px;
height: 28px;
margin: 0 auto;
padding: 4px;
box-sizing: border-box;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
}
.vs__knob {
position: absolute; top: 4px; bottom: 4px; left: 4px;
width: calc(50% - 4px);
box-sizing: border-box;
border-radius: $r-pill;
background: $stage-paper;
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration $easing;
}
#vs-list:checked ~ .vs__toggle .vs__knob { transform: translateX(100%); }
.vs__toggle-labels {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 168px;
margin: -28px auto $sp-3; // 글자를 토글 위에 얹는다
}
.vs__label {
padding: $sp-1 0;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: $stage-paper;
text-align: center;
cursor: pointer;
transition: color $dur-quick $easing;
}
#vs-grid:checked ~ .vs__toggle-labels .vs__label[for="vs-grid"],
#vs-list:checked ~ .vs__toggle-labels .vs__label[for="vs-list"] { color: $stage-ink; }
// ── 보드: 같은 카드 3장 (크기 고정), 배치만 transform 좌표로 바뀐다
.vs__board { position: relative; height: 200px; }
.vs__card {
position: absolute; left: 0; top: 0;
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
width: 136px;
height: 60px;
padding: $sp-2;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
transition: transform $duration $easing;
}
.vs__thumb {
flex: 0 0 auto;
width: 36px; height: 36px;
border-radius: $r-sm;
background: linear-gradient(150deg, $color, $stage-yellow);
}
.vs__name {
font-weight: 700;
font-size: 12px;
line-height: 1.2;
font-family: inherit;
color: $stage-ink;
}
// 격자 배치: 2열 — % 는 카드 자기 크기 기준이라 카드가 줄면 간격도 줄어든다
.vs__card--1 { transform: translate(0, 0); }
.vs__card--2 { transform: translate(calc(100% + 8px), 0); }
.vs__card--3 { transform: translate(0, calc(100% + 10px)); }
// 목록 배치: 한 줄씩 아래로
#vs-list:checked ~ .vs__board .vs__card--1 { transform: translate(0, 0); }
#vs-list:checked ~ .vs__board .vs__card--2 { transform: translate(0, calc(100% + 10px)); }
#vs-list:checked ~ .vs__board .vs__card--3 { transform: translate(0, calc(200% + 20px)); }
// 렌더·갤러리용 자동 루프: 격자→목록→격자를 2초 주기로
.vs.is-demo .vs__knob { animation: knob-loop $dur-loop $easing infinite; }
.vs.is-demo .vs__label[for="vs-grid"] { animation: lab-grid $dur-loop $easing infinite; }
.vs.is-demo .vs__label[for="vs-list"] { animation: lab-list $dur-loop $easing infinite; }
.vs.is-demo .vs__card--2 { animation: c2-loop $dur-loop $easing infinite; }
.vs.is-demo .vs__card--3 { animation: c3-loop $dur-loop $easing infinite; }
@keyframes knob-loop {
0%, 20%, 100% { transform: translateX(0); }
45%, 65% { transform: translateX(100%); }
}
@keyframes lab-grid { // 손잡이가 떠나자마자 색이 따라간다
0%, 24% { color: $stage-ink; }
32%, 68% { color: $stage-paper; }
78%, 100% { color: $stage-ink; }
}
@keyframes lab-list {
0%, 24% { color: $stage-paper; }
32%, 68% { color: $stage-ink; }
78%, 100% { color: $stage-paper; }
}
@keyframes c2-loop {
0%, 20%, 100% { transform: translate(calc(100% + 8px), 0); }
45%, 65% { transform: translate(0, calc(100% + 10px)); }
}
@keyframes c3-loop {
0%, 20%, 100% { transform: translate(0, calc(100% + 10px)); }
45%, 65% { transform: translate(0, calc(200% + 20px)); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.vs { width: 232px; }
.vs__toggle { width: 140px; height: 24px; }
.vs__toggle-labels { width: 140px; margin: -24px auto 8px; }
.vs__label { font-size: 11px; padding: 4px 0; }
.vs__board { height: 140px; }
.vs__card { width: 112px; height: 40px; padding: 4px; gap: 4px; }
.vs__thumb { width: 24px; height: 24px; }
.vs__name { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.vs__knob, .vs__card, .vs__label { transition: none; }
.vs.is-demo .vs__knob,
.vs.is-demo .vs__card,
.vs.is-demo .vs__label { animation: none; } // 즉시 목표 배치로 전환
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vs {
width: 280px;
max-width: 92vw;
}
.vs__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.vs__toggle {
position: relative;
width: 168px;
height: 28px;
margin: 0 auto;
padding: 4px;
box-sizing: border-box;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.vs__knob {
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
width: calc(50% - 4px);
box-sizing: border-box;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#vs-list:checked ~ .vs__toggle .vs__knob {
transform: translateX(100%);
}
.vs__toggle-labels {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 168px;
margin: -28px auto 12px;
}
.vs__label {
padding: 4px 0;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: #ffffff;
text-align: center;
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#vs-grid:checked ~ .vs__toggle-labels .vs__label[for=vs-grid],
#vs-list:checked ~ .vs__toggle-labels .vs__label[for=vs-list] {
color: #17141a;
}
.vs__board {
position: relative;
height: 200px;
}
.vs__card {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 136px;
height: 60px;
padding: 8px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vs__thumb {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(150deg, #2f6df6, #ffd23f);
}
.vs__name {
font-weight: 700;
font-size: 12px;
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.vs__card--1 {
transform: translate(0, 0);
}
.vs__card--2 {
transform: translate(calc(100% + 8px), 0);
}
.vs__card--3 {
transform: translate(0, calc(100% + 10px));
}
#vs-list:checked ~ .vs__board .vs__card--1 {
transform: translate(0, 0);
}
#vs-list:checked ~ .vs__board .vs__card--2 {
transform: translate(0, calc(100% + 10px));
}
#vs-list:checked ~ .vs__board .vs__card--3 {
transform: translate(0, calc(200% + 20px));
}
.vs.is-demo .vs__knob {
animation: knob-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__label[for=vs-grid] {
animation: lab-grid 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__label[for=vs-list] {
animation: lab-list 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__card--2 {
animation: c2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__card--3 {
animation: c3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes knob-loop {
0%, 20%, 100% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(100%);
}
}
@keyframes lab-grid {
0%, 24% {
color: #17141a;
}
32%, 68% {
color: #ffffff;
}
78%, 100% {
color: #17141a;
}
}
@keyframes lab-list {
0%, 24% {
color: #ffffff;
}
32%, 68% {
color: #17141a;
}
78%, 100% {
color: #ffffff;
}
}
@keyframes c2-loop {
0%, 20%, 100% {
transform: translate(calc(100% + 8px), 0);
}
45%, 65% {
transform: translate(0, calc(100% + 10px));
}
}
@keyframes c3-loop {
0%, 20%, 100% {
transform: translate(0, calc(100% + 10px));
}
45%, 65% {
transform: translate(0, calc(200% + 20px));
}
}
@media (max-width: 360px) {
.vs {
width: 232px;
}
.vs__toggle {
width: 140px;
height: 24px;
}
.vs__toggle-labels {
width: 140px;
margin: -24px auto 8px;
}
.vs__label {
font-size: 11px;
padding: 4px 0;
}
.vs__board {
height: 140px;
}
.vs__card {
width: 112px;
height: 40px;
padding: 4px;
gap: 4px;
}
.vs__thumb {
width: 24px;
height: 24px;
}
.vs__name {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.vs__knob, .vs__card, .vs__label {
transition: none;
}
.vs.is-demo .vs__knob,
.vs.is-demo .vs__card,
.vs.is-demo .vs__label {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./ViewSwitchGridList.module.scss";
export type ViewSwitchGridListProps = {
items: string[];
/** 카드가 배치을 바꾸는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 썸네일 그라데이션 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ViewSwitchGridList({
items,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ViewSwitchGridListProps) {
const [list, setList] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const layout = list ? "list" : "grid";
return (
<section className={styles.vs} style={vars} aria-label="보기 방식 전환">
<div className={styles.toggle} role="tablist" aria-label="보기 방식">
<button
type="button"
className={`${styles.label} ${layout === "grid" ? styles.labelOn : ""}`}
role="tab" aria-selected={layout === "grid"}
onClick={() => setList(false)}
>
격자
</button>
<button
type="button"
className={`${styles.label} ${layout === "list" ? styles.labelOn : ""}`}
role="tab" aria-selected={layout === "list"}
onClick={() => setList(true)}
>
목록
</button>
<span className={styles.knob} aria-hidden="true" data-at={layout} />
</div>
<div className={styles.board}>
{items.map((name, i) => (
<article key={name} className={styles.card} data-pos={layout === "list" ? `l${i + 1}` : `g${i + 1}`}>
<span className={styles.thumb} aria-hidden="true" />
<span className={styles.name}>{name}</span>
</article>
))}
</div>
</section>
);
}03 · 격자↔목록 뷰 전환 — 측정 누적면적 28.997% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
토글 손잡이와 카드가 같은 박자로 함께 움직이므로 누르는 즉시 화면이 살아 있다는 느낌을 줍니다.
.vs__card { transition: transform $duration $easing; }
// % 는 카드 자기 크기 기준 — 격자: 오른쪽 열로 한 카드 폭만큼
.vs__card--2 { transform: translate(calc(100% + 8px), 0); }
.vs__card--3 { transform: translate(0, calc(100% + 10px)); }
// 목록: 같은 카드가 한 줄씩 아래로
#vs-list:checked ~ .vs__board .vs__card--2 { transform: translate(0, calc(100% + 10px)); }
#vs-list:checked ~ .vs__board .vs__card--3 { transform: translate(0, calc(200% + 20px)); }
04큰 이미지 슬라이드 교체
무엇이 보이나
아래 썸네일을 고르면 지금 보이던 큰 사진은 왼쪽으로 빠지고 새 사진이 오른쪽에서 들어와 같은 자리를 차지합니다.

<main class="stage">
<section class="gal is-demo" aria-label="썸네일을 고르면 큰 이미지가 같은 자리에서 교체된다">
<input class="gal__radio" type="radio" name="gal-218" id="gal-1" checked>
<input class="gal__radio" type="radio" name="gal-218" id="gal-2">
<input class="gal__radio" type="radio" name="gal-218" id="gal-3">
<div class="gal__frame">
<div class="gal__scene gal__scene--1"><span class="gal__tag">정면</span></div>
<div class="gal__scene gal__scene--2"><span class="gal__tag">옆면</span></div>
<div class="gal__scene gal__scene--3"><span class="gal__tag">디테일</span></div>
</div>
<div class="gal__thumbs">
<label class="gal__thumb gal__thumb--1" for="gal-1">정면</label>
<label class="gal__thumb gal__thumb--2" for="gal-2">옆면</label>
<label class="gal__thumb gal__thumb--3" for="gal-3">디테일</label>
<span class="gal__indicator" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 이미지가 자리를 바꾸는 시간
$easing: $ease-out;
$color: $subject-blue; // 1번 장면 색
@include stage($stage-paper);
.gal { width: 280px; max-width: 100%; }
.gal__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 큰 이미지 틀: 장면 3장이 겹쳐 있고 translateX 로만 교체된다
.gal__frame {
position: relative;
height: clamp(100px, 50vh, 176px);
border-radius: $r-card;
overflow: hidden;
background: rgba(23, 20, 26, .06);
}
.gal__scene {
position: absolute; inset: 0;
display: grid;
place-items: center;
transform: translateX(110%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.gal__scene--1 { background: linear-gradient(150deg, $color, rgba($color, .35)); }
.gal__scene--2 { background: linear-gradient(150deg, $stage-orange, rgba($stage-orange, .35)); }
.gal__scene--3 { background: linear-gradient(150deg, $stage-ink, rgba($stage-ink, .5)); }
.gal__tag {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .9);
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: $stage-ink;
}
.gal__scene--1 { transform: translateX(0); opacity: 1; } // 기본 = 1번
// 라디오로 고르면: 새 장면은 오른쪽에서 들어오고 나머지는 왼쪽으로 빠진다
#gal-1:checked ~ .gal__frame .gal__scene--1 { transform: translateX(0); opacity: 1; }
#gal-1:checked ~ .gal__frame .gal__scene--2,
#gal-1:checked ~ .gal__frame .gal__scene--3 { transform: translateX(-110%); opacity: 0; }
#gal-2:checked ~ .gal__frame .gal__scene--2 { transform: translateX(0); opacity: 1; }
#gal-2:checked ~ .gal__frame .gal__scene--1,
#gal-2:checked ~ .gal__frame .gal__scene--3 { transform: translateX(-110%); opacity: 0; }
#gal-3:checked ~ .gal__frame .gal__scene--3 { transform: translateX(0); opacity: 1; }
#gal-3:checked ~ .gal__frame .gal__scene--1,
#gal-3:checked ~ .gal__frame .gal__scene--2 { transform: translateX(-110%); opacity: 0; }
// ── 썸네일: 고정 폭 셀 3개 + 밑에 움직이는 인디케이터 (transform 만)
.gal__thumbs {
position: relative;
display: flex;
gap: $sp-2;
justify-content: center;
margin-top: $sp-3;
padding-bottom: $sp-2;
}
.gal__thumb {
box-sizing: border-box;
width: 88px;
padding: $sp-2 0;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: $stage-paper;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: rgba(23, 20, 26, .7);
text-align: center;
cursor: pointer;
transition: color $dur-quick $easing, border-color $dur-quick $easing;
}
#gal-1:checked ~ .gal__thumbs .gal__thumb--1,
#gal-2:checked ~ .gal__thumbs .gal__thumb--2,
#gal-3:checked ~ .gal__thumbs .gal__thumb--3 { color: $stage-ink; border-color: $stage-ink; }
.gal__indicator {
position: absolute;
bottom: 0; left: calc(50% - 140px); // 행의 왼쪽 끝 (280px 행 기준)
width: 88px;
height: 4px;
border-radius: $r-pill;
background: $stage-ink;
transform: translateX(0);
transition: transform $duration $easing;
}
#gal-2:checked ~ .gal__thumbs .gal__indicator { transform: translateX(96px); }
#gal-3:checked ~ .gal__thumbs .gal__indicator { transform: translateX(192px); }
// 렌더·갤러리용 자동 루프: 1→2→3→1 을 2초 주기로
.gal.is-demo .gal__scene--1 { animation: s1-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__scene--2 { animation: s2-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__scene--3 { animation: s3-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__indicator { animation: ind-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--1 { animation: th1 $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--2 { animation: th2 $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--3 { animation: th3 $dur-loop $easing infinite; }
@keyframes s1-loop {
0%, 23% { transform: translateX(0); opacity: 1; }
33%, 88% { transform: translateX(-110%); opacity: 0; }
98%, 100% { transform: translateX(0); opacity: 1; }
}
@keyframes s2-loop {
0%, 23% { transform: translateX(110%); opacity: 0; }
33%, 56% { transform: translateX(0); opacity: 1; }
66%, 100% { transform: translateX(-110%); opacity: 0; }
}
@keyframes s3-loop {
0%, 56% { transform: translateX(110%); opacity: 0; }
66%, 88% { transform: translateX(0); opacity: 1; }
98%, 100% { transform: translateX(110%); opacity: 0; }
}
@keyframes ind-loop {
0%, 23% { transform: translateX(0); }
33%, 56% { transform: translateX(calc(100% + 8px)); }
66%, 88% { transform: translateX(calc(200% + 16px)); }
98%, 100% { transform: translateX(0); }
}
@keyframes th1 { // 활성 엄지 테두리가 장면과 함께 이동
0%, 23% { border-color: $stage-ink; color: $stage-ink; }
33%, 88% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
98%, 100% { border-color: $stage-ink; color: $stage-ink; }
}
@keyframes th2 {
0%, 23% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
33%, 56% { border-color: $stage-ink; color: $stage-ink; }
66%, 100% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
}
@keyframes th3 {
0%, 56% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
66%, 88% { border-color: $stage-ink; color: $stage-ink; }
98%, 100% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.gal__frame { height: 100px; }
.gal__thumb { width: 72px; padding: 4px 0; font-size: 10px; }
.gal__indicator { width: 72px; left: calc(50% - 116px); }
#gal-2:checked ~ .gal__thumbs .gal__indicator { transform: translateX(80px); }
#gal-3:checked ~ .gal__thumbs .gal__indicator { transform: translateX(160px); }
}
@media (prefers-reduced-motion: reduce) {
.gal__scene, .gal__thumb, .gal__indicator { transition: none; }
.gal.is-demo .gal__scene,
.gal.is-demo .gal__indicator,
.gal.is-demo .gal__thumb { animation: none; } // 장면·인디케이터가 즉시 바뀐다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gal {
width: 280px;
max-width: 100%;
}
.gal__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.gal__frame {
position: relative;
height: clamp(100px, 50vh, 176px);
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.06);
}
.gal__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transform: translateX(110%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.gal__scene--1 {
background: linear-gradient(150deg, #2f6df6, rgba(47, 109, 246, 0.35));
}
.gal__scene--2 {
background: linear-gradient(150deg, #ff4d1f, rgba(255, 77, 31, 0.35));
}
.gal__scene--3 {
background: linear-gradient(150deg, #17141a, rgba(23, 20, 26, 0.5));
}
.gal__tag {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.9);
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: #17141a;
}
.gal__scene--1 {
transform: translateX(0);
opacity: 1;
}
#gal-1:checked ~ .gal__frame .gal__scene--1 {
transform: translateX(0);
opacity: 1;
}
#gal-1:checked ~ .gal__frame .gal__scene--2,
#gal-1:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(-110%);
opacity: 0;
}
#gal-2:checked ~ .gal__frame .gal__scene--2 {
transform: translateX(0);
opacity: 1;
}
#gal-2:checked ~ .gal__frame .gal__scene--1,
#gal-2:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(-110%);
opacity: 0;
}
#gal-3:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(0);
opacity: 1;
}
#gal-3:checked ~ .gal__frame .gal__scene--1,
#gal-3:checked ~ .gal__frame .gal__scene--2 {
transform: translateX(-110%);
opacity: 0;
}
.gal__thumbs {
position: relative;
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
padding-bottom: 8px;
}
.gal__thumb {
box-sizing: border-box;
width: 88px;
padding: 8px 0;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: #ffffff;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: rgba(23, 20, 26, 0.7);
text-align: center;
cursor: pointer;
transition: color 220ms ease-out, border-color 220ms ease-out;
}
#gal-1:checked ~ .gal__thumbs .gal__thumb--1,
#gal-2:checked ~ .gal__thumbs .gal__thumb--2,
#gal-3:checked ~ .gal__thumbs .gal__thumb--3 {
color: #17141a;
border-color: #17141a;
}
.gal__indicator {
position: absolute;
bottom: 0;
left: calc(50% - 140px);
width: 88px;
height: 4px;
border-radius: 999px;
background: #17141a;
transform: translateX(0);
transition: transform 300ms ease-out;
}
#gal-2:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(96px);
}
#gal-3:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(192px);
}
.gal.is-demo .gal__scene--1 {
animation: s1-loop 2s ease-out infinite;
}
.gal.is-demo .gal__scene--2 {
animation: s2-loop 2s ease-out infinite;
}
.gal.is-demo .gal__scene--3 {
animation: s3-loop 2s ease-out infinite;
}
.gal.is-demo .gal__indicator {
animation: ind-loop 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--1 {
animation: th1 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--2 {
animation: th2 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--3 {
animation: th3 2s ease-out infinite;
}
@keyframes s1-loop {
0%, 23% {
transform: translateX(0);
opacity: 1;
}
33%, 88% {
transform: translateX(-110%);
opacity: 0;
}
98%, 100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes s2-loop {
0%, 23% {
transform: translateX(110%);
opacity: 0;
}
33%, 56% {
transform: translateX(0);
opacity: 1;
}
66%, 100% {
transform: translateX(-110%);
opacity: 0;
}
}
@keyframes s3-loop {
0%, 56% {
transform: translateX(110%);
opacity: 0;
}
66%, 88% {
transform: translateX(0);
opacity: 1;
}
98%, 100% {
transform: translateX(110%);
opacity: 0;
}
}
@keyframes ind-loop {
0%, 23% {
transform: translateX(0);
}
33%, 56% {
transform: translateX(calc(100% + 8px));
}
66%, 88% {
transform: translateX(calc(200% + 16px));
}
98%, 100% {
transform: translateX(0);
}
}
@keyframes th1 {
0%, 23% {
border-color: #17141a;
color: #17141a;
}
33%, 88% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
98%, 100% {
border-color: #17141a;
color: #17141a;
}
}
@keyframes th2 {
0%, 23% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
33%, 56% {
border-color: #17141a;
color: #17141a;
}
66%, 100% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes th3 {
0%, 56% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
66%, 88% {
border-color: #17141a;
color: #17141a;
}
98%, 100% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
}
@media (max-width: 360px) {
.gal__frame {
height: 100px;
}
.gal__thumb {
width: 72px;
padding: 4px 0;
font-size: 10px;
}
.gal__indicator {
width: 72px;
left: calc(50% - 116px);
}
#gal-2:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(80px);
}
#gal-3:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(160px);
}
}
@media (prefers-reduced-motion: reduce) {
.gal__scene, .gal__thumb, .gal__indicator {
transition: none;
}
.gal.is-demo .gal__scene,
.gal.is-demo .gal__indicator,
.gal.is-demo .gal__thumb {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./ProductImageSwap.module.scss";
export type ProductImageSwapProps = {
/** 장면 이름 순서 — 썸네일 라벨과 같다 */
scenes: string[];
/** 장면 배경색 순서 (장면 수와 같게) */
colors?: string[];
/** 이미지가 자리를 바꾸는 시간 (기본 300ms) */
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ProductImageSwap({
scenes,
colors = ["#2f6df6", "#ff4d1f", "#17141a"],
duration = 300,
easing = "ease-out",
}: ProductImageSwapProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<section className={styles.gal} style={vars} aria-label="상품 이미지 갤러리">
<div className={styles.frame}>
{scenes.map((name, i) => (
<div
key={name}
className={styles.scene}
data-active={i === active}
style={{ background: `linear-gradient(150deg, ${colors[i % colors.length]}, rgba(0,0,0,.25))` }}
>
<span className={styles.tag}>{name}</span>
</div>
))}
</div>
<div className={styles.thumbs}>
{scenes.map((name, i) => (
<button
key={name}
type="button"
className={`${styles.thumb} ${i === active ? styles.thumbOn : ""}`}
aria-pressed={i === active}
onClick={() => setActive(i)}
>
{name}
</button>
))}
<span className={styles.indicator} data-index={Math.min(active, 2)} aria-hidden="true" />
</div>
</section>
);
}04 · 큰 이미지 슬라이드 교체 — 측정 누적면적 31.293% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
상품 상세 페이지에서 사진만 깜빡 바뀌는 대신 흐르듯 교체되어 고르는 재미가 붙습니다.
.gal__scene {
position: absolute; inset: 0;
transform: translateX(110%); // 대기: 오른쪽 밖
transition: transform $duration $easing, opacity $duration $easing;
}
#gal-2:checked ~ .gal__frame .gal__scene--2 { transform: translateX(0); }
#gal-2:checked ~ .gal__frame .gal__scene--1 { transform: translateX(-110%); } // 왼쪽으로 퇴장
05필터 칩 슬롯 채움
화면에서 일어나는 일
색상 칩을 고르면 그 칩이 제자리에서 위쪽 빈 점선 슬롯으로 통째로 날아가 크기 그대로 착지합니다.

<main class="stage">
<section class="pick is-demo" aria-label="고른 필터 칩이 위 빈 슬롯으로 날아가 채워진다">
<input class="pick__check" type="checkbox" id="pick-1">
<input class="pick__check" type="checkbox" id="pick-2">
<input class="pick__check" type="checkbox" id="pick-3">
<p class="pick__slot"><span class="pick__slot-text">색상을 고르세요</span></p>
<div class="pick__row">
<label class="pick__chip pick__chip--1" for="pick-1">블랙</label>
<label class="pick__chip pick__chip--2" for="pick-2">크림</label>
<label class="pick__chip pick__chip--3" for="pick-3">민트</label>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 620ms; // 칩이 슬롯으로 날아가는 시간
$easing: $ease-spring;
$color: $subject-blue; // 포인트색 (칩 보더)
@include stage($stage-ink);
.pick {
position: relative;
width: min(300px, 90vw);
padding-top: $sp-4;
}
.pick__check { position: absolute; opacity: 0; pointer-events: none; }
// ── 위쪽 슬롯: 비어 있을 땐 점선, 칩이 착지하면 덮인다
.pick__slot {
margin: 0 0 $sp-8;
height: 44px;
border: 2px dashed rgba(255, 255, 255, .34);
border-radius: $r-pill;
transition: border-color $duration $easing;
}
.pick__slot-text {
display: grid; place-items: center;
height: 100%;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: rgba(255, 255, 255, .5);
}
// ── 아래 칩 줄: 크기 고정, transform 으로만 움직인다
.pick__row {
position: relative;
display: flex;
gap: $sp-2;
padding-left: 4px;
}
.pick__chip {
display: grid; place-items: center;
width: 84px;
height: 44px;
box-sizing: border-box;
border-radius: $r-pill;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
cursor: pointer;
transition: transform $duration $easing;
z-index: 1;
}
.pick__chip--1 { background: $subject-ink; color: $stage-paper; border: 2px solid $color; }
.pick__chip--2 { background: $subject-cream; color: $stage-ink; }
.pick__chip--3 { background: #d5eae5; color: $stage-ink; }
// 칩마다 슬롯까지의 거리가 다르다 (칩1 x4 → x0, 칩2 x96, 칩3 x188 / 낙차 76px)
#pick-1:checked ~ .pick__row .pick__chip--1 { transform: translate(-4px, -76px); z-index: 2; }
#pick-2:checked ~ .pick__row .pick__chip--2 { transform: translate(-96px, -76px); z-index: 2; }
#pick-3:checked ~ .pick__row .pick__chip--3 { transform: translate(-188px, -76px); z-index: 2; }
#pick-1:checked ~ .pick__slot,
#pick-2:checked ~ .pick__slot,
#pick-3:checked ~ .pick__slot { border-color: rgba(255, 255, 255, .8); }
// 렌더·갤러리용 자동 루프: 칩2 가 날아가 채워졌다 돌아오고, 이어 칩3 도 같은 길을 밟는다
.pick.is-demo .pick__chip--2 { animation: chip2-loop $dur-loop $easing infinite; }
.pick.is-demo .pick__chip--3 { animation: chip3-loop $dur-loop $easing infinite; }
.pick.is-demo .pick__slot { animation: slot-loop $dur-loop $easing infinite; }
@keyframes chip2-loop {
0%, 6% { transform: translate(0, 0); }
26%, 40% { transform: translate(-96px, -76px); }
50%, 100% { transform: translate(0, 0); }
}
@keyframes chip3-loop {
0%, 52% { transform: translate(0, 0); }
72%, 86% { transform: translate(-188px, -76px); }
94%, 100% { transform: translate(0, 0); }
}
@keyframes slot-loop {
0%, 12% { border-color: rgba(255, 255, 255, .34); }
30%, 42% { border-color: rgba(255, 255, 255, .8); }
58%, 68% { border-color: rgba(255, 255, 255, .34); }
76%, 88% { border-color: rgba(255, 255, 255, .8); }
96%, 100% { border-color: rgba(255, 255, 255, .34); }
}
@media (prefers-reduced-motion: reduce) {
.pick__chip, .pick__slot { transition: none; }
.pick.is-demo .pick__chip,
.pick.is-demo .pick__slot { animation: none; } // 칩이 즉시 슬롯에 얹힌다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pick {
position: relative;
width: min(300px, 90vw);
padding-top: 16px;
}
.pick__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.pick__slot {
margin: 0 0 32px;
height: 44px;
border: 2px dashed rgba(255, 255, 255, 0.34);
border-radius: 999px;
transition: border-color 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pick__slot-text {
display: grid;
place-items: center;
height: 100%;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: rgba(255, 255, 255, 0.5);
}
.pick__row {
position: relative;
display: flex;
gap: 8px;
padding-left: 4px;
}
.pick__chip {
display: grid;
place-items: center;
width: 84px;
height: 44px;
box-sizing: border-box;
border-radius: 999px;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
cursor: pointer;
transition: transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 1;
}
.pick__chip--1 {
background: #17141a;
color: #ffffff;
border: 2px solid #2f6df6;
}
.pick__chip--2 {
background: #fff7e6;
color: #17141a;
}
.pick__chip--3 {
background: #d5eae5;
color: #17141a;
}
#pick-1:checked ~ .pick__row .pick__chip--1 {
transform: translate(-4px, -76px);
z-index: 2;
}
#pick-2:checked ~ .pick__row .pick__chip--2 {
transform: translate(-96px, -76px);
z-index: 2;
}
#pick-3:checked ~ .pick__row .pick__chip--3 {
transform: translate(-188px, -76px);
z-index: 2;
}
#pick-1:checked ~ .pick__slot,
#pick-2:checked ~ .pick__slot,
#pick-3:checked ~ .pick__slot {
border-color: rgba(255, 255, 255, 0.8);
}
.pick.is-demo .pick__chip--2 {
animation: chip2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pick.is-demo .pick__chip--3 {
animation: chip3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pick.is-demo .pick__slot {
animation: slot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes chip2-loop {
0%, 6% {
transform: translate(0, 0);
}
26%, 40% {
transform: translate(-96px, -76px);
}
50%, 100% {
transform: translate(0, 0);
}
}
@keyframes chip3-loop {
0%, 52% {
transform: translate(0, 0);
}
72%, 86% {
transform: translate(-188px, -76px);
}
94%, 100% {
transform: translate(0, 0);
}
}
@keyframes slot-loop {
0%, 12% {
border-color: rgba(255, 255, 255, 0.34);
}
30%, 42% {
border-color: rgba(255, 255, 255, 0.8);
}
58%, 68% {
border-color: rgba(255, 255, 255, 0.34);
}
76%, 88% {
border-color: rgba(255, 255, 255, 0.8);
}
96%, 100% {
border-color: rgba(255, 255, 255, 0.34);
}
}
@media (prefers-reduced-motion: reduce) {
.pick__chip, .pick__slot {
transition: none;
}
.pick.is-demo .pick__chip,
.pick.is-demo .pick__slot {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./ChipSlotFill.module.scss";
export type ChipSlotFillProps = {
chips: string[];
placeholder: string;
/** 칩이 슬롯으로 날아가는 시간 (기본 620ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ChipSlotFill({
chips,
placeholder,
duration = 620,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChipSlotFillProps) {
const [picked, setPicked] = useState<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.pick} style={vars} aria-label={placeholder}>
<p className={`${styles.slot} ${picked !== null ? styles.slotOn : ""}`}>
{picked === null ? <span className={styles.slotText}>{placeholder}</span> : <span className={styles.slotText}>{chips[picked]}</span>}
</p>
<div className={styles.row}>
{chips.map((name, i) => (
<button
key={name}
type="button"
className={`${styles.chip} ${styles["chip" + (i + 1)]}`}
aria-pressed={picked === i}
data-picked={picked === i}
onClick={() => setPicked(picked === i ? null : i)}
>
{name}
</button>
))}
</div>
</section>
);
}05 · 필터 칩 슬롯 채움 — 측정 누적면적 12.475% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
필터를 골랐다는 것이 글자가 아니라 물건이 옮겨진 것으로 보이므로 쇼핑 화면에서 특히 잘 읽힙니다.
.pick__chip { transition: transform $duration $easing; }
// 칩마다 슬롯까지의 거리가 다르다 — 칩2는 96px 왼쪽·76px 위
#pick-2:checked ~ .pick__row .pick__chip--2 {
transform: translate(-96px, -76px);
z-index: 2; // 날아가는 칩이 위에 뜬다
}
#pick-2:checked ~ .pick__slot { border-color: rgba(255,255,255,.8); }
06장바구니로 날아가는 담기
어떤 움직임인가
담기 버튼을 누르면 상품색 원형 점 하나가 상품 카드에서 오른쪽 위 장바구니까지 포물선을 그리며 날아가 옅어지며 사라지고, 개수 배지가 통통 튀어 올라 담겼다는 것을 알립니다.

<main class="stage">
<section class="cart is-demo" aria-label="담기를 누르면 상품 점이 포물선으로 장바구니까지 날아간다">
<input class="cart__check" type="checkbox" id="cart-add">
<div class="cart__bag" aria-hidden="true">
<span class="cart__basket"></span>
<span class="cart__badge">2</span>
</div>
<article class="cart__product">
<span class="cart__swatch" aria-hidden="true"></span>
<h2 class="cart__name">코지 머그 350ml</h2>
<label class="cart__btn" for="cart-add">담기</label>
</article>
<span class="cart__fly" aria-hidden="true"><span class="cart__fly-y"></span></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 700ms; // 점이 카트까지 날아가는 시간
$easing: $ease-out;
$color: $stage-orange; // 상품·점 색
@include stage($stage-yellow);
.cart { position: relative; width: min(300px, 90vw); }
.cart__check { position: absolute; opacity: 0; pointer-events: none; }
// ── 오른쪽 위 장바구니 표시 + 개수 배지
.cart__bag { position: absolute; top: 0; right: 0; }
.cart__basket {
position: relative; // 손잡이 ::before 가 바구니에 붙게
display: block;
width: 44px; height: 36px;
box-sizing: border-box;
border: 3px solid $stage-ink;
border-radius: $r-xs $r-xs $r-sm $r-sm; // 바구니 몸통
}
.cart__basket::before { // 손잡이
content: "";
position: absolute; top: -8px; left: 8px;
width: 20px; height: 12px;
border: 3px solid $stage-ink;
border-bottom: 0;
border-radius: $r-pill $r-pill 0 0;
}
.cart__badge {
position: absolute; top: -8px; right: -8px;
display: grid; place-items: center;
min-width: 20px; height: 20px;
padding: 0 4px;
box-sizing: border-box;
border-radius: $r-pill;
background: $stage-ink;
font-weight: 800;
font-size: 11px;
font-family: inherit;
color: $stage-paper;
transition: transform $dur-quick $ease-pop;
}
#cart-add:checked ~ .cart__bag .cart__badge { transform: scale(1.35); }
// ── 상품 카드
.cart__product {
display: grid;
gap: $sp-2;
justify-items: start;
margin-top: $sp-12;
padding: $sp-4;
width: 180px;
box-sizing: border-box;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
}
.cart__swatch {
width: 56px; height: 56px;
border-radius: 50%;
background: $color;
}
.cart__name {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.8vw, 15px);
font-family: inherit;
color: $stage-ink;
}
.cart__btn {
padding: $sp-1 $sp-4;
border-radius: $r-control;
background: $stage-ink;
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: $stage-paper;
cursor: pointer;
transition: transform $dur-quick $easing;
}
.cart__btn:active { transform: scale(.94); }
// ── 날아가는 점: 겹 요소 2개로 포물선 (x 는 등속, y 는 느렸다 빨라짐)
.cart__fly {
position: absolute; left: 16px; top: 64px; // 상품 스와치 위치
opacity: 0;
transition: opacity $dur-quick linear;
pointer-events: none;
z-index: 2;
}
.cart__fly-y {
display: block;
width: 18px; height: 18px;
border-radius: 50%;
background: $color;
box-shadow: $shadow-press;
}
#cart-add:checked ~ .cart__fly { transform: translateX(273px); opacity: 0; transition: transform $duration $easing, opacity $dur-quick linear $duration; }
#cart-add:checked ~ .cart__fly .cart__fly-y { transform: translateY(-71px); transition: transform $duration $easing; }
// 렌더·갤러리용 자동 루프: 대기 → 비행 → 배지 바운스 → 리셋
.cart.is-demo .cart__fly { animation: fly-x $dur-loop linear infinite; opacity: 1; }
.cart.is-demo .cart__fly-y { animation: fly-y $dur-loop $easing infinite; }
.cart.is-demo .cart__badge { animation: badge-loop $dur-loop $easing infinite; }
@keyframes fly-x {
0%, 30% { transform: translateX(0); opacity: 1; }
68%, 100% { transform: translateX(273px); opacity: 0; }
}
@keyframes fly-y {
0%, 30% { transform: translateY(0); }
68%, 100% { transform: translateY(-71px); }
}
@keyframes badge-loop {
0%, 66% { transform: scale(1); }
74% { transform: scale(1.35); }
84%, 100% { transform: scale(1); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응 — 카드를 위로 붙인다
.cart__product { margin-top: 8px; padding: 8px; width: 148px; gap: 4px; }
.cart__swatch { width: 36px; height: 36px; }
.cart__name { font-size: 12px; }
.cart__btn { padding: 4px 12px; font-size: 11px; }
.cart__fly { top: 40px; left: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cart__badge, .cart__btn { transition: none; }
.cart.is-demo .cart__fly,
.cart.is-demo .cart__fly-y,
.cart.is-demo .cart__badge { animation: none; } // 점이 순간 이동, 배지는 고정
.cart.is-demo .cart__fly { opacity: 0; }
#cart-add:checked ~ .cart__fly { transform: translateX(273px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cart {
position: relative;
width: min(300px, 90vw);
}
.cart__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.cart__bag {
position: absolute;
top: 0;
right: 0;
}
.cart__basket {
position: relative;
display: block;
width: 44px;
height: 36px;
box-sizing: border-box;
border: 3px solid #17141a;
border-radius: 4px 4px 8px 8px;
}
.cart__basket::before {
content: "";
position: absolute;
top: -8px;
left: 8px;
width: 20px;
height: 12px;
border: 3px solid #17141a;
border-bottom: 0;
border-radius: 999px 999px 0 0;
}
.cart__badge {
position: absolute;
top: -8px;
right: -8px;
display: grid;
place-items: center;
min-width: 20px;
height: 20px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 999px;
background: #17141a;
font-weight: 800;
font-size: 11px;
font-family: inherit;
color: #ffffff;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
#cart-add:checked ~ .cart__bag .cart__badge {
transform: scale(1.35);
}
.cart__product {
display: grid;
gap: 8px;
justify-items: start;
margin-top: 48px;
padding: 16px;
width: 180px;
box-sizing: border-box;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cart__swatch {
width: 56px;
height: 56px;
border-radius: 50%;
background: #ff4d1f;
}
.cart__name {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.8vw, 15px);
font-family: inherit;
color: #17141a;
}
.cart__btn {
padding: 4px 16px;
border-radius: 12px;
background: #17141a;
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: #ffffff;
cursor: pointer;
transition: transform 220ms ease-out;
}
.cart__btn:active {
transform: scale(0.94);
}
.cart__fly {
position: absolute;
left: 16px;
top: 64px;
opacity: 0;
transition: opacity 220ms linear;
pointer-events: none;
z-index: 2;
}
.cart__fly-y {
display: block;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ff4d1f;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
#cart-add:checked ~ .cart__fly {
transform: translateX(273px);
opacity: 0;
transition: transform 700ms ease-out, opacity 220ms linear 700ms;
}
#cart-add:checked ~ .cart__fly .cart__fly-y {
transform: translateY(-71px);
transition: transform 700ms ease-out;
}
.cart.is-demo .cart__fly {
animation: fly-x 2s linear infinite;
opacity: 1;
}
.cart.is-demo .cart__fly-y {
animation: fly-y 2s ease-out infinite;
}
.cart.is-demo .cart__badge {
animation: badge-loop 2s ease-out infinite;
}
@keyframes fly-x {
0%, 30% {
transform: translateX(0);
opacity: 1;
}
68%, 100% {
transform: translateX(273px);
opacity: 0;
}
}
@keyframes fly-y {
0%, 30% {
transform: translateY(0);
}
68%, 100% {
transform: translateY(-71px);
}
}
@keyframes badge-loop {
0%, 66% {
transform: scale(1);
}
74% {
transform: scale(1.35);
}
84%, 100% {
transform: scale(1);
}
}
@media (max-width: 360px) {
.cart__product {
margin-top: 8px;
padding: 8px;
width: 148px;
gap: 4px;
}
.cart__swatch {
width: 36px;
height: 36px;
}
.cart__name {
font-size: 12px;
}
.cart__btn {
padding: 4px 12px;
font-size: 11px;
}
.cart__fly {
top: 40px;
left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cart__badge, .cart__btn {
transition: none;
}
.cart.is-demo .cart__fly,
.cart.is-demo .cart__fly-y,
.cart.is-demo .cart__badge {
animation: none;
}
.cart.is-demo .cart__fly {
opacity: 0;
}
#cart-add:checked ~ .cart__fly {
transform: translateX(273px);
}
}import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import styles from "./FlyToCart.module.scss";
export type FlyToCartProps = {
name: string;
addLabel: string;
/** 점이 카트까지 날아가는 시간 (기본 700ms) */
duration?: number;
easing?: string;
/** 상품·점 색 */
color?: string;
/** 카트에 이미 담겨 있는 개수 */
count?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FlyToCart({
name,
addLabel,
duration = 700,
easing = "ease-out",
color = "#ff4d1f",
count = 2,
}: FlyToCartProps) {
const [flying, setFlying] = useState(false);
const [added, setAdded] = useState(count);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!flying) return;
const t = setTimeout(() => { setAdded((n) => n + 1); setFlying(false); }, duration);
return () => clearTimeout(t);
}, [flying, duration]);
return (
<section className={styles.cart} style={vars} aria-label={name}>
<div className={styles.bag} aria-hidden="true">
<span className={styles.basket} />
<span className={`${styles.badge} ${flying ? styles.badgeBump : ""}`}>{added}</span>
</div>
<article className={styles.product}>
<span className={styles.swatch} aria-hidden="true" />
<h2 className={styles.name}>{name}</h2>
<button type="button" className={styles.btn} onClick={() => setFlying(true)}>{addLabel}</button>
</article>
<span className={`${styles.fly} ${flying ? styles.flyGo : ""}`} aria-hidden="true">
<span className={styles.flyY} />
</span>
</section>
);
}06 · 장바구니로 날아가는 담기 — 측정 누적면적 2.603% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
점이 떠나는 출발점과 도착점이 같은 화면에 있으니 사용자는 자신의 동작이 어디로 갔는지 눈으로 따라갑니다.
.cart__fly { transition: transform $duration $easing; } // 바깥 요소: x 등속
.cart__fly-y { transition: transform $duration $easing; } // 안 요소: y 이징
// 착지 좌표는 getBoundingClientRect 실측값 — 배지 중심에 맞춘 숫자다
#cart-add:checked ~ .cart__fly { transform: translateX(273px); opacity: 0; }
#cart-add:checked ~ .cart__fly .cart__fly-y { transform: translateY(-71px); }
#cart-add:checked ~ .cart__bag .cart__badge { transform: scale(1.35); }
07티켓 찢김 확인
무엇이 보이나
확인을 누르는 순간 티켓 가운데 점선이 밝아지고 왼쪽 반과 오른쪽 반이 반대 방향으로 기울며 벌어져, 찢긴 두 쪽이 화면에서 따로 빠집니다.

<main class="stage">
<section class="tear is-demo" aria-label="확인 순간 티켓이 좌우로 찢겨 두 쪽으로 빠진다">
<input class="tear__check" type="checkbox" id="tear-ok">
<div class="tear__ticket">
<div class="tear__half tear__half--l">
<p class="tear__title">입장권</p>
<p class="tear__seat">A열 12번</p>
</div>
<div class="tear__half tear__half--r">
<span class="tear__barcode" aria-hidden="true"></span>
<label class="tear__btn" for="tear-ok">확인</label>
</div>
<span class="tear__perf" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 720ms; // 찢겨 빠지는 시간
$easing: $ease-spring;
$color: $subject-blue; // 티켓 포인트색
@include stage($stage-orange);
.tear { display: grid; place-items: center; }
.tear__check { position: absolute; opacity: 0; pointer-events: none; }
.tear__ticket {
position: relative;
display: flex;
width: min(320px, 90vw);
height: 128px;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.tear__half {
box-sizing: border-box;
display: grid;
align-content: center;
gap: $sp-2;
padding: $sp-4;
background: $stage-paper;
transition: transform $duration $easing, opacity $duration $easing;
z-index: 1;
}
.tear__half--l { flex: 1 1 auto; border-right: 0; }
.tear__half--r {
flex: 0 0 112px;
justify-items: center;
background: linear-gradient(180deg, $stage-paper 0%, rgba($color, .12) 100%);
}
.tear__title {
margin: 0;
font-weight: 800;
font-size: clamp(16px, 4.6vw, 20px);
font-family: inherit;
color: $stage-ink;
}
.tear__seat {
margin: 0;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: rgba(23, 20, 26, .6);
}
.tear__barcode {
width: 56px; height: 44px;
border-radius: $r-xs;
background: repeating-linear-gradient(90deg,
$stage-ink 0 4px, transparent 4px 8px, $stage-ink 8px 10px, transparent 10px 16px);
}
.tear__btn {
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: $stage-ink;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: $stage-paper;
cursor: pointer;
transition: transform $dur-quick $easing;
}
.tear__btn:active { transform: scale(.94); }
// 가운데 점선 미신흔 — 찢기는 순간 밝아진다
.tear__perf {
position: absolute; top: 0; bottom: 0; left: calc(100% - 112px);
width: 0;
border-left: 2px dashed rgba(23, 20, 26, .3);
z-index: 2;
transition: border-color $dur-quick $easing;
}
// 확인: 왼쪽·오른쪽 반대 방향으로 기울며 벌어져 빠진다
#tear-ok:checked ~ .tear__ticket .tear__perf { border-color: rgba(23, 20, 26, .9); }
#tear-ok:checked ~ .tear__ticket .tear__half--l {
transform: translate(-52px, 26px) rotate(-9deg);
opacity: 0;
}
#tear-ok:checked ~ .tear__ticket .tear__half--r {
transform: translate(44px, 30px) rotate(8deg);
opacity: 0;
}
// 렌더·갤러리용 자동 루프: 온전 → 미싯값 밝아짐 → 찢김 퇴장 → 재등장
.tear.is-demo .tear__half--l { animation: half-l $dur-loop $easing infinite; }
.tear.is-demo .tear__half--r { animation: half-r $dur-loop $easing infinite; }
.tear.is-demo .tear__perf { animation: perf $dur-loop $easing infinite; }
@keyframes half-l {
0%, 24% { transform: translate(0, 0) rotate(0); opacity: 1; }
56%, 84% { transform: translate(-52px, 26px) rotate(-9deg); opacity: 0; }
92%, 100% { transform: translate(0, 0) rotate(0); opacity: 1; }
}
@keyframes half-r {
0%, 24% { transform: translate(0, 0) rotate(0); opacity: 1; }
56%, 84% { transform: translate(44px, 30px) rotate(8deg); opacity: 0; }
92%, 100% { transform: translate(0, 0) rotate(0); opacity: 1; }
}
@keyframes perf {
0%, 24% { border-color: rgba(23, 20, 26, .3); }
32%, 52% { border-color: rgba(23, 20, 26, .9); }
60%, 100% { border-color: rgba(23, 20, 26, .3); }
}
@media (prefers-reduced-motion: reduce) {
.tear__half, .tear__perf, .tear__btn { transition: none; }
.tear.is-demo .tear__half,
.tear.is-demo .tear__perf { animation: none; } // 찢김 없이 즉시 사라진다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tear {
display: grid;
place-items: center;
}
.tear__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.tear__ticket {
position: relative;
display: flex;
width: min(320px, 90vw);
height: 128px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.tear__half {
box-sizing: border-box;
display: grid;
align-content: center;
gap: 8px;
padding: 16px;
background: #ffffff;
transition: transform 720ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 720ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 1;
}
.tear__half--l {
flex: 1 1 auto;
border-right: 0;
}
.tear__half--r {
flex: 0 0 112px;
justify-items: center;
background: linear-gradient(180deg, #ffffff 0%, rgba(47, 109, 246, 0.12) 100%);
}
.tear__title {
margin: 0;
font-weight: 800;
font-size: clamp(16px, 4.6vw, 20px);
font-family: inherit;
color: #17141a;
}
.tear__seat {
margin: 0;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.tear__barcode {
width: 56px;
height: 44px;
border-radius: 4px;
background: repeating-linear-gradient(90deg, #17141a 0 4px, transparent 4px 8px, #17141a 8px 10px, transparent 10px 16px);
}
.tear__btn {
padding: 4px 12px;
border-radius: 12px;
background: #17141a;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: #ffffff;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tear__btn:active {
transform: scale(0.94);
}
.tear__perf {
position: absolute;
top: 0;
bottom: 0;
left: calc(100% - 112px);
width: 0;
border-left: 2px dashed rgba(23, 20, 26, 0.3);
z-index: 2;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#tear-ok:checked ~ .tear__ticket .tear__perf {
border-color: rgba(23, 20, 26, 0.9);
}
#tear-ok:checked ~ .tear__ticket .tear__half--l {
transform: translate(-52px, 26px) rotate(-9deg);
opacity: 0;
}
#tear-ok:checked ~ .tear__ticket .tear__half--r {
transform: translate(44px, 30px) rotate(8deg);
opacity: 0;
}
.tear.is-demo .tear__half--l {
animation: half-l 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tear.is-demo .tear__half--r {
animation: half-r 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tear.is-demo .tear__perf {
animation: perf 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes half-l {
0%, 24% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
56%, 84% {
transform: translate(-52px, 26px) rotate(-9deg);
opacity: 0;
}
92%, 100% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
}
@keyframes half-r {
0%, 24% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
56%, 84% {
transform: translate(44px, 30px) rotate(8deg);
opacity: 0;
}
92%, 100% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
}
@keyframes perf {
0%, 24% {
border-color: rgba(23, 20, 26, 0.3);
}
32%, 52% {
border-color: rgba(23, 20, 26, 0.9);
}
60%, 100% {
border-color: rgba(23, 20, 26, 0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.tear__half, .tear__perf, .tear__btn {
transition: none;
}
.tear.is-demo .tear__half,
.tear.is-demo .tear__perf {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./TicketTearConfirm.module.scss";
export type TicketTearConfirmProps = {
title: string;
meta: string;
confirmLabel: string;
/** 찢겨 빠지는 시간 (기본 720ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TicketTearConfirm({
title,
meta,
confirmLabel,
duration = 720,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TicketTearConfirmProps) {
const [torn, setTorn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.tear} style={vars} aria-label={title}>
<div className={styles.ticket}>
<div className={`${styles.half} ${styles.halfL} ${torn ? styles.halfLGo : ""}`}>
<p className={styles.title}>{title}</p>
<p className={styles.meta}>{meta}</p>
</div>
<div className={`${styles.half} ${styles.halfR} ${torn ? styles.halfRGo : ""}`}>
<span className={styles.barcode} aria-hidden="true" />
<button type="button" className={styles.btn} onClick={() => setTorn(true)}>{confirmLabel}</button>
</div>
<span className={`${styles.perf} ${torn ? styles.perfOn : ""}`} aria-hidden="true" />
</div>
</section>
);
}07 · 티켓 찢김 확인 — 측정 누적면적 8.812% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
주문 완료나 체크인 같은 되돌릴 수 없는 순간을 안내 문구 한 줄보다 확실하게 각인시킵니다.
.tear__half { transition: transform $duration $easing, opacity $duration $easing; }
#tear-ok:checked ~ .tear__ticket .tear__half--l {
transform: translate(-52px, 26px) rotate(-9deg); // 왼쪽 반: 왼쪽 아래로
opacity: 0;
}
#tear-ok:checked ~ .tear__ticket .tear__half--r {
transform: translate(44px, 30px) rotate(8deg); // 오른쪽 반: 오른쪽 아래로
opacity: 0;
}
08단계 방향 슬라이드
화면에서 일어나는 일
다단계 폼에서 다음 단계로 넘어가면 지금 패널은 왼쪽으로 빠지고 새 패널이 오른쪽에서 들어오며, 이전으로 돌아가면 방향이 정확히 반대가 됩니다.

<main class="stage">
<section class="wiz is-demo" aria-label="단계 패널이 진행 방향에 맞춰 슬라이드한다">
<input class="wiz__radio" type="radio" name="wiz-218" id="wiz-1" checked>
<input class="wiz__radio" type="radio" name="wiz-218" id="wiz-2">
<div class="wiz__window">
<div class="wiz__panel wiz__panel--1">
<p class="wiz__step">1단계 · 계정</p>
<p class="wiz__q">이름과 이메일을 적어주세요</p>
</div>
<div class="wiz__panel wiz__panel--2">
<p class="wiz__step">2단계 · 배송지</p>
<p class="wiz__q">받으실 주소를 적어주세요</p>
</div>
</div>
<div class="wiz__foot">
<div class="wiz__dots" aria-hidden="true"><span class="wiz__dot"></span><span class="wiz__dot"></span></div>
<label class="wiz__nav" for="wiz-1">이전</label>
<label class="wiz__nav wiz__nav--next" for="wiz-2">다음</label>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널이 한 단계 넘어가는 시간
$easing: $ease-spring;
$color: $subject-blue; // 포인트색
@include stage($stage-paper);
.wiz { width: min(300px, 90vw); }
.wiz__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 패널 창: 두 단계가 나란히 붙어 있고, 창이 이를 보여준다
.wiz__window {
position: relative;
height: clamp(96px, 48vh, 148px);
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
overflow: hidden;
}
.wiz__panel {
position: absolute; top: 0; bottom: 0; left: 0;
display: grid;
align-content: center;
gap: $sp-2;
width: 100%;
box-sizing: border-box;
padding: $sp-4;
transition: transform $duration $easing, opacity $duration $easing;
}
.wiz__panel--1 { transform: translateX(0); }
.wiz__panel--2 { transform: translateX(100%); opacity: 0; }
.wiz__step {
margin: 0;
padding: $sp-1 $sp-2;
width: fit-content;
border-radius: $r-pill;
background: rgba($color, .14); // 칩 면(#e2eafe)은 그대로
font-weight: 700;
font-size: 11px;
font-family: inherit;
color: $subject-blue-deep; // 그 면 위 $subject-blue 글자는 3.76:1 — 짙은 파랑으로 5.29:1
}
.wiz__q {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 18px);
line-height: 1.35;
font-family: inherit;
color: $stage-ink;
}
// 라디오: 앞으로 갈 때 1 은 왼쪽으로, 2 는 오른쪽에서 들어온다
#wiz-1:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(0); opacity: 1; }
#wiz-1:checked ~ .wiz__window .wiz__panel--2 { transform: translateX(100%); opacity: 0; }
#wiz-2:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(-100%); opacity: 0; }
#wiz-2:checked ~ .wiz__window .wiz__panel--2 { transform: translateX(0); opacity: 1; }
// ── 발판: 진행 점 + 이전/다음 (라디오 라벨)
.wiz__foot {
display: flex;
align-items: center;
gap: $sp-3;
margin-top: $sp-3;
}
.wiz__dots { display: flex; gap: $sp-2; padding: 0 $sp-1; }
.wiz__dot {
width: 8px; height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, .18);
transition: transform $duration $easing, background-color $duration $easing;
}
#wiz-1:checked ~ .wiz__foot .wiz__dot:nth-child(1),
#wiz-2:checked ~ .wiz__foot .wiz__dot:nth-child(2) { background: $color; transform: scale(1.5); }
.wiz__nav {
padding: $sp-1 $sp-4;
border-radius: $r-control;
border: 2px solid rgba(23, 20, 26, .2);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
transition: transform $dur-quick $easing, border-color $dur-quick $easing;
}
.wiz__nav--next { margin-left: auto; background: $stage-ink; border-color: $stage-ink; color: $stage-paper; }
.wiz__nav:active { transform: scale(.94); }
// 렌더·갤러리용 자동 루프: 앞으로(1→2) 갔다 뒤로(2→1) 돌아온다 — 방향이 반대로 보인다
.wiz.is-demo .wiz__panel--1 { animation: p1-loop $dur-loop $easing infinite; }
.wiz.is-demo .wiz__panel--2 { animation: p2-loop $dur-loop $easing infinite; }
.wiz.is-demo .wiz__dot:nth-child(1) { animation: d1-loop $dur-loop $easing infinite; }
.wiz.is-demo .wiz__dot:nth-child(2) { animation: d2-loop $dur-loop $easing infinite; }
@keyframes p1-loop {
0%, 10% { transform: translateX(0); opacity: 1; }
40%, 60% { transform: translateX(-100%); opacity: 0; }
90%, 100% { transform: translateX(0); opacity: 1; }
}
@keyframes p2-loop {
0%, 10% { transform: translateX(100%); opacity: 0; }
40%, 60% { transform: translateX(0); opacity: 1; }
90%, 100% { transform: translateX(100%); opacity: 0; }
}
@keyframes d1-loop {
0%, 10% { transform: scale(1.5); background: $color; }
40%, 60% { transform: scale(1); background: rgba(23, 20, 26, .18); }
90%, 100% { transform: scale(1.5); background: $color; }
}
@keyframes d2-loop {
0%, 10% { transform: scale(1); background: rgba(23, 20, 26, .18); }
40%, 60% { transform: scale(1.5); background: $color; }
90%, 100% { transform: scale(1); background: rgba(23, 20, 26, .18); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.wiz__window { height: 96px; }
.wiz__panel { padding: 8px; gap: 4px; }
.wiz__q { font-size: 13px; }
.wiz__foot { margin-top: 8px; gap: 8px; }
.wiz__nav { padding: 4px 12px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.wiz__panel, .wiz__dot, .wiz__nav { transition: none; }
.wiz.is-demo .wiz__panel,
.wiz.is-demo .wiz__dot { animation: none; } // 패널이 즉시 바뀐다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wiz {
width: min(300px, 90vw);
}
.wiz__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.wiz__window {
position: relative;
height: clamp(96px, 48vh, 148px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.wiz__panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: grid;
align-content: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
padding: 16px;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wiz__panel--1 {
transform: translateX(0);
}
.wiz__panel--2 {
transform: translateX(100%);
opacity: 0;
}
.wiz__step {
margin: 0;
padding: 4px 8px;
width: fit-content;
border-radius: 999px;
background: rgba(47, 109, 246, 0.14);
font-weight: 700;
font-size: 11px;
font-family: inherit;
color: #0a4fe8;
}
.wiz__q {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 18px);
line-height: 1.35;
font-family: inherit;
color: #17141a;
}
#wiz-1:checked ~ .wiz__window .wiz__panel--1 {
transform: translateX(0);
opacity: 1;
}
#wiz-1:checked ~ .wiz__window .wiz__panel--2 {
transform: translateX(100%);
opacity: 0;
}
#wiz-2:checked ~ .wiz__window .wiz__panel--1 {
transform: translateX(-100%);
opacity: 0;
}
#wiz-2:checked ~ .wiz__window .wiz__panel--2 {
transform: translateX(0);
opacity: 1;
}
.wiz__foot {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.wiz__dots {
display: flex;
gap: 8px;
padding: 0 4px;
}
.wiz__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.18);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#wiz-1:checked ~ .wiz__foot .wiz__dot:nth-child(1),
#wiz-2:checked ~ .wiz__foot .wiz__dot:nth-child(2) {
background: #2f6df6;
transform: scale(1.5);
}
.wiz__nav {
padding: 4px 16px;
border-radius: 12px;
border: 2px solid rgba(23, 20, 26, 0.2);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: #17141a;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wiz__nav--next {
margin-left: auto;
background: #17141a;
border-color: #17141a;
color: #ffffff;
}
.wiz__nav:active {
transform: scale(0.94);
}
.wiz.is-demo .wiz__panel--1 {
animation: p1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wiz.is-demo .wiz__panel--2 {
animation: p2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wiz.is-demo .wiz__dot:nth-child(1) {
animation: d1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wiz.is-demo .wiz__dot:nth-child(2) {
animation: d2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes p1-loop {
0%, 10% {
transform: translateX(0);
opacity: 1;
}
40%, 60% {
transform: translateX(-100%);
opacity: 0;
}
90%, 100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes p2-loop {
0%, 10% {
transform: translateX(100%);
opacity: 0;
}
40%, 60% {
transform: translateX(0);
opacity: 1;
}
90%, 100% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes d1-loop {
0%, 10% {
transform: scale(1.5);
background: #2f6df6;
}
40%, 60% {
transform: scale(1);
background: rgba(23, 20, 26, 0.18);
}
90%, 100% {
transform: scale(1.5);
background: #2f6df6;
}
}
@keyframes d2-loop {
0%, 10% {
transform: scale(1);
background: rgba(23, 20, 26, 0.18);
}
40%, 60% {
transform: scale(1.5);
background: #2f6df6;
}
90%, 100% {
transform: scale(1);
background: rgba(23, 20, 26, 0.18);
}
}
@media (max-width: 360px) {
.wiz__window {
height: 96px;
}
.wiz__panel {
padding: 8px;
gap: 4px;
}
.wiz__q {
font-size: 13px;
}
.wiz__foot {
margin-top: 8px;
gap: 8px;
}
.wiz__nav {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.wiz__panel, .wiz__dot, .wiz__nav {
transition: none;
}
.wiz.is-demo .wiz__panel,
.wiz.is-demo .wiz__dot {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./WizardStepSlide.module.scss";
export type WizardStepSlideProps = {
steps: { badge: string; question: string }[];
backLabel: string;
nextLabel: string;
/** 패널이 한 단계 넘어가는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WizardStepSlide({
steps,
backLabel,
nextLabel,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: WizardStepSlideProps) {
const [step, setStep] = useState(0);
const [dir, setDir] = useState<"fwd" | "back">("fwd");
const go = (d: number) => {
const next = Math.min(Math.max(step + d, 0), steps.length - 1);
if (next === step) return;
setDir(d > 0 ? "fwd" : "back");
setStep(next);
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.wiz} style={vars} aria-label="다단계 폼">
<div className={styles.window}>
{steps.map((s, i) => (
<div
key={s.badge}
className={`${styles.panel} ${i === step ? styles.panelOn : ""}`}
data-side={i < step ? "left" : i > step ? "right" : dir}
>
<p className={styles.step}>{s.badge}</p>
<p className={styles.q}>{s.question}</p>
</div>
))}
</div>
<div className={styles.foot}>
<div className={styles.dots} aria-hidden="true">
{steps.map((s, i) => (
<span key={s.badge} className={`${styles.dot} ${i === step ? styles.dotOn : ""}`} />
))}
</div>
<button type="button" className={styles.nav} onClick={() => go(-1)}>{backLabel}</button>
<button type="button" className={`${styles.nav} ${styles.navNext}`} onClick={() => go(1)}>{nextLabel}</button>
</div>
</section>
);
}08 · 단계 방향 슬라이드 — 측정 누적면적 7.495% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
화살표를 읽지 않아도 흐름 방향이 눈에 보이므로 몇 단계짜리 폼이든 길을 잃지 않습니다.
.wiz__panel { transition: transform $duration $easing, opacity $duration $easing; }
.wiz__panel--2 { transform: translateX(100%); opacity: 0; } // 2단계의 집: 오른쪽
// 뒤로 돌아가면 1단계는 왼쪽 집으로 빠지고 2단계는 오른쪽 집으로 돌아간다 — 방향이 반대가 된다
#wiz-2:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(-100%); opacity: 0; }
#wiz-2:checked ~ .wiz__window .wiz__panel--2 { transform: translateX(0); opacity: 1; }
#wiz-1:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(0); opacity: 1; }
09대각선 장면 교체
어떤 움직임인가
화면을 비스듬히 가른 두 판이 이어서 같은 방향으로 프레임을 쓸고 지나가면서 뒤에 숨어 있던 다음 장면을 한 번에 드러냅니다.

<main class="stage">
<section class="scene is-demo" aria-label="대각선 두 판이 지나가며 장면이 교체된다">
<input class="scene__check" type="checkbox" id="scene-swap">
<div class="scene__frame">
<div class="scene__inner scene__inner--a"><p class="scene__name">봄 컬렉션</p></div>
<div class="scene__inner scene__inner--b"><p class="scene__name">여름 컬렉션</p></div>
<div class="scene__wipes" aria-hidden="true">
<span class="scene__wipe scene__wipe--1"></span>
<span class="scene__wipe scene__wipe--2"></span>
</div>
</div>
<label class="scene__btn" for="scene-swap">장면 바꾸기</label>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1s; // 판이 프레임을 통과하는 시간
$easing: $ease-spring;
$color: $subject-blue; // A 장면 색
@include stage($stage-ink);
.scene {
display: grid;
justify-items: center;
gap: $sp-3;
}
.scene__check { position: absolute; opacity: 0; pointer-events: none; }
.scene__frame {
position: relative;
width: min(320px, 90vw);
height: clamp(96px, 50vh, 172px);
border-radius: $r-card;
overflow: hidden;
}
.scene__inner {
position: absolute; inset: 0;
display: grid;
place-items: center;
transition: opacity $dur-quick linear calc(#{$duration} / 2); // 판이 덮는 중간에 교체
}
.scene__inner--a { background: linear-gradient(140deg, $color 0%, $subject-cream 100%); opacity: 1; }
.scene__inner--b { background: linear-gradient(140deg, $stage-orange 0%, $stage-yellow 100%); opacity: 0; }
.scene__name {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.2vw, 24px);
font-family: inherit;
color: $stage-ink;
}
// ── 대각선 판 2개: skew 로 비스듬히 눕히고 좌우로 통과시킨다 (clip-path 아님)
.scene__wipes { position: absolute; inset: 0; }
.scene__wipe {
position: absolute;
top: -20%; bottom: -20%; left: -45%;
width: 190%;
background: $stage-paper;
transform: skewX(-14deg) translateX(-115%);
}
.scene__wipe--2 { background: rgba(255, 210, 63, .92); }
// 버튼: 판이 프레임을 쓸고 지나가며 장면이 바뀐다
.scene__btn {
padding: $sp-2 $sp-5;
border-radius: $r-control;
border: 2px solid rgba(255, 255, 255, .4);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: $stage-paper;
cursor: pointer;
transition: transform $dur-quick $easing, border-color $dur-quick $easing;
}
.scene__btn:active { transform: scale(.94); }
// 체크: 판이 왼→오른쪽으로 통과 (transition 이라 해제하면 역방향 통과가 된다)
.scene__wipe { transition: transform $duration $easing; }
.scene__wipe--2 { transition-delay: 100ms; }
#scene-swap:checked ~ .scene__frame .scene__wipe { transform: skewX(-14deg) translateX(115%); }
#scene-swap:checked ~ .scene__frame .scene__inner--a { opacity: 0; }
#scene-swap:checked ~ .scene__frame .scene__inner--b { opacity: 1; }
// 렌더·갤러리용 자동 루프: A → 판 통과 → B → 판 역통과 → A
.scene.is-demo .scene__wipe--1 { animation: wipe-loop-1 $dur-loop $easing infinite; }
.scene.is-demo .scene__wipe--2 { animation: wipe-loop-2 $dur-loop $easing infinite; }
.scene.is-demo .scene__inner--a { animation: inner-a $dur-loop $easing infinite; }
.scene.is-demo .scene__inner--b { animation: inner-b $dur-loop $easing infinite; }
@keyframes wipe-loop-1 {
0%, 8% { transform: skewX(-14deg) translateX(-115%); } // 순방향 통과
34% { transform: skewX(-14deg) translateX(115%); }
52%, 58% { transform: skewX(-14deg) translateX(115%); } // 역방향 통과
84%, 100% { transform: skewX(-14deg) translateX(-115%); }
}
@keyframes wipe-loop-2 {
0%, 14% { transform: skewX(-14deg) translateX(-115%); }
40%, 60% { transform: skewX(-14deg) translateX(115%); }
86%, 100% { transform: skewX(-14deg) translateX(-115%); }
}
@keyframes inner-a {
0%, 20% { opacity: 1; }
26%, 70% { opacity: 0; }
76%, 100% { opacity: 1; }
}
@keyframes inner-b {
0%, 20% { opacity: 0; }
26%, 70% { opacity: 1; }
76%, 100% { opacity: 0; }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.scene__frame { height: 96px; }
.scene__name { font-size: 15px; }
.scene__btn { padding: 4px 16px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.scene__inner, .scene__btn { transition: none; }
.scene.is-demo .scene__wipe,
.scene.is-demo .scene__inner { animation: none; } // 판 없이 장면만 즉시 바뀐다
.scene.is-demo .scene__wipe { transform: skewX(-14deg) translateX(-115%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.scene {
display: grid;
justify-items: center;
gap: 12px;
}
.scene__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.scene__frame {
position: relative;
width: min(320px, 90vw);
height: clamp(96px, 50vh, 172px);
border-radius: 16px;
overflow: hidden;
}
.scene__inner {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transition: opacity 220ms linear calc(1s / 2);
}
.scene__inner--a {
background: linear-gradient(140deg, #2f6df6 0%, #fff7e6 100%);
opacity: 1;
}
.scene__inner--b {
background: linear-gradient(140deg, #ff4d1f 0%, #ffd23f 100%);
opacity: 0;
}
.scene__name {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.2vw, 24px);
font-family: inherit;
color: #17141a;
}
.scene__wipes {
position: absolute;
inset: 0;
}
.scene__wipe {
position: absolute;
top: -20%;
bottom: -20%;
left: -45%;
width: 190%;
background: #ffffff;
transform: skewX(-14deg) translateX(-115%);
}
.scene__wipe--2 {
background: rgba(255, 210, 63, 0.92);
}
.scene__btn {
padding: 8px 20px;
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.4);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: #ffffff;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.scene__btn:active {
transform: scale(0.94);
}
.scene__wipe {
transition: transform 1s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.scene__wipe--2 {
transition-delay: 100ms;
}
#scene-swap:checked ~ .scene__frame .scene__wipe {
transform: skewX(-14deg) translateX(115%);
}
#scene-swap:checked ~ .scene__frame .scene__inner--a {
opacity: 0;
}
#scene-swap:checked ~ .scene__frame .scene__inner--b {
opacity: 1;
}
.scene.is-demo .scene__wipe--1 {
animation: wipe-loop-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__wipe--2 {
animation: wipe-loop-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__inner--a {
animation: inner-a 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__inner--b {
animation: inner-b 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wipe-loop-1 {
0%, 8% {
transform: skewX(-14deg) translateX(-115%);
}
34% {
transform: skewX(-14deg) translateX(115%);
}
52%, 58% {
transform: skewX(-14deg) translateX(115%);
}
84%, 100% {
transform: skewX(-14deg) translateX(-115%);
}
}
@keyframes wipe-loop-2 {
0%, 14% {
transform: skewX(-14deg) translateX(-115%);
}
40%, 60% {
transform: skewX(-14deg) translateX(115%);
}
86%, 100% {
transform: skewX(-14deg) translateX(-115%);
}
}
@keyframes inner-a {
0%, 20% {
opacity: 1;
}
26%, 70% {
opacity: 0;
}
76%, 100% {
opacity: 1;
}
}
@keyframes inner-b {
0%, 20% {
opacity: 0;
}
26%, 70% {
opacity: 1;
}
76%, 100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.scene__frame {
height: 96px;
}
.scene__name {
font-size: 15px;
}
.scene__btn {
padding: 4px 16px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.scene__inner, .scene__btn {
transition: none;
}
.scene.is-demo .scene__wipe,
.scene.is-demo .scene__inner {
animation: none;
}
.scene.is-demo .scene__wipe {
transform: skewX(-14deg) translateX(-115%);
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./SplitDiagonalSwap.module.scss";
export type SplitDiagonalSwapProps = {
sceneA: string;
sceneB: string;
swapLabel: string;
/** 판이 프레임을 통과하는 시간 (기본 1000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SplitDiagonalSwap({
sceneA,
sceneB,
swapLabel,
duration = 1000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SplitDiagonalSwapProps) {
const [atB, setAtB] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.scene} style={vars} aria-label={`${sceneA} / ${sceneB}`}>
<div className={styles.frame}>
<div className={`${styles.inner} ${styles.innerA} ${atB ? styles.innerHide : ""}`}>
<p className={styles.name}>{sceneA}</p>
</div>
<div className={`${styles.inner} ${styles.innerB} ${atB ? styles.innerShow : ""}`}>
<p className={styles.name}>{sceneB}</p>
</div>
<div className={styles.wipes} aria-hidden="true">
<span className={`${styles.wipe} ${styles.wipe1} ${atB ? styles.wipeGo : ""}`} />
<span className={`${styles.wipe} ${styles.wipe2} ${atB ? styles.wipeGo : ""}`} />
</div>
</div>
<button type="button" className={styles.btn} onClick={() => setAtB((v) => !v)}>{swapLabel}</button>
</section>
);
}09 · 대각선 장면 교체 — 측정 누적면적 33.21% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
랜딩 페이지에서 계절이나 시즌 장면을 바꿀 때 페이지를 새로 고침하는 대신 이 동작 하나로 극적인 전환을 만들 수 있습니다.
.scene__wipe {
top: -20%; bottom: -20%; left: -45%;
width: 190%;
transform: skewX(-14deg) translateX(-115%); // 비스듬히 눕힌 판
transition: transform $duration $easing;
}
#scene-swap:checked ~ .scene__frame .scene__wipe { transform: skewX(-14deg) translateX(115%); }
.scene__inner { transition: opacity $dur-quick linear calc(#{$duration} / 2); } // 판이 덮는 중간에 교체
어디서 깨지는가 — 함정 하나
06 장바구니 비행을 처음 만들 때 점이 분명히 움직이는데 도착점이 어딘가 어긋나 있었습니다. 눈짐작으로 카드에서 장바구니까지 거리를 계산해 translateX(247px)를 넣었던 것인데, Playwright로 두 요소의 실제 좌표를 재니 장바구니 배지의 중심이 예상보다 오른쪽에 있었고 점은 배지에 26px 못 미쳐 허공에 사라지고 있었습니다. getBoundingClientRect로 출발점과 도착점 중심을 재어 translateX(273px)·translateY(-71px)로 고쳤더니 점이 배지 한가운데에 정확히 착지했습니다. 같은 검사에서 장바구니 손잡이를 그리는 가상 요소가 부모 위치 기준을 못 잡아 무대 왼쪽 위에 떠 있던 것도 발견해 부모에 position: relative를 한 줄 넣어 붙였습니다. 날아가는 동작은 출발점과 도착점을 눈으로 맞추지 말고 좌표로 재야 합니다. 장면이 아니라 손가락이 내려갈 때마다 변하는 쪽은 CSS 스크롤 애니메이션 9종이 맡습니다. 이 아홉 개 파일의 압축 비밀번호는 2aq7sbjt이며 전부 그런 실측을 거친 뒤에 올라온 것입니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서는 움직임만 끄고 결과 상태를 남깁니다. 01은 브라인드가 접힌 채 문구가 바로 보이고, 02는 사진이 흔들리지 않고 반듯하게 걸리며, 03·04·08은 카드·장면·패널이 전환 없이 즉시 목표 배치로 바뀌고, 05는 칩이 즉시 슬롯에 얹히며, 06은 점이 순간 이동하고 배지만 커지며, 07은 티켓이 찢어지는 연출 없이 즉시 사라지고, 09는 판이 통과하지 않고 장면만 바뀝니다. 클릭 동작은 전부 실제 라디오·체크박스와 연결된 라벨이라 키보드와 스크린 리더로도 조작되고, 장식 요소는 aria-hidden으로 읽는 순서에서 뺐습니다. transform 전환이 왜 부드러운지는 MDN transform 문서에서, 움직임 줄이기 설정의 의미는 MDN prefers-reduced-motion 문서에서 확인할 수 있습니다. 페이지 사이를 넘어가는 전환은 이전 글 CSS 페이지 전환 애니메이션 9종에, 탭 패널 전환은 탭 메뉴 전환 모음에 따로 정리했습니다.
FAQ
자바스크립트를 정말 한 줄도 안 써도 됩니까?
이 아홉 개는 전부 CSS만으로 돌아갑니다. 클릭이 필요한 것은 화면에 보이지 않는 라디오·체크박스를 두고 :checked 상태로 CSS가 따라가게 만든 방식이라, 붙여 넣는 순간 그대로 동작합니다. React 판은 상태 관리를 위해 useState를 씁니다. 전환이 버튼 하나 안에서 벌어지는 형태는 수상작의 버튼 hover 기법 9종에서 다룹니다.
react 페이지 전환 애니메이션이 이미 있는 사이트에 붙이려면 무엇을 바꿔야 합니까?
항목마다 움직이는 부분의 클래스 이름과 거리 값만 여러분 화면 크기에 맞게 고치면 됩니다. 특히 05 칩 비행과 06 장바구니 비행은 칩·카드·장바구니의 실제 위치에 따라 translate 숫자가 달라지니, 개발자 도구에서 요소 좌표를 재고 넣는 것이 정확합니다.
뷰 전환 API로도 같은 효과를 낼 수 있습니까?
뷰 전환 API는 문서나 큰 덩어리가 통째로 바뀌는 순간에 쓰는 브라우저 기능이라 이 글의 작은 상태 전환에는 맞지 않고, 아직 브라우저마다 지원 정도가 다릅니다. 이 글의 아홉 개는 지원 편차 없이 어디서나 같은 모습으로 돌아가도록 transform과 opacity만 썼습니다.









