9 CSS Screen Transition Animation Effects
A CSS screen transition animation smooths every moment the screen changes — an image swap, a confirmed order, a form step — so the page glides from one state
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Blinds slat reveal
- 02 Polaroid swing-in
- 03 Grid↔list view switch
- 04 Main image slide swap
- 05 Filter chip slot-fill
- 06 Fly-to-cart add
- 07 Ticket-tear confirm
- 08 Wizard step slide
- 09 Diagonal scene swap
The nine css screen transitions are ordered by the moments a visitor actually meets on a live page. The first scene opens like window blinds (01), a photo swings in on a string (02), the browsing mode glides between grid and list (03), the featured image swaps in place (04), a picked filter chip flies into its slot (05), an added product arcs into the cart (06), a ticket tears apart at confirmation (07), a form slides step by step in the direction you move (08), and finally a whole scene splits diagonally and gives way to the next one (09). Shrink the swap to a few tabs and you get 9 CSS Tab Switch Animations; move it to a bottom dock and you get Mac Dock CSS.
| # | Name | What moves | JS |
|---|---|---|---|
| 01 | Blinds slat reveal | 6 slats scaleY cascade |
none |
| 02 | Polaroid swing-in | card rotate damped swing |
none |
| 03 | Grid↔list view switch | cards translate positions |
none |
| 04 | Main image slide swap | scenes translateX crossing |
none |
| 05 | Filter chip slot-fill | chip translate flight |
none |
| 06 | Fly-to-cart add | dot double-translate arc |
none |
| 07 | Ticket-tear confirm | two halves rotate apart |
none |
| 08 | Wizard step slide | panels translateX ±100% |
none |
| 09 | Diagonal scene swap | skew panels sweeping |
none |
01Blinds slat reveal
What this css page transition animation shows
When the page opens, six horizontal blind slats covering a bright headline panel fold away from the top one after another and uncover the text beneath.

<main class="stage">
<section class="reveal is-demo" aria-label="A spring headline appears as blinds fold away">
<div class="reveal__panel">
<p class="reveal__kicker">Spring opening</p>
<h2 class="reveal__headline">New collection 20% off</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 · Blinds slat reveal — Measured area 33.702% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Instead of a hero headline flashing in all at once, it settles open slat by slat.
.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); }
}
// each slat waits 90ms longer than the one above it
.reveal__slat:nth-child(2) { animation-delay: 90ms; }
.reveal__slat:nth-child(3) { animation-delay: 180ms; }
02Polaroid swing-in
What happens on screen
A polaroid photo card hung from a single pin drops in tilted, sways left and right a few times, and comes to rest perfectly straight.

<main class="stage">
<figure class="swing is-demo" aria-label="A polaroid card drops in and sways on its pin">
<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">Jeju, April sea</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 · Polaroid swing-in — Measured area 47.004% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Dropped onto a gallery intro or a travel-log thumbnail, it gives a static photo page a physical, handmade warmth.
.swing {
transform-origin: top center; // the pin is the rotation axis
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); }
}
03Grid↔list view switch
What moves
The same three product cards glide between a two-column grid and a single-column list without ever leaving the frame.

<main class="stage">
<section class="vs is-demo" aria-label="The same three cards move between grid and list layouts">
<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">Grid</label>
<label class="vs__label" for="vs-list">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">Summer knit</span></article>
<article class="vs__card vs__card--2"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">Linen shirt</span></article>
<article class="vs__card vs__card--3"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">Straw hat</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 · Grid↔list view switch — Measured area 28.997% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The toggle knob answers first and the cards follow, so the screen feels alive the instant you touch it.
.vs__card { transition: transform $duration $easing; }
// percentages are relative to the card's own size — grid: one column to the right
.vs__card--2 { transform: translate(calc(100% + 8px), 0); }
.vs__card--3 { transform: translate(0, calc(100% + 10px)); }
// list: the same cards stack one row at a time
#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)); }
04Main image slide swap
What you see
Picking a thumbnail slides the current image out to the left while the new one enters from the right into the same frame.

<main class="stage">
<section class="gal is-demo" aria-label="Picking a thumbnail swaps the main image in place">
<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">Front</span></div>
<div class="gal__scene gal__scene--2"><span class="gal__tag">Side</span></div>
<div class="gal__scene gal__scene--3"><span class="gal__tag">Detail</span></div>
</div>
<div class="gal__thumbs">
<label class="gal__thumb gal__thumb--1" for="gal-1">Front</label>
<label class="gal__thumb gal__thumb--2" for="gal-2">Side</label>
<label class="gal__thumb gal__thumb--3" for="gal-3">Detail</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 · Main image slide swap — Measured area 31.293% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
On a product page this turns an abrupt image swap into a small, flowing exchange that makes browsing feel rewarding.
.gal__scene {
position: absolute; inset: 0;
transform: translateX(110%); // waiting off-stage right
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%); } // exits left
05Filter chip slot-fill
What happens on screen
Picking a color chip sends that chip flying from its row up into an empty outlined slot above, landing at exactly the same size.

<main class="stage">
<section class="pick is-demo" aria-label="The picked filter chip flies up and fills the empty slot">
<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">Pick a color</span></p>
<div class="pick__row">
<label class="pick__chip pick__chip--1" for="pick-1">Black</label>
<label class="pick__chip pick__chip--2" for="pick-2">Cream</label>
<label class="pick__chip pick__chip--3" for="pick-3">Mint</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 · Filter chip slot-fill — Measured area 12.475% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The choice registers as a physical object moving, not as text changing, which reads instantly on shopping screens.
.pick__chip { transition: transform $duration $easing; }
// each chip has a different distance to the slot — chip 2 travels 96px left, 76px up
#pick-2:checked ~ .pick__row .pick__chip--2 {
transform: translate(-96px, -76px);
z-index: 2; // the flying chip floats above the row
}
#pick-2:checked ~ .pick__slot { border-color: rgba(255,255,255,.8); }
06Fly-to-cart add
What moves
Pressing Add sends one product-colored dot flying on an arc from the product card to the cart icon in the corner, fading away as it lands while the count badge bounces.

<main class="stage">
<section class="cart is-demo" aria-label="Pressing Add flies a product dot on an arc to the cart">
<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">Cozy mug 350ml</h2>
<label class="cart__btn" for="cart-add">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 · Fly-to-cart add — Measured area 2.603% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Because the departure and arrival points share one screen, the visitor can follow their own action all the way to its destination.
.cart__fly { transition: transform $duration $easing; } // outer element: x at constant speed
.cart__fly-y { transition: transform $duration $easing; } // inner element: y on an eased curve
// landing coordinates come from measured getBoundingClientRect values
#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); }
07Ticket-tear confirm
What you see
At the moment of confirmation the dashed line down the middle of the ticket lights up, and the left and right halves tilt away in opposite directions and exit the frame as two torn stubs.

<main class="stage">
<section class="tear is-demo" aria-label="At confirmation the ticket tears into two stubs">
<input class="tear__check" type="checkbox" id="tear-ok">
<div class="tear__ticket">
<div class="tear__half tear__half--l">
<p class="tear__title">Admission</p>
<p class="tear__seat">Row 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">Confirm</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 · Ticket-tear confirm — Measured area 8.812% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
For irreversible moments like placing an order or checking in, this seals the action far more firmly than a text change.
.tear__half { transition: transform $duration $easing, opacity $duration $easing; }
#tear-ok:checked ~ .tear__ticket .tear__half--l {
transform: translate(-52px, 26px) rotate(-9deg); // left half: down-left
opacity: 0;
}
#tear-ok:checked ~ .tear__ticket .tear__half--r {
transform: translate(44px, 30px) rotate(8deg); // right half: down-right
opacity: 0;
}
08Wizard step slide
What happens on screen
Moving to the next step slides the current panel out left and brings the new one in from the right, and going back reverses the direction exactly.

<main class="stage">
<section class="wiz is-demo" aria-label="Step panels slide in the direction you move">
<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">Step 1 · Account</p>
<p class="wiz__q">Enter your name and email</p>
</div>
<div class="wiz__panel wiz__panel--2">
<p class="wiz__step">Step 2 · Shipping</p>
<p class="wiz__q">Enter your shipping address</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">Back</label>
<label class="wiz__nav wiz__nav--next" for="wiz-2">Next</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 · Wizard step slide — Measured area 7.495% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
You never have to read an arrow to know where you are in the flow, so multi-step forms stop feeling like a maze.
.wiz__panel { transition: transform $duration $easing, opacity $duration $easing; }
.wiz__panel--2 { transform: translateX(100%); opacity: 0; } // step 2's home side: right
// going back sends step 1 to its left home and step 2 back to the right — direction flips
#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; }
09Diagonal scene swap
What moves
Two panels crossing the frame diagonally slide past in the same direction, one stepping in a beat behind the other, and sweep away to uncover the next scene hidden behind them in one motion.

<main class="stage">
<section class="scene is-demo" aria-label="Two diagonal panels sweep past and swap the scene">
<input class="scene__check" type="checkbox" id="scene-swap">
<div class="scene__frame">
<div class="scene__inner scene__inner--a"><p class="scene__name">Spring collection</p></div>
<div class="scene__inner scene__inner--b"><p class="scene__name">Summer collection</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">Swap scene</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 · Diagonal scene swap — Measured area 33.21% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Season or campaign scenes on a landing page can change dramatically without a single reload.
.scene__wipe {
top: -20%; bottom: -20%; left: -45%;
width: 190%;
transform: skewX(-14deg) translateX(-115%); // a panel laid diagonally
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); } // swap at midpoint
Where does it break — one trap
The first version of the fly-to-cart dot clearly moved, but it landed somewhere off target. I had eyeballed the distance from the card to the cart and typed translateX(247px); measuring the real coordinates with Playwright showed the badge center sat farther right than expected, and the dot was vanishing into thin air 26px short of it. Remeasuring both centers with getBoundingClientRect and correcting to translateX(273px) and translateY(-71px) made the dot land dead center on the badge. The same inspection caught the pseudo-element that draws the basket handle: it was anchored to the wrong parent and floating loose in a corner of the stage, fixed with one position: relative line. Flight animations should never be aimed by eye — measure the coordinates. Change that arrives with each notch of a scroll wheel instead belongs to 9 CSS Animation on Scroll Effects. The password that unlocks these nine files is 2aq7sbjt, published only after exactly this kind of verification.
Accessibility
All nine effects turn off motion under prefers-reduced-motion: reduce and keep the final state visible. 01 shows the headline with the blinds already folded away, 02 hangs the photo straight with no swing, 03, 04, and 08 swap cards, scenes, and panels instantly with no transition, 05 seats the chip in the slot immediately, 06 keeps the dot hidden and only bumps the badge, 07 skips the tearing and removes the ticket at once, and 09 swaps the scene with no panels sweeping through. Every click target is a real radio or checkbox wired to a visible label, so keyboard and screen-reader users can operate them too, and purely decorative elements are marked aria-hidden. Why transform-based motion stays smooth is documented at MDN's transform reference, and what the reduced-motion media query means at MDN's prefers-reduced-motion page. For transitions between whole pages, see 9 CSS Page Transitions, and for tab panels, the tab menu transition collection.
FAQ
Do I really need zero lines of JavaScript?
These nine run on CSS alone. Anything click-driven hides a radio or checkbox on the page and lets CSS follow the :checked state, so they work the moment you paste them in. The React versions use useState to manage that state instead. A transition that happens inside a single button is the ground 9 button hover effects css covers.
What do I change to drop this page transition html css into my own site?
Adjust the class names of the moving parts and the distance values to match your own layout. The chip flight in 05 and the cart flight in 06 depend on where your chips, cards, and cart icon actually sit, so measure the element coordinates in your browser's developer tools and enter those numbers.
Can the View Transitions API do the same thing?
The View Transitions API is a browser feature for moments when an entire document or large region changes, not for small in-page state changes like these, and its support still varies between browsers. These nine effects use only transform and opacity so they run identically everywhere.









