CSS 画面切り替え アニメーション9選
CSS 画面切り替え アニメーションとは、ページを読み込み直さずに、画像が別の画像に変わったり、注文が確定したり、フォームが次の段階へ進んだりするとき、その変化を滑らかに見せる短い動きのことです。
01 blinds-slat-reveal
02 polaroid-swing-in
03 view-switch-grid-list
04 product-image-swap
05 chip-slot-fill
06 fly-to-cart
07 ticket-tear-confirm
08 wizard-step-slide
09 split-diagonal-swap
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ブラインド開閉の登場
- 02 ポラロイドの揺れ登場
- 03 グリッド⇔リスト表示切替
- 04 メイン画像スライド切替
- 05 フィルターチップのスロット埋め
- 06 カートへ飛ぶ追加
- 07 チケット破り確認
- 08 ステップ方向スライド
- 09 斜めシーン交代
9種の css 画面 遷移 アニメーションは人気順ではなく、訪問者がページで出会う順番に並べています。最初の場面がブラインドのように開き(01)、写真がひもに吊るされて揺れながら現れ(02)、見方の切り替えがグリッドとリストの間を滑り(03)、目を引く大きな画像が同じ場所で入れ替わり(04)、選んだ条件がチップとしてスロットへ飛び込み(05)、追加した商品が放物線を描いてカートへ届き(06)、確認の瞬間にチケットが破れ(07)、フォームが進む方向に合わせて流れ(08)、最後に場面全体が斜めに裂けて次へ変わります(09)。入れ替わりをタブ数枚の中に縮めればCSS タブ 切り替え アニメーション9選、下のドックへ移せばフローティングメニュー デザイン9選になります。
| 番号 | 名前 | 何が動くか | JS |
|---|---|---|---|
| 01 | ブラインド開閉の登場 | スラット6枚の scaleY 連鎖 |
なし |
| 02 | ポラロイドの揺れ登場 | カードの rotate 減衰振動 |
なし |
| 03 | グリッド⇔リスト表示切替 | カードの translate 座標 |
なし |
| 04 | メイン画像スライド切替 | シーンの translateX 交差 |
なし |
| 05 | フィルターチップのスロット埋め | チップの translate 飛行 |
なし |
| 06 | カートへ飛ぶ追加 | 点の二重 translate 放物線 |
なし |
| 07 | チケット破り確認 | 左右の rotate 分離 |
なし |
| 08 | ステップ方向スライド | パネルの translateX ±100% |
なし |
| 09 | 斜めシーン交代 | skew の板が通過 |
なし |
01ブラインド開閉の登場
css ページ遷移 アニメーションとして何が見えるか
ページが開くと、明るい見出しパネルを覆っていた横ブラインド6枚が上から順に折りたたまれ、下の文を現します。

<main class="stage">
<section class="reveal is-demo" aria-label="ブラインドが折りたたまれ春の見出しが現れる">
<div class="reveal__panel">
<p class="reveal__kicker">春シーズン開幕</p>
<h2 class="reveal__headline">新コレクション 20%オフ</h2>
</div>
<div class="reveal__slats" aria-hidden="true">
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
<span class="reveal__slat"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.2s; // 브라인드 한 살이 접히는 시간
$easing: $ease-out;
$color: $subject-blue; // 패널 그라데이션 시작색
@include stage($stage-ink);
.reveal {
position: relative;
width: min(320px, 88vw);
height: clamp(150px, 52vh, 180px);
border-radius: $r-card;
overflow: hidden;
}
.reveal__panel {
position: absolute; inset: 0;
display: grid;
align-content: center;
gap: $sp-2;
padding: $sp-4;
background: linear-gradient(135deg, $color 0%, $stage-yellow 100%);
}
.reveal__kicker {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: $stage-ink;
letter-spacing: 2px;
}
.reveal__headline {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.4vw, 26px);
line-height: 1.25;
font-family: inherit;
color: $stage-paper;
}
.reveal__slats {
position: absolute; inset: 0;
display: flex;
flex-direction: column;
}
.reveal__slat {
flex: 1;
background: $stage-paper;
border-bottom: 2px solid rgba(23, 20, 26, .12);
transform-origin: top center; // 규칙8: 윗변서 접힘
transform: scaleY(1);
}
// 실사용: 페이지 첫 등장에 한 번 — 살마다 90ms 늦게 위→아래로 접힌다
@keyframes slat-open { from { transform: scaleY(1); } to { transform: scaleY(0); } }
@for $i from 1 through 6 {
.reveal__slat:nth-child(#{$i}) {
animation: slat-open $duration $easing (($i - 1) * 90ms) both;
}
}
// 렌더·갤러리용 자동 루프: 닫힘 → 열림 파동 → 다시 닫힘 (2초 주기 안에서 계단 재현)
@for $i from 1 through 6 {
.reveal.is-demo .reveal__slat:nth-child(#{$i}) {
animation: slat-loop-#{$i} $dur-loop $easing infinite;
}
@keyframes slat-loop-#{$i} {
0%, 8% { transform: scaleY(1); }
#{10 + $i * 6}%, 78% { transform: scaleY(0); }
92%, 100% { transform: scaleY(1); }
}
}
@media (prefers-reduced-motion: reduce) {
.reveal__slat,
.reveal.is-demo .reveal__slat {
animation: none;
transform: scaleY(0); // 접힌 최종 상태 — 문구가 바로 보인다
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.reveal {
position: relative;
width: min(320px, 88vw);
height: clamp(150px, 52vh, 180px);
border-radius: 16px;
overflow: hidden;
}
.reveal__panel {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 8px;
padding: 16px;
background: linear-gradient(135deg, #2f6df6 0%, #ffd23f 100%);
}
.reveal__kicker {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: #17141a;
letter-spacing: 2px;
}
.reveal__headline {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.4vw, 26px);
line-height: 1.25;
font-family: inherit;
color: #ffffff;
}
.reveal__slats {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.reveal__slat {
flex: 1;
background: #ffffff;
border-bottom: 2px solid rgba(23, 20, 26, 0.12);
transform-origin: top center;
transform: scaleY(1);
}
@keyframes slat-open {
from {
transform: scaleY(1);
}
to {
transform: scaleY(0);
}
}
.reveal__slat:nth-child(1) {
animation: slat-open 1.2s ease-out 0ms both;
}
.reveal__slat:nth-child(2) {
animation: slat-open 1.2s ease-out 90ms both;
}
.reveal__slat:nth-child(3) {
animation: slat-open 1.2s ease-out 180ms both;
}
.reveal__slat:nth-child(4) {
animation: slat-open 1.2s ease-out 270ms both;
}
.reveal__slat:nth-child(5) {
animation: slat-open 1.2s ease-out 360ms both;
}
.reveal__slat:nth-child(6) {
animation: slat-open 1.2s ease-out 450ms both;
}
.reveal.is-demo .reveal__slat:nth-child(1) {
animation: slat-loop-1 2s ease-out infinite;
}
@keyframes slat-loop-1 {
0%, 8% {
transform: scaleY(1);
}
16%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(2) {
animation: slat-loop-2 2s ease-out infinite;
}
@keyframes slat-loop-2 {
0%, 8% {
transform: scaleY(1);
}
22%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(3) {
animation: slat-loop-3 2s ease-out infinite;
}
@keyframes slat-loop-3 {
0%, 8% {
transform: scaleY(1);
}
28%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(4) {
animation: slat-loop-4 2s ease-out infinite;
}
@keyframes slat-loop-4 {
0%, 8% {
transform: scaleY(1);
}
34%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(5) {
animation: slat-loop-5 2s ease-out infinite;
}
@keyframes slat-loop-5 {
0%, 8% {
transform: scaleY(1);
}
40%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
.reveal.is-demo .reveal__slat:nth-child(6) {
animation: slat-loop-6 2s ease-out infinite;
}
@keyframes slat-loop-6 {
0%, 8% {
transform: scaleY(1);
}
46%, 78% {
transform: scaleY(0);
}
92%, 100% {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.reveal__slat,
.reveal.is-demo .reveal__slat {
animation: none;
transform: scaleY(0);
}
}import type { CSSProperties } from "react";
import styles from "./BlindsSlatReveal.module.scss";
export type BlindsSlatRevealProps = {
kicker: string;
headline: string;
/** 살이 전부 접히는 데 걸리는 시간 (기본 1200ms) */
duration?: number;
easing?: string;
/** 패널 그라데이션 시작색 */
color?: string;
/** 블라인드 살 개수 (기본 6) */
slats?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BlindsSlatReveal({
kicker,
headline,
duration = 1200,
easing = "ease-out",
color = "#2f6df6",
slats = 6,
}: BlindsSlatRevealProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.reveal} style={vars} aria-label={headline}>
<div className={styles.panel}>
<p className={styles.kicker}>{kicker}</p>
<h2 className={styles.headline}>{headline}</h2>
</div>
<div className={styles.slats} aria-hidden="true">
{Array.from({ length: slats }, (_, i) => (
<span key={i} className={styles.slat} style={{ animationDelay: `${i * 90}ms` }} />
))}
</div>
</section>
);
}01 · ブラインド開閉の登場 — 計測 累積面積 33.702% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ヒーロー見出しが一度に現れるのではなく、スラット一枚ずつ落ち着いて開いていくのが違いです。
.reveal__slat {
transform-origin: top center;
transform: scaleY(1);
animation: slat-open $duration $easing both;
}
@keyframes slat-open {
from { transform: scaleY(1); }
to { transform: scaleY(0); }
}
// スラットごとに90msずつ遅らせて階段のように
.reveal__slat:nth-child(2) { animation-delay: 90ms; }
.reveal__slat:nth-child(3) { animation-delay: 180ms; }
02ポラロイドの揺れ登場
画面で起きること
上端一点に吊るされたポラロイド写真カードが、傾いたまま落ちてきて左右に数回揺れた後、まっすぐ収まります。

<main class="stage">
<figure class="swing is-demo" aria-label="ポラロイドカードが吊るされたまま揺れながら登場する">
<span class="swing__pin" aria-hidden="true"></span>
<span class="swing__string" aria-hidden="true"></span>
<div class="swing__photo">
<span class="swing__shot" aria-hidden="true"></span>
<figcaption class="swing__caption">済州島、4月の海</figcaption>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1.1s; // 흔들림이 잦아들 때까지의 시간
$easing: $ease-pop; // 오버슈트 — 그네 감쇠 운동
$color: $subject-blue; // 사진 그라데이션
@include stage($stage-yellow);
.swing {
position: relative;
margin: 0;
width: min(190px, 56vw);
transform-origin: top center; // 한 점에 매달림
}
.swing__pin {
position: absolute;
top: -6px; left: 50%;
width: 12px; height: 12px;
margin-left: -6px;
border-radius: 50%;
background: $stage-ink;
}
.swing__string {
position: absolute;
top: 0; left: 50%;
width: 2px; height: 24px;
margin-left: -1px;
background: rgba(23, 20, 26, .4);
}
.swing__photo {
margin-top: 24px;
padding: $sp-2 $sp-2 $sp-1;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
}
.swing__shot {
display: block;
aspect-ratio: 1 / 1;
border-radius: $r-xs;
background: linear-gradient(160deg, $color 0%, $stage-yellow 90%);
}
.swing__caption {
padding-top: $sp-2;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: $stage-ink;
text-align: center;
}
// 실사용: 첫 등장 1회 — 기울어 떨어지며 좌우로 흔들리다 반듯하게 멈춘다
@keyframes swing-in {
0% { transform: translateY(-70px) rotate(18deg); opacity: 0; }
18% { transform: translateY(0) rotate(14deg); opacity: 1; }
38% { transform: rotate(-11deg); }
56% { transform: rotate(7deg); }
72% { transform: rotate(-4deg); }
86% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
.swing { animation: swing-in $duration $easing both; }
// 렌더·갤러리용 자동 루프: 등장→흔들림→사라짐을 2초 주기로
@keyframes swing-loop {
0% { transform: translateY(-70px) rotate(18deg); opacity: 0; }
12% { transform: translateY(0) rotate(14deg); opacity: 1; }
26% { transform: rotate(-11deg); }
38% { transform: rotate(7deg); }
48% { transform: rotate(-4deg); }
56% { transform: rotate(0deg); }
84%, 100% { transform: rotate(0deg); opacity: 1; }
}
.swing.is-demo { animation: swing-loop $dur-loop $easing infinite; }
@media (max-width: 360px) { // 320×200 아이프레임 안에 whole 카드가 들어오게
.swing { width: 112px; }
.swing__string { height: 16px; }
.swing__photo { margin-top: 16px; padding: 4px; }
.swing__caption { font-size: 10px; padding-top: 4px; }
.swing__pin { width: 8px; height: 8px; margin-left: -4px; top: -4px; }
}
@media (prefers-reduced-motion: reduce) {
.swing,
.swing.is-demo { animation: none; transform: none; } // 반듯하게 걸린 최종 상태
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.swing {
position: relative;
margin: 0;
width: min(190px, 56vw);
transform-origin: top center;
}
.swing__pin {
position: absolute;
top: -6px;
left: 50%;
width: 12px;
height: 12px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
}
.swing__string {
position: absolute;
top: 0;
left: 50%;
width: 2px;
height: 24px;
margin-left: -1px;
background: rgba(23, 20, 26, 0.4);
}
.swing__photo {
margin-top: 24px;
padding: 8px 8px 4px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.swing__shot {
display: block;
aspect-ratio: 1/1;
border-radius: 4px;
background: linear-gradient(160deg, #2f6df6 0%, #ffd23f 90%);
}
.swing__caption {
padding-top: 8px;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: #17141a;
text-align: center;
}
@keyframes swing-in {
0% {
transform: translateY(-70px) rotate(18deg);
opacity: 0;
}
18% {
transform: translateY(0) rotate(14deg);
opacity: 1;
}
38% {
transform: rotate(-11deg);
}
56% {
transform: rotate(7deg);
}
72% {
transform: rotate(-4deg);
}
86% {
transform: rotate(2deg);
}
100% {
transform: rotate(0deg);
}
}
.swing {
animation: swing-in 1.1s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes swing-loop {
0% {
transform: translateY(-70px) rotate(18deg);
opacity: 0;
}
12% {
transform: translateY(0) rotate(14deg);
opacity: 1;
}
26% {
transform: rotate(-11deg);
}
38% {
transform: rotate(7deg);
}
48% {
transform: rotate(-4deg);
}
56% {
transform: rotate(0deg);
}
84%, 100% {
transform: rotate(0deg);
opacity: 1;
}
}
.swing.is-demo {
animation: swing-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@media (max-width: 360px) {
.swing {
width: 112px;
}
.swing__string {
height: 16px;
}
.swing__photo {
margin-top: 16px;
padding: 4px;
}
.swing__caption {
font-size: 10px;
padding-top: 4px;
}
.swing__pin {
width: 8px;
height: 8px;
margin-left: -4px;
top: -4px;
}
}
@media (prefers-reduced-motion: reduce) {
.swing,
.swing.is-demo {
animation: none;
transform: none;
}
}import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import styles from "./PolaroidSwingIn.module.scss";
export type PolaroidSwingInProps = {
caption: string;
/** 흔들림이 잦아들 때까지의 시간 (기본 1100ms) */
duration?: number;
easing?: string;
/** 사진 그라데이션 색 */
color?: string;
/** 다시 흔들려면 이 값을 바꾼다 */
replayKey?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PolaroidSwingIn({
caption,
duration = 1100,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#2f6df6",
replayKey = 0,
}: PolaroidSwingInProps) {
const [run, setRun] = useState(0);
useEffect(() => { setRun(replayKey); }, [replayKey]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<figure key={run} className={styles.swing} style={vars} aria-label={caption}>
<span className={styles.pin} aria-hidden="true" />
<span className={styles.string} aria-hidden="true" />
<div className={styles.photo}>
<span className={styles.shot} aria-hidden="true" />
<figcaption className={styles.caption}>{caption}</figcaption>
</div>
</figure>
);
}02 · ポラロイドの揺れ登場 — 計測 累積面積 47.004% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ギャラリーの導入や旅の記録サムネイルに置くと、静止的な写真ページに手作りの温かさが生まれます。
.swing {
transform-origin: top center; // ピン一点が回転軸
animation: swing-in $duration $easing both;
}
@keyframes swing-in {
0% { transform: translateY(-70px) rotate(18deg); opacity: 0; }
38% { transform: rotate(-11deg); }
56% { transform: rotate(7deg); }
72% { transform: rotate(-4deg); }
100% { transform: rotate(0deg); }
}
03グリッド⇔リスト表示切替
どんな動きか
同じ商品カード3枚が、2列のグリッドと1列のリストの間を、画面の外に出ることなく行き来します。

<main class="stage">
<section class="vs is-demo" aria-label="同じカード3枚がグリッドとリスト配置を行き来する">
<input class="vs__radio" type="radio" name="vs-218" id="vs-grid" checked>
<input class="vs__radio" type="radio" name="vs-218" id="vs-list">
<div class="vs__toggle" aria-hidden="true"><span class="vs__knob"></span></div>
<div class="vs__toggle-labels">
<label class="vs__label" for="vs-grid">グリッド</label>
<label class="vs__label" for="vs-list">リスト</label>
</div>
<div class="vs__board">
<article class="vs__card vs__card--1"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">サマーニット</span></article>
<article class="vs__card vs__card--2"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">リネンシャツ</span></article>
<article class="vs__card vs__card--3"><span class="vs__thumb" aria-hidden="true"></span><span class="vs__name">ストローハット</span></article>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 카드가 배치를 바꾸는 시간
$easing: $ease-spring;
$color: $subject-blue; // 썸네일 색
@include stage($stage-orange);
.vs { width: 280px; max-width: 92vw; }
.vs__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 토글: 라디오 + 미끄러지는 손잡이 (JS 0줄) — 시각 요소는 형제로
.vs__toggle {
position: relative;
width: 168px;
height: 28px;
margin: 0 auto;
padding: 4px;
box-sizing: border-box;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
}
.vs__knob {
position: absolute; top: 4px; bottom: 4px; left: 4px;
width: calc(50% - 4px);
box-sizing: border-box;
border-radius: $r-pill;
background: $stage-paper;
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration $easing;
}
#vs-list:checked ~ .vs__toggle .vs__knob { transform: translateX(100%); }
.vs__toggle-labels {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 168px;
margin: -28px auto $sp-3; // 글자를 토글 위에 얹는다
}
.vs__label {
padding: $sp-1 0;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: $stage-paper;
text-align: center;
cursor: pointer;
transition: color $dur-quick $easing;
}
#vs-grid:checked ~ .vs__toggle-labels .vs__label[for="vs-grid"],
#vs-list:checked ~ .vs__toggle-labels .vs__label[for="vs-list"] { color: $stage-ink; }
// ── 보드: 같은 카드 3장 (크기 고정), 배치만 transform 좌표로 바뀐다
.vs__board { position: relative; height: 200px; }
.vs__card {
position: absolute; left: 0; top: 0;
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
width: 136px;
height: 60px;
padding: $sp-2;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
transition: transform $duration $easing;
}
.vs__thumb {
flex: 0 0 auto;
width: 36px; height: 36px;
border-radius: $r-sm;
background: linear-gradient(150deg, $color, $stage-yellow);
}
.vs__name {
font-weight: 700;
font-size: 12px;
line-height: 1.2;
font-family: inherit;
color: $stage-ink;
}
// 격자 배치: 2열 — % 는 카드 자기 크기 기준이라 카드가 줄면 간격도 줄어든다
.vs__card--1 { transform: translate(0, 0); }
.vs__card--2 { transform: translate(calc(100% + 8px), 0); }
.vs__card--3 { transform: translate(0, calc(100% + 10px)); }
// 목록 배치: 한 줄씩 아래로
#vs-list:checked ~ .vs__board .vs__card--1 { transform: translate(0, 0); }
#vs-list:checked ~ .vs__board .vs__card--2 { transform: translate(0, calc(100% + 10px)); }
#vs-list:checked ~ .vs__board .vs__card--3 { transform: translate(0, calc(200% + 20px)); }
// 렌더·갤러리용 자동 루프: 격자→목록→격자를 2초 주기로
.vs.is-demo .vs__knob { animation: knob-loop $dur-loop $easing infinite; }
.vs.is-demo .vs__label[for="vs-grid"] { animation: lab-grid $dur-loop $easing infinite; }
.vs.is-demo .vs__label[for="vs-list"] { animation: lab-list $dur-loop $easing infinite; }
.vs.is-demo .vs__card--2 { animation: c2-loop $dur-loop $easing infinite; }
.vs.is-demo .vs__card--3 { animation: c3-loop $dur-loop $easing infinite; }
@keyframes knob-loop {
0%, 20%, 100% { transform: translateX(0); }
45%, 65% { transform: translateX(100%); }
}
@keyframes lab-grid { // 손잡이가 떠나자마자 색이 따라간다
0%, 24% { color: $stage-ink; }
32%, 68% { color: $stage-paper; }
78%, 100% { color: $stage-ink; }
}
@keyframes lab-list {
0%, 24% { color: $stage-paper; }
32%, 68% { color: $stage-ink; }
78%, 100% { color: $stage-paper; }
}
@keyframes c2-loop {
0%, 20%, 100% { transform: translate(calc(100% + 8px), 0); }
45%, 65% { transform: translate(0, calc(100% + 10px)); }
}
@keyframes c3-loop {
0%, 20%, 100% { transform: translate(0, calc(100% + 10px)); }
45%, 65% { transform: translate(0, calc(200% + 20px)); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.vs { width: 232px; }
.vs__toggle { width: 140px; height: 24px; }
.vs__toggle-labels { width: 140px; margin: -24px auto 8px; }
.vs__label { font-size: 11px; padding: 4px 0; }
.vs__board { height: 140px; }
.vs__card { width: 112px; height: 40px; padding: 4px; gap: 4px; }
.vs__thumb { width: 24px; height: 24px; }
.vs__name { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.vs__knob, .vs__card, .vs__label { transition: none; }
.vs.is-demo .vs__knob,
.vs.is-demo .vs__card,
.vs.is-demo .vs__label { animation: none; } // 즉시 목표 배치로 전환
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vs {
width: 280px;
max-width: 92vw;
}
.vs__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.vs__toggle {
position: relative;
width: 168px;
height: 28px;
margin: 0 auto;
padding: 4px;
box-sizing: border-box;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.vs__knob {
position: absolute;
top: 4px;
bottom: 4px;
left: 4px;
width: calc(50% - 4px);
box-sizing: border-box;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#vs-list:checked ~ .vs__toggle .vs__knob {
transform: translateX(100%);
}
.vs__toggle-labels {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
width: 168px;
margin: -28px auto 12px;
}
.vs__label {
padding: 4px 0;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: #ffffff;
text-align: center;
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#vs-grid:checked ~ .vs__toggle-labels .vs__label[for=vs-grid],
#vs-list:checked ~ .vs__toggle-labels .vs__label[for=vs-list] {
color: #17141a;
}
.vs__board {
position: relative;
height: 200px;
}
.vs__card {
position: absolute;
left: 0;
top: 0;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 136px;
height: 60px;
padding: 8px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vs__thumb {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(150deg, #2f6df6, #ffd23f);
}
.vs__name {
font-weight: 700;
font-size: 12px;
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.vs__card--1 {
transform: translate(0, 0);
}
.vs__card--2 {
transform: translate(calc(100% + 8px), 0);
}
.vs__card--3 {
transform: translate(0, calc(100% + 10px));
}
#vs-list:checked ~ .vs__board .vs__card--1 {
transform: translate(0, 0);
}
#vs-list:checked ~ .vs__board .vs__card--2 {
transform: translate(0, calc(100% + 10px));
}
#vs-list:checked ~ .vs__board .vs__card--3 {
transform: translate(0, calc(200% + 20px));
}
.vs.is-demo .vs__knob {
animation: knob-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__label[for=vs-grid] {
animation: lab-grid 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__label[for=vs-list] {
animation: lab-list 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__card--2 {
animation: c2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vs.is-demo .vs__card--3 {
animation: c3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes knob-loop {
0%, 20%, 100% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(100%);
}
}
@keyframes lab-grid {
0%, 24% {
color: #17141a;
}
32%, 68% {
color: #ffffff;
}
78%, 100% {
color: #17141a;
}
}
@keyframes lab-list {
0%, 24% {
color: #ffffff;
}
32%, 68% {
color: #17141a;
}
78%, 100% {
color: #ffffff;
}
}
@keyframes c2-loop {
0%, 20%, 100% {
transform: translate(calc(100% + 8px), 0);
}
45%, 65% {
transform: translate(0, calc(100% + 10px));
}
}
@keyframes c3-loop {
0%, 20%, 100% {
transform: translate(0, calc(100% + 10px));
}
45%, 65% {
transform: translate(0, calc(200% + 20px));
}
}
@media (max-width: 360px) {
.vs {
width: 232px;
}
.vs__toggle {
width: 140px;
height: 24px;
}
.vs__toggle-labels {
width: 140px;
margin: -24px auto 8px;
}
.vs__label {
font-size: 11px;
padding: 4px 0;
}
.vs__board {
height: 140px;
}
.vs__card {
width: 112px;
height: 40px;
padding: 4px;
gap: 4px;
}
.vs__thumb {
width: 24px;
height: 24px;
}
.vs__name {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.vs__knob, .vs__card, .vs__label {
transition: none;
}
.vs.is-demo .vs__knob,
.vs.is-demo .vs__card,
.vs.is-demo .vs__label {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./ViewSwitchGridList.module.scss";
export type ViewSwitchGridListProps = {
items: string[];
/** 카드가 배치을 바꾸는 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 썸네일 그라데이션 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ViewSwitchGridList({
items,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ViewSwitchGridListProps) {
const [list, setList] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const layout = list ? "list" : "grid";
return (
<section className={styles.vs} style={vars} aria-label="보기 방식 전환">
<div className={styles.toggle} role="tablist" aria-label="보기 방식">
<button
type="button"
className={`${styles.label} ${layout === "grid" ? styles.labelOn : ""}`}
role="tab" aria-selected={layout === "grid"}
onClick={() => setList(false)}
>
격자
</button>
<button
type="button"
className={`${styles.label} ${layout === "list" ? styles.labelOn : ""}`}
role="tab" aria-selected={layout === "list"}
onClick={() => setList(true)}
>
목록
</button>
<span className={styles.knob} aria-hidden="true" data-at={layout} />
</div>
<div className={styles.board}>
{items.map((name, i) => (
<article key={name} className={styles.card} data-pos={layout === "list" ? `l${i + 1}` : `g${i + 1}`}>
<span className={styles.thumb} aria-hidden="true" />
<span className={styles.name}>{name}</span>
</article>
))}
</div>
</section>
);
}03 · グリッド⇔リスト表示切替 — 計測 累積面積 28.997% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
トグルのつまみとカードが同時に動くので、触れた瞬間に画面が生きている感覚になります。
.vs__card { transition: transform $duration $easing; }
// % はカード自身の大きさ基準 — グリッド: 右へカード1枚分
.vs__card--2 { transform: translate(calc(100% + 8px), 0); }
.vs__card--3 { transform: translate(0, calc(100% + 10px)); }
// リスト: 同じカードが1行ずつ下へ
#vs-list:checked ~ .vs__board .vs__card--2 { transform: translate(0, calc(100% + 10px)); }
#vs-list:checked ~ .vs__board .vs__card--3 { transform: translate(0, calc(200% + 20px)); }
04メイン画像スライド切替
何が見えるか
下のサムネイルを選ぶと、今の画像が左へ抜け、新しい画像が右から同じ枠に入ります。

<main class="stage">
<section class="gal is-demo" aria-label="サムネイルを選ぶとメイン画像が同じ場所で切り替わる">
<input class="gal__radio" type="radio" name="gal-218" id="gal-1" checked>
<input class="gal__radio" type="radio" name="gal-218" id="gal-2">
<input class="gal__radio" type="radio" name="gal-218" id="gal-3">
<div class="gal__frame">
<div class="gal__scene gal__scene--1"><span class="gal__tag">正面</span></div>
<div class="gal__scene gal__scene--2"><span class="gal__tag">側面</span></div>
<div class="gal__scene gal__scene--3"><span class="gal__tag">ディテール</span></div>
</div>
<div class="gal__thumbs">
<label class="gal__thumb gal__thumb--1" for="gal-1">正面</label>
<label class="gal__thumb gal__thumb--2" for="gal-2">側面</label>
<label class="gal__thumb gal__thumb--3" for="gal-3">ディテール</label>
<span class="gal__indicator" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 이미지가 자리를 바꾸는 시간
$easing: $ease-out;
$color: $subject-blue; // 1번 장면 색
@include stage($stage-paper);
.gal { width: 280px; max-width: 100%; }
.gal__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 큰 이미지 틀: 장면 3장이 겹쳐 있고 translateX 로만 교체된다
.gal__frame {
position: relative;
height: clamp(100px, 50vh, 176px);
border-radius: $r-card;
overflow: hidden;
background: rgba(23, 20, 26, .06);
}
.gal__scene {
position: absolute; inset: 0;
display: grid;
place-items: center;
transform: translateX(110%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.gal__scene--1 { background: linear-gradient(150deg, $color, rgba($color, .35)); }
.gal__scene--2 { background: linear-gradient(150deg, $stage-orange, rgba($stage-orange, .35)); }
.gal__scene--3 { background: linear-gradient(150deg, $stage-ink, rgba($stage-ink, .5)); }
.gal__tag {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .9);
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: $stage-ink;
}
.gal__scene--1 { transform: translateX(0); opacity: 1; } // 기본 = 1번
// 라디오로 고르면: 새 장면은 오른쪽에서 들어오고 나머지는 왼쪽으로 빠진다
#gal-1:checked ~ .gal__frame .gal__scene--1 { transform: translateX(0); opacity: 1; }
#gal-1:checked ~ .gal__frame .gal__scene--2,
#gal-1:checked ~ .gal__frame .gal__scene--3 { transform: translateX(-110%); opacity: 0; }
#gal-2:checked ~ .gal__frame .gal__scene--2 { transform: translateX(0); opacity: 1; }
#gal-2:checked ~ .gal__frame .gal__scene--1,
#gal-2:checked ~ .gal__frame .gal__scene--3 { transform: translateX(-110%); opacity: 0; }
#gal-3:checked ~ .gal__frame .gal__scene--3 { transform: translateX(0); opacity: 1; }
#gal-3:checked ~ .gal__frame .gal__scene--1,
#gal-3:checked ~ .gal__frame .gal__scene--2 { transform: translateX(-110%); opacity: 0; }
// ── 썸네일: 고정 폭 셀 3개 + 밑에 움직이는 인디케이터 (transform 만)
.gal__thumbs {
position: relative;
display: flex;
gap: $sp-2;
justify-content: center;
margin-top: $sp-3;
padding-bottom: $sp-2;
}
.gal__thumb {
box-sizing: border-box;
width: 88px;
padding: $sp-2 0;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: $stage-paper;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: rgba(23, 20, 26, .7);
text-align: center;
cursor: pointer;
transition: color $dur-quick $easing, border-color $dur-quick $easing;
}
#gal-1:checked ~ .gal__thumbs .gal__thumb--1,
#gal-2:checked ~ .gal__thumbs .gal__thumb--2,
#gal-3:checked ~ .gal__thumbs .gal__thumb--3 { color: $stage-ink; border-color: $stage-ink; }
.gal__indicator {
position: absolute;
bottom: 0; left: calc(50% - 140px); // 행의 왼쪽 끝 (280px 행 기준)
width: 88px;
height: 4px;
border-radius: $r-pill;
background: $stage-ink;
transform: translateX(0);
transition: transform $duration $easing;
}
#gal-2:checked ~ .gal__thumbs .gal__indicator { transform: translateX(96px); }
#gal-3:checked ~ .gal__thumbs .gal__indicator { transform: translateX(192px); }
// 렌더·갤러리용 자동 루프: 1→2→3→1 을 2초 주기로
.gal.is-demo .gal__scene--1 { animation: s1-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__scene--2 { animation: s2-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__scene--3 { animation: s3-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__indicator { animation: ind-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--1 { animation: th1 $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--2 { animation: th2 $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--3 { animation: th3 $dur-loop $easing infinite; }
@keyframes s1-loop {
0%, 23% { transform: translateX(0); opacity: 1; }
33%, 88% { transform: translateX(-110%); opacity: 0; }
98%, 100% { transform: translateX(0); opacity: 1; }
}
@keyframes s2-loop {
0%, 23% { transform: translateX(110%); opacity: 0; }
33%, 56% { transform: translateX(0); opacity: 1; }
66%, 100% { transform: translateX(-110%); opacity: 0; }
}
@keyframes s3-loop {
0%, 56% { transform: translateX(110%); opacity: 0; }
66%, 88% { transform: translateX(0); opacity: 1; }
98%, 100% { transform: translateX(110%); opacity: 0; }
}
@keyframes ind-loop {
0%, 23% { transform: translateX(0); }
33%, 56% { transform: translateX(calc(100% + 8px)); }
66%, 88% { transform: translateX(calc(200% + 16px)); }
98%, 100% { transform: translateX(0); }
}
@keyframes th1 { // 활성 엄지 테두리가 장면과 함께 이동
0%, 23% { border-color: $stage-ink; color: $stage-ink; }
33%, 88% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
98%, 100% { border-color: $stage-ink; color: $stage-ink; }
}
@keyframes th2 {
0%, 23% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
33%, 56% { border-color: $stage-ink; color: $stage-ink; }
66%, 100% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
}
@keyframes th3 {
0%, 56% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
66%, 88% { border-color: $stage-ink; color: $stage-ink; }
98%, 100% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.gal__frame { height: 100px; }
.gal__thumb { width: 72px; padding: 4px 0; font-size: 10px; }
.gal__indicator { width: 72px; left: calc(50% - 116px); }
#gal-2:checked ~ .gal__thumbs .gal__indicator { transform: translateX(80px); }
#gal-3:checked ~ .gal__thumbs .gal__indicator { transform: translateX(160px); }
}
@media (prefers-reduced-motion: reduce) {
.gal__scene, .gal__thumb, .gal__indicator { transition: none; }
.gal.is-demo .gal__scene,
.gal.is-demo .gal__indicator,
.gal.is-demo .gal__thumb { animation: none; } // 장면·인디케이터가 즉시 바뀐다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gal {
width: 280px;
max-width: 100%;
}
.gal__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.gal__frame {
position: relative;
height: clamp(100px, 50vh, 176px);
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.06);
}
.gal__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transform: translateX(110%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.gal__scene--1 {
background: linear-gradient(150deg, #2f6df6, rgba(47, 109, 246, 0.35));
}
.gal__scene--2 {
background: linear-gradient(150deg, #ff4d1f, rgba(255, 77, 31, 0.35));
}
.gal__scene--3 {
background: linear-gradient(150deg, #17141a, rgba(23, 20, 26, 0.5));
}
.gal__tag {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.9);
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: #17141a;
}
.gal__scene--1 {
transform: translateX(0);
opacity: 1;
}
#gal-1:checked ~ .gal__frame .gal__scene--1 {
transform: translateX(0);
opacity: 1;
}
#gal-1:checked ~ .gal__frame .gal__scene--2,
#gal-1:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(-110%);
opacity: 0;
}
#gal-2:checked ~ .gal__frame .gal__scene--2 {
transform: translateX(0);
opacity: 1;
}
#gal-2:checked ~ .gal__frame .gal__scene--1,
#gal-2:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(-110%);
opacity: 0;
}
#gal-3:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(0);
opacity: 1;
}
#gal-3:checked ~ .gal__frame .gal__scene--1,
#gal-3:checked ~ .gal__frame .gal__scene--2 {
transform: translateX(-110%);
opacity: 0;
}
.gal__thumbs {
position: relative;
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
padding-bottom: 8px;
}
.gal__thumb {
box-sizing: border-box;
width: 88px;
padding: 8px 0;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: #ffffff;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: rgba(23, 20, 26, 0.7);
text-align: center;
cursor: pointer;
transition: color 220ms ease-out, border-color 220ms ease-out;
}
#gal-1:checked ~ .gal__thumbs .gal__thumb--1,
#gal-2:checked ~ .gal__thumbs .gal__thumb--2,
#gal-3:checked ~ .gal__thumbs .gal__thumb--3 {
color: #17141a;
border-color: #17141a;
}
.gal__indicator {
position: absolute;
bottom: 0;
left: calc(50% - 140px);
width: 88px;
height: 4px;
border-radius: 999px;
background: #17141a;
transform: translateX(0);
transition: transform 300ms ease-out;
}
#gal-2:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(96px);
}
#gal-3:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(192px);
}
.gal.is-demo .gal__scene--1 {
animation: s1-loop 2s ease-out infinite;
}
.gal.is-demo .gal__scene--2 {
animation: s2-loop 2s ease-out infinite;
}
.gal.is-demo .gal__scene--3 {
animation: s3-loop 2s ease-out infinite;
}
.gal.is-demo .gal__indicator {
animation: ind-loop 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--1 {
animation: th1 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--2 {
animation: th2 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--3 {
animation: th3 2s ease-out infinite;
}
@keyframes s1-loop {
0%, 23% {
transform: translateX(0);
opacity: 1;
}
33%, 88% {
transform: translateX(-110%);
opacity: 0;
}
98%, 100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes s2-loop {
0%, 23% {
transform: translateX(110%);
opacity: 0;
}
33%, 56% {
transform: translateX(0);
opacity: 1;
}
66%, 100% {
transform: translateX(-110%);
opacity: 0;
}
}
@keyframes s3-loop {
0%, 56% {
transform: translateX(110%);
opacity: 0;
}
66%, 88% {
transform: translateX(0);
opacity: 1;
}
98%, 100% {
transform: translateX(110%);
opacity: 0;
}
}
@keyframes ind-loop {
0%, 23% {
transform: translateX(0);
}
33%, 56% {
transform: translateX(calc(100% + 8px));
}
66%, 88% {
transform: translateX(calc(200% + 16px));
}
98%, 100% {
transform: translateX(0);
}
}
@keyframes th1 {
0%, 23% {
border-color: #17141a;
color: #17141a;
}
33%, 88% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
98%, 100% {
border-color: #17141a;
color: #17141a;
}
}
@keyframes th2 {
0%, 23% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
33%, 56% {
border-color: #17141a;
color: #17141a;
}
66%, 100% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes th3 {
0%, 56% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
66%, 88% {
border-color: #17141a;
color: #17141a;
}
98%, 100% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
}
@media (max-width: 360px) {
.gal__frame {
height: 100px;
}
.gal__thumb {
width: 72px;
padding: 4px 0;
font-size: 10px;
}
.gal__indicator {
width: 72px;
left: calc(50% - 116px);
}
#gal-2:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(80px);
}
#gal-3:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(160px);
}
}
@media (prefers-reduced-motion: reduce) {
.gal__scene, .gal__thumb, .gal__indicator {
transition: none;
}
.gal.is-demo .gal__scene,
.gal.is-demo .gal__indicator,
.gal.is-demo .gal__thumb {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./ProductImageSwap.module.scss";
export type ProductImageSwapProps = {
/** 장면 이름 순서 — 썸네일 라벨과 같다 */
scenes: string[];
/** 장면 배경색 순서 (장면 수와 같게) */
colors?: string[];
/** 이미지가 자리를 바꾸는 시간 (기본 300ms) */
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ProductImageSwap({
scenes,
colors = ["#2f6df6", "#ff4d1f", "#17141a"],
duration = 300,
easing = "ease-out",
}: ProductImageSwapProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<section className={styles.gal} style={vars} aria-label="상품 이미지 갤러리">
<div className={styles.frame}>
{scenes.map((name, i) => (
<div
key={name}
className={styles.scene}
data-active={i === active}
style={{ background: `linear-gradient(150deg, ${colors[i % colors.length]}, rgba(0,0,0,.25))` }}
>
<span className={styles.tag}>{name}</span>
</div>
))}
</div>
<div className={styles.thumbs}>
{scenes.map((name, i) => (
<button
key={name}
type="button"
className={`${styles.thumb} ${i === active ? styles.thumbOn : ""}`}
aria-pressed={i === active}
onClick={() => setActive(i)}
>
{name}
</button>
))}
<span className={styles.indicator} data-index={Math.min(active, 2)} aria-hidden="true" />
</div>
</section>
);
}04 · メイン画像スライド切替 — 計測 累積面積 31.293% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
商品ページで画像が瞬きなく流れるように入れ替わるため、選ぶ楽しさが続きます。
.gal__scene {
position: absolute; inset: 0;
transform: translateX(110%); // 待機: 右の外
transition: transform $duration $easing, opacity $duration $easing;
}
#gal-2:checked ~ .gal__frame .gal__scene--2 { transform: translateX(0); }
#gal-2:checked ~ .gal__frame .gal__scene--1 { transform: translateX(-110%); } // 左へ退場
05フィルターチップのスロット埋め
画面で起きること
色チップを選ぶと、そのチップが列から上の空きスロットへそのまま飛んで、同じ大きさでぴたりと収まります。

<main class="stage">
<section class="pick is-demo" aria-label="選んだフィルターチップが上の空きスロットへ飛んで収まる">
<input class="pick__check" type="checkbox" id="pick-1">
<input class="pick__check" type="checkbox" id="pick-2">
<input class="pick__check" type="checkbox" id="pick-3">
<p class="pick__slot"><span class="pick__slot-text">色を選んでください</span></p>
<div class="pick__row">
<label class="pick__chip pick__chip--1" for="pick-1">ブラック</label>
<label class="pick__chip pick__chip--2" for="pick-2">クリーム</label>
<label class="pick__chip pick__chip--3" for="pick-3">ミント</label>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 620ms; // 칩이 슬롯으로 날아가는 시간
$easing: $ease-spring;
$color: $subject-blue; // 포인트색 (칩 보더)
@include stage($stage-ink);
.pick {
position: relative;
width: min(300px, 90vw);
padding-top: $sp-4;
}
.pick__check { position: absolute; opacity: 0; pointer-events: none; }
// ── 위쪽 슬롯: 비어 있을 땐 점선, 칩이 착지하면 덮인다
.pick__slot {
margin: 0 0 $sp-8;
height: 44px;
border: 2px dashed rgba(255, 255, 255, .34);
border-radius: $r-pill;
transition: border-color $duration $easing;
}
.pick__slot-text {
display: grid; place-items: center;
height: 100%;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: rgba(255, 255, 255, .5);
}
// ── 아래 칩 줄: 크기 고정, transform 으로만 움직인다
.pick__row {
position: relative;
display: flex;
gap: $sp-2;
padding-left: 4px;
}
.pick__chip {
display: grid; place-items: center;
width: 84px;
height: 44px;
box-sizing: border-box;
border-radius: $r-pill;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
cursor: pointer;
transition: transform $duration $easing;
z-index: 1;
}
.pick__chip--1 { background: $subject-ink; color: $stage-paper; border: 2px solid $color; }
.pick__chip--2 { background: $subject-cream; color: $stage-ink; }
.pick__chip--3 { background: #d5eae5; color: $stage-ink; }
// 칩마다 슬롯까지의 거리가 다르다 (칩1 x4 → x0, 칩2 x96, 칩3 x188 / 낙차 76px)
#pick-1:checked ~ .pick__row .pick__chip--1 { transform: translate(-4px, -76px); z-index: 2; }
#pick-2:checked ~ .pick__row .pick__chip--2 { transform: translate(-96px, -76px); z-index: 2; }
#pick-3:checked ~ .pick__row .pick__chip--3 { transform: translate(-188px, -76px); z-index: 2; }
#pick-1:checked ~ .pick__slot,
#pick-2:checked ~ .pick__slot,
#pick-3:checked ~ .pick__slot { border-color: rgba(255, 255, 255, .8); }
// 렌더·갤러리용 자동 루프: 칩2 가 날아가 채워졌다 돌아오고, 이어 칩3 도 같은 길을 밟는다
.pick.is-demo .pick__chip--2 { animation: chip2-loop $dur-loop $easing infinite; }
.pick.is-demo .pick__chip--3 { animation: chip3-loop $dur-loop $easing infinite; }
.pick.is-demo .pick__slot { animation: slot-loop $dur-loop $easing infinite; }
@keyframes chip2-loop {
0%, 6% { transform: translate(0, 0); }
26%, 40% { transform: translate(-96px, -76px); }
50%, 100% { transform: translate(0, 0); }
}
@keyframes chip3-loop {
0%, 52% { transform: translate(0, 0); }
72%, 86% { transform: translate(-188px, -76px); }
94%, 100% { transform: translate(0, 0); }
}
@keyframes slot-loop {
0%, 12% { border-color: rgba(255, 255, 255, .34); }
30%, 42% { border-color: rgba(255, 255, 255, .8); }
58%, 68% { border-color: rgba(255, 255, 255, .34); }
76%, 88% { border-color: rgba(255, 255, 255, .8); }
96%, 100% { border-color: rgba(255, 255, 255, .34); }
}
@media (prefers-reduced-motion: reduce) {
.pick__chip, .pick__slot { transition: none; }
.pick.is-demo .pick__chip,
.pick.is-demo .pick__slot { animation: none; } // 칩이 즉시 슬롯에 얹힌다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pick {
position: relative;
width: min(300px, 90vw);
padding-top: 16px;
}
.pick__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.pick__slot {
margin: 0 0 32px;
height: 44px;
border: 2px dashed rgba(255, 255, 255, 0.34);
border-radius: 999px;
transition: border-color 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pick__slot-text {
display: grid;
place-items: center;
height: 100%;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
color: rgba(255, 255, 255, 0.5);
}
.pick__row {
position: relative;
display: flex;
gap: 8px;
padding-left: 4px;
}
.pick__chip {
display: grid;
place-items: center;
width: 84px;
height: 44px;
box-sizing: border-box;
border-radius: 999px;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
font-family: inherit;
cursor: pointer;
transition: transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 1;
}
.pick__chip--1 {
background: #17141a;
color: #ffffff;
border: 2px solid #2f6df6;
}
.pick__chip--2 {
background: #fff7e6;
color: #17141a;
}
.pick__chip--3 {
background: #d5eae5;
color: #17141a;
}
#pick-1:checked ~ .pick__row .pick__chip--1 {
transform: translate(-4px, -76px);
z-index: 2;
}
#pick-2:checked ~ .pick__row .pick__chip--2 {
transform: translate(-96px, -76px);
z-index: 2;
}
#pick-3:checked ~ .pick__row .pick__chip--3 {
transform: translate(-188px, -76px);
z-index: 2;
}
#pick-1:checked ~ .pick__slot,
#pick-2:checked ~ .pick__slot,
#pick-3:checked ~ .pick__slot {
border-color: rgba(255, 255, 255, 0.8);
}
.pick.is-demo .pick__chip--2 {
animation: chip2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pick.is-demo .pick__chip--3 {
animation: chip3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pick.is-demo .pick__slot {
animation: slot-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes chip2-loop {
0%, 6% {
transform: translate(0, 0);
}
26%, 40% {
transform: translate(-96px, -76px);
}
50%, 100% {
transform: translate(0, 0);
}
}
@keyframes chip3-loop {
0%, 52% {
transform: translate(0, 0);
}
72%, 86% {
transform: translate(-188px, -76px);
}
94%, 100% {
transform: translate(0, 0);
}
}
@keyframes slot-loop {
0%, 12% {
border-color: rgba(255, 255, 255, 0.34);
}
30%, 42% {
border-color: rgba(255, 255, 255, 0.8);
}
58%, 68% {
border-color: rgba(255, 255, 255, 0.34);
}
76%, 88% {
border-color: rgba(255, 255, 255, 0.8);
}
96%, 100% {
border-color: rgba(255, 255, 255, 0.34);
}
}
@media (prefers-reduced-motion: reduce) {
.pick__chip, .pick__slot {
transition: none;
}
.pick.is-demo .pick__chip,
.pick.is-demo .pick__slot {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./ChipSlotFill.module.scss";
export type ChipSlotFillProps = {
chips: string[];
placeholder: string;
/** 칩이 슬롯으로 날아가는 시간 (기본 620ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ChipSlotFill({
chips,
placeholder,
duration = 620,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChipSlotFillProps) {
const [picked, setPicked] = useState<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.pick} style={vars} aria-label={placeholder}>
<p className={`${styles.slot} ${picked !== null ? styles.slotOn : ""}`}>
{picked === null ? <span className={styles.slotText}>{placeholder}</span> : <span className={styles.slotText}>{chips[picked]}</span>}
</p>
<div className={styles.row}>
{chips.map((name, i) => (
<button
key={name}
type="button"
className={`${styles.chip} ${styles["chip" + (i + 1)]}`}
aria-pressed={picked === i}
data-picked={picked === i}
onClick={() => setPicked(picked === i ? null : i)}
>
{name}
</button>
))}
</div>
</section>
);
}05 · フィルターチップのスロット埋め — 計測 累積面積 12.475% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
選んだという事実が文字の変化ではなく物の移動として見えるので、買い物の画面では特に伝わりやすいです。
.pick__chip { transition: transform $duration $easing; }
// チップごとにスロットまでの距離が違う — チップ2は左へ96px・上へ76px
#pick-2:checked ~ .pick__row .pick__chip--2 {
transform: translate(-96px, -76px);
z-index: 2; // 飛ぶチップが上に浮く
}
#pick-2:checked ~ .pick__slot { border-color: rgba(255,255,255,.8); }
06カートへ飛ぶ追加
どんな動きか
「追加」を押すと、商品色の点がカードから右上のカート印まで放物線で飛び、透明になって消え、個数のバッジが弾んで知らせます。

<main class="stage">
<section class="cart is-demo" aria-label="追加を押すと商品の点が放物線でカートまで飛ぶ">
<input class="cart__check" type="checkbox" id="cart-add">
<div class="cart__bag" aria-hidden="true">
<span class="cart__basket"></span>
<span class="cart__badge">2</span>
</div>
<article class="cart__product">
<span class="cart__swatch" aria-hidden="true"></span>
<h2 class="cart__name">コージーマグ 350ml</h2>
<label class="cart__btn" for="cart-add">追加</label>
</article>
<span class="cart__fly" aria-hidden="true"><span class="cart__fly-y"></span></span>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 700ms; // 점이 카트까지 날아가는 시간
$easing: $ease-out;
$color: $stage-orange; // 상품·점 색
@include stage($stage-yellow);
.cart { position: relative; width: min(300px, 90vw); }
.cart__check { position: absolute; opacity: 0; pointer-events: none; }
// ── 오른쪽 위 장바구니 표시 + 개수 배지
.cart__bag { position: absolute; top: 0; right: 0; }
.cart__basket {
position: relative; // 손잡이 ::before 가 바구니에 붙게
display: block;
width: 44px; height: 36px;
box-sizing: border-box;
border: 3px solid $stage-ink;
border-radius: $r-xs $r-xs $r-sm $r-sm; // 바구니 몸통
}
.cart__basket::before { // 손잡이
content: "";
position: absolute; top: -8px; left: 8px;
width: 20px; height: 12px;
border: 3px solid $stage-ink;
border-bottom: 0;
border-radius: $r-pill $r-pill 0 0;
}
.cart__badge {
position: absolute; top: -8px; right: -8px;
display: grid; place-items: center;
min-width: 20px; height: 20px;
padding: 0 4px;
box-sizing: border-box;
border-radius: $r-pill;
background: $stage-ink;
font-weight: 800;
font-size: 11px;
font-family: inherit;
color: $stage-paper;
transition: transform $dur-quick $ease-pop;
}
#cart-add:checked ~ .cart__bag .cart__badge { transform: scale(1.35); }
// ── 상품 카드
.cart__product {
display: grid;
gap: $sp-2;
justify-items: start;
margin-top: $sp-12;
padding: $sp-4;
width: 180px;
box-sizing: border-box;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
}
.cart__swatch {
width: 56px; height: 56px;
border-radius: 50%;
background: $color;
}
.cart__name {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.8vw, 15px);
font-family: inherit;
color: $stage-ink;
}
.cart__btn {
padding: $sp-1 $sp-4;
border-radius: $r-control;
background: $stage-ink;
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: $stage-paper;
cursor: pointer;
transition: transform $dur-quick $easing;
}
.cart__btn:active { transform: scale(.94); }
// ── 날아가는 점: 겹 요소 2개로 포물선 (x 는 등속, y 는 느렸다 빨라짐)
.cart__fly {
position: absolute; left: 16px; top: 64px; // 상품 스와치 위치
opacity: 0;
transition: opacity $dur-quick linear;
pointer-events: none;
z-index: 2;
}
.cart__fly-y {
display: block;
width: 18px; height: 18px;
border-radius: 50%;
background: $color;
box-shadow: $shadow-press;
}
#cart-add:checked ~ .cart__fly { transform: translateX(273px); opacity: 0; transition: transform $duration $easing, opacity $dur-quick linear $duration; }
#cart-add:checked ~ .cart__fly .cart__fly-y { transform: translateY(-71px); transition: transform $duration $easing; }
// 렌더·갤러리용 자동 루프: 대기 → 비행 → 배지 바운스 → 리셋
.cart.is-demo .cart__fly { animation: fly-x $dur-loop linear infinite; opacity: 1; }
.cart.is-demo .cart__fly-y { animation: fly-y $dur-loop $easing infinite; }
.cart.is-demo .cart__badge { animation: badge-loop $dur-loop $easing infinite; }
@keyframes fly-x {
0%, 30% { transform: translateX(0); opacity: 1; }
68%, 100% { transform: translateX(273px); opacity: 0; }
}
@keyframes fly-y {
0%, 30% { transform: translateY(0); }
68%, 100% { transform: translateY(-71px); }
}
@keyframes badge-loop {
0%, 66% { transform: scale(1); }
74% { transform: scale(1.35); }
84%, 100% { transform: scale(1); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응 — 카드를 위로 붙인다
.cart__product { margin-top: 8px; padding: 8px; width: 148px; gap: 4px; }
.cart__swatch { width: 36px; height: 36px; }
.cart__name { font-size: 12px; }
.cart__btn { padding: 4px 12px; font-size: 11px; }
.cart__fly { top: 40px; left: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cart__badge, .cart__btn { transition: none; }
.cart.is-demo .cart__fly,
.cart.is-demo .cart__fly-y,
.cart.is-demo .cart__badge { animation: none; } // 점이 순간 이동, 배지는 고정
.cart.is-demo .cart__fly { opacity: 0; }
#cart-add:checked ~ .cart__fly { transform: translateX(273px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cart {
position: relative;
width: min(300px, 90vw);
}
.cart__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.cart__bag {
position: absolute;
top: 0;
right: 0;
}
.cart__basket {
position: relative;
display: block;
width: 44px;
height: 36px;
box-sizing: border-box;
border: 3px solid #17141a;
border-radius: 4px 4px 8px 8px;
}
.cart__basket::before {
content: "";
position: absolute;
top: -8px;
left: 8px;
width: 20px;
height: 12px;
border: 3px solid #17141a;
border-bottom: 0;
border-radius: 999px 999px 0 0;
}
.cart__badge {
position: absolute;
top: -8px;
right: -8px;
display: grid;
place-items: center;
min-width: 20px;
height: 20px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 999px;
background: #17141a;
font-weight: 800;
font-size: 11px;
font-family: inherit;
color: #ffffff;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
#cart-add:checked ~ .cart__bag .cart__badge {
transform: scale(1.35);
}
.cart__product {
display: grid;
gap: 8px;
justify-items: start;
margin-top: 48px;
padding: 16px;
width: 180px;
box-sizing: border-box;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cart__swatch {
width: 56px;
height: 56px;
border-radius: 50%;
background: #ff4d1f;
}
.cart__name {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.8vw, 15px);
font-family: inherit;
color: #17141a;
}
.cart__btn {
padding: 4px 16px;
border-radius: 12px;
background: #17141a;
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: #ffffff;
cursor: pointer;
transition: transform 220ms ease-out;
}
.cart__btn:active {
transform: scale(0.94);
}
.cart__fly {
position: absolute;
left: 16px;
top: 64px;
opacity: 0;
transition: opacity 220ms linear;
pointer-events: none;
z-index: 2;
}
.cart__fly-y {
display: block;
width: 18px;
height: 18px;
border-radius: 50%;
background: #ff4d1f;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
#cart-add:checked ~ .cart__fly {
transform: translateX(273px);
opacity: 0;
transition: transform 700ms ease-out, opacity 220ms linear 700ms;
}
#cart-add:checked ~ .cart__fly .cart__fly-y {
transform: translateY(-71px);
transition: transform 700ms ease-out;
}
.cart.is-demo .cart__fly {
animation: fly-x 2s linear infinite;
opacity: 1;
}
.cart.is-demo .cart__fly-y {
animation: fly-y 2s ease-out infinite;
}
.cart.is-demo .cart__badge {
animation: badge-loop 2s ease-out infinite;
}
@keyframes fly-x {
0%, 30% {
transform: translateX(0);
opacity: 1;
}
68%, 100% {
transform: translateX(273px);
opacity: 0;
}
}
@keyframes fly-y {
0%, 30% {
transform: translateY(0);
}
68%, 100% {
transform: translateY(-71px);
}
}
@keyframes badge-loop {
0%, 66% {
transform: scale(1);
}
74% {
transform: scale(1.35);
}
84%, 100% {
transform: scale(1);
}
}
@media (max-width: 360px) {
.cart__product {
margin-top: 8px;
padding: 8px;
width: 148px;
gap: 4px;
}
.cart__swatch {
width: 36px;
height: 36px;
}
.cart__name {
font-size: 12px;
}
.cart__btn {
padding: 4px 12px;
font-size: 11px;
}
.cart__fly {
top: 40px;
left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cart__badge, .cart__btn {
transition: none;
}
.cart.is-demo .cart__fly,
.cart.is-demo .cart__fly-y,
.cart.is-demo .cart__badge {
animation: none;
}
.cart.is-demo .cart__fly {
opacity: 0;
}
#cart-add:checked ~ .cart__fly {
transform: translateX(273px);
}
}import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
import styles from "./FlyToCart.module.scss";
export type FlyToCartProps = {
name: string;
addLabel: string;
/** 점이 카트까지 날아가는 시간 (기본 700ms) */
duration?: number;
easing?: string;
/** 상품·점 색 */
color?: string;
/** 카트에 이미 담겨 있는 개수 */
count?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FlyToCart({
name,
addLabel,
duration = 700,
easing = "ease-out",
color = "#ff4d1f",
count = 2,
}: FlyToCartProps) {
const [flying, setFlying] = useState(false);
const [added, setAdded] = useState(count);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (!flying) return;
const t = setTimeout(() => { setAdded((n) => n + 1); setFlying(false); }, duration);
return () => clearTimeout(t);
}, [flying, duration]);
return (
<section className={styles.cart} style={vars} aria-label={name}>
<div className={styles.bag} aria-hidden="true">
<span className={styles.basket} />
<span className={`${styles.badge} ${flying ? styles.badgeBump : ""}`}>{added}</span>
</div>
<article className={styles.product}>
<span className={styles.swatch} aria-hidden="true" />
<h2 className={styles.name}>{name}</h2>
<button type="button" className={styles.btn} onClick={() => setFlying(true)}>{addLabel}</button>
</article>
<span className={`${styles.fly} ${flying ? styles.flyGo : ""}`} aria-hidden="true">
<span className={styles.flyY} />
</span>
</section>
);
}06 · カートへ飛ぶ追加 — 計測 累積面積 2.603% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
出発点と到着点が同じ画面にあるので、自分の操作がどこへ届いたかを目で追えます。
.cart__fly { transition: transform $duration $easing; } // 外側: x は等速
.cart__fly-y { transition: transform $duration $easing; } // 内側: y はイージング
// 着地座標は getBoundingClientRect の実測値
#cart-add:checked ~ .cart__fly { transform: translateX(273px); opacity: 0; }
#cart-add:checked ~ .cart__fly .cart__fly-y { transform: translateY(-71px); }
#cart-add:checked ~ .cart__bag .cart__badge { transform: scale(1.35); }
07チケット破り確認
何が見えるか
確認を押した瞬間、チケットの中央の点線が光り、左右の半分が逆方向へ傾いて開き、破れた2枚として画面から抜けます。

<main class="stage">
<section class="tear is-demo" aria-label="確認の瞬間チケットが左右に破れて2枚になる">
<input class="tear__check" type="checkbox" id="tear-ok">
<div class="tear__ticket">
<div class="tear__half tear__half--l">
<p class="tear__title">入場券</p>
<p class="tear__seat">A列 12番</p>
</div>
<div class="tear__half tear__half--r">
<span class="tear__barcode" aria-hidden="true"></span>
<label class="tear__btn" for="tear-ok">確認</label>
</div>
<span class="tear__perf" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 720ms; // 찢겨 빠지는 시간
$easing: $ease-spring;
$color: $subject-blue; // 티켓 포인트색
@include stage($stage-orange);
.tear { display: grid; place-items: center; }
.tear__check { position: absolute; opacity: 0; pointer-events: none; }
.tear__ticket {
position: relative;
display: flex;
width: min(320px, 90vw);
height: 128px;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.tear__half {
box-sizing: border-box;
display: grid;
align-content: center;
gap: $sp-2;
padding: $sp-4;
background: $stage-paper;
transition: transform $duration $easing, opacity $duration $easing;
z-index: 1;
}
.tear__half--l { flex: 1 1 auto; border-right: 0; }
.tear__half--r {
flex: 0 0 112px;
justify-items: center;
background: linear-gradient(180deg, $stage-paper 0%, rgba($color, .12) 100%);
}
.tear__title {
margin: 0;
font-weight: 800;
font-size: clamp(16px, 4.6vw, 20px);
font-family: inherit;
color: $stage-ink;
}
.tear__seat {
margin: 0;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: rgba(23, 20, 26, .6);
}
.tear__barcode {
width: 56px; height: 44px;
border-radius: $r-xs;
background: repeating-linear-gradient(90deg,
$stage-ink 0 4px, transparent 4px 8px, $stage-ink 8px 10px, transparent 10px 16px);
}
.tear__btn {
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: $stage-ink;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: $stage-paper;
cursor: pointer;
transition: transform $dur-quick $easing;
}
.tear__btn:active { transform: scale(.94); }
// 가운데 점선 미신흔 — 찢기는 순간 밝아진다
.tear__perf {
position: absolute; top: 0; bottom: 0; left: calc(100% - 112px);
width: 0;
border-left: 2px dashed rgba(23, 20, 26, .3);
z-index: 2;
transition: border-color $dur-quick $easing;
}
// 확인: 왼쪽·오른쪽 반대 방향으로 기울며 벌어져 빠진다
#tear-ok:checked ~ .tear__ticket .tear__perf { border-color: rgba(23, 20, 26, .9); }
#tear-ok:checked ~ .tear__ticket .tear__half--l {
transform: translate(-52px, 26px) rotate(-9deg);
opacity: 0;
}
#tear-ok:checked ~ .tear__ticket .tear__half--r {
transform: translate(44px, 30px) rotate(8deg);
opacity: 0;
}
// 렌더·갤러리용 자동 루프: 온전 → 미싯값 밝아짐 → 찢김 퇴장 → 재등장
.tear.is-demo .tear__half--l { animation: half-l $dur-loop $easing infinite; }
.tear.is-demo .tear__half--r { animation: half-r $dur-loop $easing infinite; }
.tear.is-demo .tear__perf { animation: perf $dur-loop $easing infinite; }
@keyframes half-l {
0%, 24% { transform: translate(0, 0) rotate(0); opacity: 1; }
56%, 84% { transform: translate(-52px, 26px) rotate(-9deg); opacity: 0; }
92%, 100% { transform: translate(0, 0) rotate(0); opacity: 1; }
}
@keyframes half-r {
0%, 24% { transform: translate(0, 0) rotate(0); opacity: 1; }
56%, 84% { transform: translate(44px, 30px) rotate(8deg); opacity: 0; }
92%, 100% { transform: translate(0, 0) rotate(0); opacity: 1; }
}
@keyframes perf {
0%, 24% { border-color: rgba(23, 20, 26, .3); }
32%, 52% { border-color: rgba(23, 20, 26, .9); }
60%, 100% { border-color: rgba(23, 20, 26, .3); }
}
@media (prefers-reduced-motion: reduce) {
.tear__half, .tear__perf, .tear__btn { transition: none; }
.tear.is-demo .tear__half,
.tear.is-demo .tear__perf { animation: none; } // 찢김 없이 즉시 사라진다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tear {
display: grid;
place-items: center;
}
.tear__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.tear__ticket {
position: relative;
display: flex;
width: min(320px, 90vw);
height: 128px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.tear__half {
box-sizing: border-box;
display: grid;
align-content: center;
gap: 8px;
padding: 16px;
background: #ffffff;
transition: transform 720ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 720ms cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 1;
}
.tear__half--l {
flex: 1 1 auto;
border-right: 0;
}
.tear__half--r {
flex: 0 0 112px;
justify-items: center;
background: linear-gradient(180deg, #ffffff 0%, rgba(47, 109, 246, 0.12) 100%);
}
.tear__title {
margin: 0;
font-weight: 800;
font-size: clamp(16px, 4.6vw, 20px);
font-family: inherit;
color: #17141a;
}
.tear__seat {
margin: 0;
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.tear__barcode {
width: 56px;
height: 44px;
border-radius: 4px;
background: repeating-linear-gradient(90deg, #17141a 0 4px, transparent 4px 8px, #17141a 8px 10px, transparent 10px 16px);
}
.tear__btn {
padding: 4px 12px;
border-radius: 12px;
background: #17141a;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: #ffffff;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tear__btn:active {
transform: scale(0.94);
}
.tear__perf {
position: absolute;
top: 0;
bottom: 0;
left: calc(100% - 112px);
width: 0;
border-left: 2px dashed rgba(23, 20, 26, 0.3);
z-index: 2;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#tear-ok:checked ~ .tear__ticket .tear__perf {
border-color: rgba(23, 20, 26, 0.9);
}
#tear-ok:checked ~ .tear__ticket .tear__half--l {
transform: translate(-52px, 26px) rotate(-9deg);
opacity: 0;
}
#tear-ok:checked ~ .tear__ticket .tear__half--r {
transform: translate(44px, 30px) rotate(8deg);
opacity: 0;
}
.tear.is-demo .tear__half--l {
animation: half-l 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tear.is-demo .tear__half--r {
animation: half-r 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tear.is-demo .tear__perf {
animation: perf 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes half-l {
0%, 24% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
56%, 84% {
transform: translate(-52px, 26px) rotate(-9deg);
opacity: 0;
}
92%, 100% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
}
@keyframes half-r {
0%, 24% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
56%, 84% {
transform: translate(44px, 30px) rotate(8deg);
opacity: 0;
}
92%, 100% {
transform: translate(0, 0) rotate(0);
opacity: 1;
}
}
@keyframes perf {
0%, 24% {
border-color: rgba(23, 20, 26, 0.3);
}
32%, 52% {
border-color: rgba(23, 20, 26, 0.9);
}
60%, 100% {
border-color: rgba(23, 20, 26, 0.3);
}
}
@media (prefers-reduced-motion: reduce) {
.tear__half, .tear__perf, .tear__btn {
transition: none;
}
.tear.is-demo .tear__half,
.tear.is-demo .tear__perf {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./TicketTearConfirm.module.scss";
export type TicketTearConfirmProps = {
title: string;
meta: string;
confirmLabel: string;
/** 찢겨 빠지는 시간 (기본 720ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TicketTearConfirm({
title,
meta,
confirmLabel,
duration = 720,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TicketTearConfirmProps) {
const [torn, setTorn] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.tear} style={vars} aria-label={title}>
<div className={styles.ticket}>
<div className={`${styles.half} ${styles.halfL} ${torn ? styles.halfLGo : ""}`}>
<p className={styles.title}>{title}</p>
<p className={styles.meta}>{meta}</p>
</div>
<div className={`${styles.half} ${styles.halfR} ${torn ? styles.halfRGo : ""}`}>
<span className={styles.barcode} aria-hidden="true" />
<button type="button" className={styles.btn} onClick={() => setTorn(true)}>{confirmLabel}</button>
</div>
<span className={`${styles.perf} ${torn ? styles.perfOn : ""}`} aria-hidden="true" />
</div>
</section>
);
}07 · チケット破り確認 — 計測 累積面積 8.812% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
注文確定やチェックインのような後戻りできない瞬間を、文字の変化より確実に刻みます。
.tear__half { transition: transform $duration $easing, opacity $duration $easing; }
#tear-ok:checked ~ .tear__ticket .tear__half--l {
transform: translate(-52px, 26px) rotate(-9deg); // 左半分: 左下へ
opacity: 0;
}
#tear-ok:checked ~ .tear__ticket .tear__half--r {
transform: translate(44px, 30px) rotate(8deg); // 右半分: 右下へ
opacity: 0;
}
08ステップ方向スライド
画面で起きること
次の段階へ進むと現在のパネルが左へ抜け、新しいパネルが右から入り、戻るときは方向がちょうど逆になります。

<main class="stage">
<section class="wiz is-demo" aria-label="ステップパネルが進行方向に合わせてスライドする">
<input class="wiz__radio" type="radio" name="wiz-218" id="wiz-1" checked>
<input class="wiz__radio" type="radio" name="wiz-218" id="wiz-2">
<div class="wiz__window">
<div class="wiz__panel wiz__panel--1">
<p class="wiz__step">ステップ1・アカウント</p>
<p class="wiz__q">名前とメールアドレスを入力</p>
</div>
<div class="wiz__panel wiz__panel--2">
<p class="wiz__step">ステップ2・配送先</p>
<p class="wiz__q">お届け先の住所を入力</p>
</div>
</div>
<div class="wiz__foot">
<div class="wiz__dots" aria-hidden="true"><span class="wiz__dot"></span><span class="wiz__dot"></span></div>
<label class="wiz__nav" for="wiz-1">前へ</label>
<label class="wiz__nav wiz__nav--next" for="wiz-2">次へ</label>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널이 한 단계 넘어가는 시간
$easing: $ease-spring;
$color: $subject-blue; // 포인트색
@include stage($stage-paper);
.wiz { width: min(300px, 90vw); }
.wiz__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 패널 창: 두 단계가 나란히 붙어 있고, 창이 이를 보여준다
.wiz__window {
position: relative;
height: clamp(96px, 48vh, 148px);
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-press;
overflow: hidden;
}
.wiz__panel {
position: absolute; top: 0; bottom: 0; left: 0;
display: grid;
align-content: center;
gap: $sp-2;
width: 100%;
box-sizing: border-box;
padding: $sp-4;
transition: transform $duration $easing, opacity $duration $easing;
}
.wiz__panel--1 { transform: translateX(0); }
.wiz__panel--2 { transform: translateX(100%); opacity: 0; }
.wiz__step {
margin: 0;
padding: $sp-1 $sp-2;
width: fit-content;
border-radius: $r-pill;
background: rgba($color, .14); // 칩 면(#e2eafe)은 그대로
font-weight: 700;
font-size: 11px;
font-family: inherit;
color: $subject-blue-deep; // 그 면 위 $subject-blue 글자는 3.76:1 — 짙은 파랑으로 5.29:1
}
.wiz__q {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 18px);
line-height: 1.35;
font-family: inherit;
color: $stage-ink;
}
// 라디오: 앞으로 갈 때 1 은 왼쪽으로, 2 는 오른쪽에서 들어온다
#wiz-1:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(0); opacity: 1; }
#wiz-1:checked ~ .wiz__window .wiz__panel--2 { transform: translateX(100%); opacity: 0; }
#wiz-2:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(-100%); opacity: 0; }
#wiz-2:checked ~ .wiz__window .wiz__panel--2 { transform: translateX(0); opacity: 1; }
// ── 발판: 진행 점 + 이전/다음 (라디오 라벨)
.wiz__foot {
display: flex;
align-items: center;
gap: $sp-3;
margin-top: $sp-3;
}
.wiz__dots { display: flex; gap: $sp-2; padding: 0 $sp-1; }
.wiz__dot {
width: 8px; height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, .18);
transition: transform $duration $easing, background-color $duration $easing;
}
#wiz-1:checked ~ .wiz__foot .wiz__dot:nth-child(1),
#wiz-2:checked ~ .wiz__foot .wiz__dot:nth-child(2) { background: $color; transform: scale(1.5); }
.wiz__nav {
padding: $sp-1 $sp-4;
border-radius: $r-control;
border: 2px solid rgba(23, 20, 26, .2);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: $stage-ink;
cursor: pointer;
transition: transform $dur-quick $easing, border-color $dur-quick $easing;
}
.wiz__nav--next { margin-left: auto; background: $stage-ink; border-color: $stage-ink; color: $stage-paper; }
.wiz__nav:active { transform: scale(.94); }
// 렌더·갤러리용 자동 루프: 앞으로(1→2) 갔다 뒤로(2→1) 돌아온다 — 방향이 반대로 보인다
.wiz.is-demo .wiz__panel--1 { animation: p1-loop $dur-loop $easing infinite; }
.wiz.is-demo .wiz__panel--2 { animation: p2-loop $dur-loop $easing infinite; }
.wiz.is-demo .wiz__dot:nth-child(1) { animation: d1-loop $dur-loop $easing infinite; }
.wiz.is-demo .wiz__dot:nth-child(2) { animation: d2-loop $dur-loop $easing infinite; }
@keyframes p1-loop {
0%, 10% { transform: translateX(0); opacity: 1; }
40%, 60% { transform: translateX(-100%); opacity: 0; }
90%, 100% { transform: translateX(0); opacity: 1; }
}
@keyframes p2-loop {
0%, 10% { transform: translateX(100%); opacity: 0; }
40%, 60% { transform: translateX(0); opacity: 1; }
90%, 100% { transform: translateX(100%); opacity: 0; }
}
@keyframes d1-loop {
0%, 10% { transform: scale(1.5); background: $color; }
40%, 60% { transform: scale(1); background: rgba(23, 20, 26, .18); }
90%, 100% { transform: scale(1.5); background: $color; }
}
@keyframes d2-loop {
0%, 10% { transform: scale(1); background: rgba(23, 20, 26, .18); }
40%, 60% { transform: scale(1.5); background: $color; }
90%, 100% { transform: scale(1); background: rgba(23, 20, 26, .18); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.wiz__window { height: 96px; }
.wiz__panel { padding: 8px; gap: 4px; }
.wiz__q { font-size: 13px; }
.wiz__foot { margin-top: 8px; gap: 8px; }
.wiz__nav { padding: 4px 12px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.wiz__panel, .wiz__dot, .wiz__nav { transition: none; }
.wiz.is-demo .wiz__panel,
.wiz.is-demo .wiz__dot { animation: none; } // 패널이 즉시 바뀐다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wiz {
width: min(300px, 90vw);
}
.wiz__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.wiz__window {
position: relative;
height: clamp(96px, 48vh, 148px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.wiz__panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: grid;
align-content: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
padding: 16px;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wiz__panel--1 {
transform: translateX(0);
}
.wiz__panel--2 {
transform: translateX(100%);
opacity: 0;
}
.wiz__step {
margin: 0;
padding: 4px 8px;
width: fit-content;
border-radius: 999px;
background: rgba(47, 109, 246, 0.14);
font-weight: 700;
font-size: 11px;
font-family: inherit;
color: #0a4fe8;
}
.wiz__q {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 18px);
line-height: 1.35;
font-family: inherit;
color: #17141a;
}
#wiz-1:checked ~ .wiz__window .wiz__panel--1 {
transform: translateX(0);
opacity: 1;
}
#wiz-1:checked ~ .wiz__window .wiz__panel--2 {
transform: translateX(100%);
opacity: 0;
}
#wiz-2:checked ~ .wiz__window .wiz__panel--1 {
transform: translateX(-100%);
opacity: 0;
}
#wiz-2:checked ~ .wiz__window .wiz__panel--2 {
transform: translateX(0);
opacity: 1;
}
.wiz__foot {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.wiz__dots {
display: flex;
gap: 8px;
padding: 0 4px;
}
.wiz__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.18);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#wiz-1:checked ~ .wiz__foot .wiz__dot:nth-child(1),
#wiz-2:checked ~ .wiz__foot .wiz__dot:nth-child(2) {
background: #2f6df6;
transform: scale(1.5);
}
.wiz__nav {
padding: 4px 16px;
border-radius: 12px;
border: 2px solid rgba(23, 20, 26, 0.2);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: #17141a;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wiz__nav--next {
margin-left: auto;
background: #17141a;
border-color: #17141a;
color: #ffffff;
}
.wiz__nav:active {
transform: scale(0.94);
}
.wiz.is-demo .wiz__panel--1 {
animation: p1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wiz.is-demo .wiz__panel--2 {
animation: p2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wiz.is-demo .wiz__dot:nth-child(1) {
animation: d1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wiz.is-demo .wiz__dot:nth-child(2) {
animation: d2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes p1-loop {
0%, 10% {
transform: translateX(0);
opacity: 1;
}
40%, 60% {
transform: translateX(-100%);
opacity: 0;
}
90%, 100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes p2-loop {
0%, 10% {
transform: translateX(100%);
opacity: 0;
}
40%, 60% {
transform: translateX(0);
opacity: 1;
}
90%, 100% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes d1-loop {
0%, 10% {
transform: scale(1.5);
background: #2f6df6;
}
40%, 60% {
transform: scale(1);
background: rgba(23, 20, 26, 0.18);
}
90%, 100% {
transform: scale(1.5);
background: #2f6df6;
}
}
@keyframes d2-loop {
0%, 10% {
transform: scale(1);
background: rgba(23, 20, 26, 0.18);
}
40%, 60% {
transform: scale(1.5);
background: #2f6df6;
}
90%, 100% {
transform: scale(1);
background: rgba(23, 20, 26, 0.18);
}
}
@media (max-width: 360px) {
.wiz__window {
height: 96px;
}
.wiz__panel {
padding: 8px;
gap: 4px;
}
.wiz__q {
font-size: 13px;
}
.wiz__foot {
margin-top: 8px;
gap: 8px;
}
.wiz__nav {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.wiz__panel, .wiz__dot, .wiz__nav {
transition: none;
}
.wiz.is-demo .wiz__panel,
.wiz.is-demo .wiz__dot {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./WizardStepSlide.module.scss";
export type WizardStepSlideProps = {
steps: { badge: string; question: string }[];
backLabel: string;
nextLabel: string;
/** 패널이 한 단계 넘어가는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WizardStepSlide({
steps,
backLabel,
nextLabel,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: WizardStepSlideProps) {
const [step, setStep] = useState(0);
const [dir, setDir] = useState<"fwd" | "back">("fwd");
const go = (d: number) => {
const next = Math.min(Math.max(step + d, 0), steps.length - 1);
if (next === step) return;
setDir(d > 0 ? "fwd" : "back");
setStep(next);
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.wiz} style={vars} aria-label="다단계 폼">
<div className={styles.window}>
{steps.map((s, i) => (
<div
key={s.badge}
className={`${styles.panel} ${i === step ? styles.panelOn : ""}`}
data-side={i < step ? "left" : i > step ? "right" : dir}
>
<p className={styles.step}>{s.badge}</p>
<p className={styles.q}>{s.question}</p>
</div>
))}
</div>
<div className={styles.foot}>
<div className={styles.dots} aria-hidden="true">
{steps.map((s, i) => (
<span key={s.badge} className={`${styles.dot} ${i === step ? styles.dotOn : ""}`} />
))}
</div>
<button type="button" className={styles.nav} onClick={() => go(-1)}>{backLabel}</button>
<button type="button" className={`${styles.nav} ${styles.navNext}`} onClick={() => go(1)}>{nextLabel}</button>
</div>
</section>
);
}08 · ステップ方向スライド — 計測 累積面積 7.495% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
矢印を読まなくても流れの方向が目でわかるので、何段階あるフォームでも迷わなくなります。
.wiz__panel { transition: transform $duration $easing, opacity $duration $easing; }
.wiz__panel--2 { transform: translateX(100%); opacity: 0; } // ステップ2の居場所: 右
// 戻るとステップ1は左の居場所へ、ステップ2は右へ戻る — 方向が逆になる
#wiz-2:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(-100%); opacity: 0; }
#wiz-2:checked ~ .wiz__window .wiz__panel--2 { transform: translateX(0); opacity: 1; }
#wiz-1:checked ~ .wiz__window .wiz__panel--1 { transform: translateX(0); opacity: 1; }
09斜めシーン交代
どんな動きか
画面を斜めに分ける2枚の板が同じ方向へタイミングをずらしながら滑って通り過ぎ、後ろに隠れていた次の場面を一度に現します。

<main class="stage">
<section class="scene is-demo" aria-label="斜めの2枚の板が通り過ぎてシーンが変わる">
<input class="scene__check" type="checkbox" id="scene-swap">
<div class="scene__frame">
<div class="scene__inner scene__inner--a"><p class="scene__name">春コレクション</p></div>
<div class="scene__inner scene__inner--b"><p class="scene__name">夏コレクション</p></div>
<div class="scene__wipes" aria-hidden="true">
<span class="scene__wipe scene__wipe--1"></span>
<span class="scene__wipe scene__wipe--2"></span>
</div>
</div>
<label class="scene__btn" for="scene-swap">シーンを変える</label>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1s; // 판이 프레임을 통과하는 시간
$easing: $ease-spring;
$color: $subject-blue; // A 장면 색
@include stage($stage-ink);
.scene {
display: grid;
justify-items: center;
gap: $sp-3;
}
.scene__check { position: absolute; opacity: 0; pointer-events: none; }
.scene__frame {
position: relative;
width: min(320px, 90vw);
height: clamp(96px, 50vh, 172px);
border-radius: $r-card;
overflow: hidden;
}
.scene__inner {
position: absolute; inset: 0;
display: grid;
place-items: center;
transition: opacity $dur-quick linear calc(#{$duration} / 2); // 판이 덮는 중간에 교체
}
.scene__inner--a { background: linear-gradient(140deg, $color 0%, $subject-cream 100%); opacity: 1; }
.scene__inner--b { background: linear-gradient(140deg, $stage-orange 0%, $stage-yellow 100%); opacity: 0; }
.scene__name {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.2vw, 24px);
font-family: inherit;
color: $stage-ink;
}
// ── 대각선 판 2개: skew 로 비스듬히 눕히고 좌우로 통과시킨다 (clip-path 아님)
.scene__wipes { position: absolute; inset: 0; }
.scene__wipe {
position: absolute;
top: -20%; bottom: -20%; left: -45%;
width: 190%;
background: $stage-paper;
transform: skewX(-14deg) translateX(-115%);
}
.scene__wipe--2 { background: rgba(255, 210, 63, .92); }
// 버튼: 판이 프레임을 쓸고 지나가며 장면이 바뀐다
.scene__btn {
padding: $sp-2 $sp-5;
border-radius: $r-control;
border: 2px solid rgba(255, 255, 255, .4);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: $stage-paper;
cursor: pointer;
transition: transform $dur-quick $easing, border-color $dur-quick $easing;
}
.scene__btn:active { transform: scale(.94); }
// 체크: 판이 왼→오른쪽으로 통과 (transition 이라 해제하면 역방향 통과가 된다)
.scene__wipe { transition: transform $duration $easing; }
.scene__wipe--2 { transition-delay: 100ms; }
#scene-swap:checked ~ .scene__frame .scene__wipe { transform: skewX(-14deg) translateX(115%); }
#scene-swap:checked ~ .scene__frame .scene__inner--a { opacity: 0; }
#scene-swap:checked ~ .scene__frame .scene__inner--b { opacity: 1; }
// 렌더·갤러리용 자동 루프: A → 판 통과 → B → 판 역통과 → A
.scene.is-demo .scene__wipe--1 { animation: wipe-loop-1 $dur-loop $easing infinite; }
.scene.is-demo .scene__wipe--2 { animation: wipe-loop-2 $dur-loop $easing infinite; }
.scene.is-demo .scene__inner--a { animation: inner-a $dur-loop $easing infinite; }
.scene.is-demo .scene__inner--b { animation: inner-b $dur-loop $easing infinite; }
@keyframes wipe-loop-1 {
0%, 8% { transform: skewX(-14deg) translateX(-115%); } // 순방향 통과
34% { transform: skewX(-14deg) translateX(115%); }
52%, 58% { transform: skewX(-14deg) translateX(115%); } // 역방향 통과
84%, 100% { transform: skewX(-14deg) translateX(-115%); }
}
@keyframes wipe-loop-2 {
0%, 14% { transform: skewX(-14deg) translateX(-115%); }
40%, 60% { transform: skewX(-14deg) translateX(115%); }
86%, 100% { transform: skewX(-14deg) translateX(-115%); }
}
@keyframes inner-a {
0%, 20% { opacity: 1; }
26%, 70% { opacity: 0; }
76%, 100% { opacity: 1; }
}
@keyframes inner-b {
0%, 20% { opacity: 0; }
26%, 70% { opacity: 1; }
76%, 100% { opacity: 0; }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.scene__frame { height: 96px; }
.scene__name { font-size: 15px; }
.scene__btn { padding: 4px 16px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.scene__inner, .scene__btn { transition: none; }
.scene.is-demo .scene__wipe,
.scene.is-demo .scene__inner { animation: none; } // 판 없이 장면만 즉시 바뀐다
.scene.is-demo .scene__wipe { transform: skewX(-14deg) translateX(-115%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.scene {
display: grid;
justify-items: center;
gap: 12px;
}
.scene__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.scene__frame {
position: relative;
width: min(320px, 90vw);
height: clamp(96px, 50vh, 172px);
border-radius: 16px;
overflow: hidden;
}
.scene__inner {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transition: opacity 220ms linear calc(1s / 2);
}
.scene__inner--a {
background: linear-gradient(140deg, #2f6df6 0%, #fff7e6 100%);
opacity: 1;
}
.scene__inner--b {
background: linear-gradient(140deg, #ff4d1f 0%, #ffd23f 100%);
opacity: 0;
}
.scene__name {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.2vw, 24px);
font-family: inherit;
color: #17141a;
}
.scene__wipes {
position: absolute;
inset: 0;
}
.scene__wipe {
position: absolute;
top: -20%;
bottom: -20%;
left: -45%;
width: 190%;
background: #ffffff;
transform: skewX(-14deg) translateX(-115%);
}
.scene__wipe--2 {
background: rgba(255, 210, 63, 0.92);
}
.scene__btn {
padding: 8px 20px;
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.4);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: #ffffff;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.scene__btn:active {
transform: scale(0.94);
}
.scene__wipe {
transition: transform 1s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.scene__wipe--2 {
transition-delay: 100ms;
}
#scene-swap:checked ~ .scene__frame .scene__wipe {
transform: skewX(-14deg) translateX(115%);
}
#scene-swap:checked ~ .scene__frame .scene__inner--a {
opacity: 0;
}
#scene-swap:checked ~ .scene__frame .scene__inner--b {
opacity: 1;
}
.scene.is-demo .scene__wipe--1 {
animation: wipe-loop-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__wipe--2 {
animation: wipe-loop-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__inner--a {
animation: inner-a 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__inner--b {
animation: inner-b 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wipe-loop-1 {
0%, 8% {
transform: skewX(-14deg) translateX(-115%);
}
34% {
transform: skewX(-14deg) translateX(115%);
}
52%, 58% {
transform: skewX(-14deg) translateX(115%);
}
84%, 100% {
transform: skewX(-14deg) translateX(-115%);
}
}
@keyframes wipe-loop-2 {
0%, 14% {
transform: skewX(-14deg) translateX(-115%);
}
40%, 60% {
transform: skewX(-14deg) translateX(115%);
}
86%, 100% {
transform: skewX(-14deg) translateX(-115%);
}
}
@keyframes inner-a {
0%, 20% {
opacity: 1;
}
26%, 70% {
opacity: 0;
}
76%, 100% {
opacity: 1;
}
}
@keyframes inner-b {
0%, 20% {
opacity: 0;
}
26%, 70% {
opacity: 1;
}
76%, 100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.scene__frame {
height: 96px;
}
.scene__name {
font-size: 15px;
}
.scene__btn {
padding: 4px 16px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.scene__inner, .scene__btn {
transition: none;
}
.scene.is-demo .scene__wipe,
.scene.is-demo .scene__inner {
animation: none;
}
.scene.is-demo .scene__wipe {
transform: skewX(-14deg) translateX(-115%);
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./SplitDiagonalSwap.module.scss";
export type SplitDiagonalSwapProps = {
sceneA: string;
sceneB: string;
swapLabel: string;
/** 판이 프레임을 통과하는 시간 (기본 1000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SplitDiagonalSwap({
sceneA,
sceneB,
swapLabel,
duration = 1000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SplitDiagonalSwapProps) {
const [atB, setAtB] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.scene} style={vars} aria-label={`${sceneA} / ${sceneB}`}>
<div className={styles.frame}>
<div className={`${styles.inner} ${styles.innerA} ${atB ? styles.innerHide : ""}`}>
<p className={styles.name}>{sceneA}</p>
</div>
<div className={`${styles.inner} ${styles.innerB} ${atB ? styles.innerShow : ""}`}>
<p className={styles.name}>{sceneB}</p>
</div>
<div className={styles.wipes} aria-hidden="true">
<span className={`${styles.wipe} ${styles.wipe1} ${atB ? styles.wipeGo : ""}`} />
<span className={`${styles.wipe} ${styles.wipe2} ${atB ? styles.wipeGo : ""}`} />
</div>
</div>
<button type="button" className={styles.btn} onClick={() => setAtB((v) => !v)}>{swapLabel}</button>
</section>
);
}09 · 斜めシーン交代 — 計測 累積面積 33.21% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
ランディングページの季節や企画の場面を、読み込み直しではなくこの動作ひとつで劇的に切り替えられます。
.scene__wipe {
top: -20%; bottom: -20%; left: -45%;
width: 190%;
transform: skewX(-14deg) translateX(-115%); // 斜めに寝かせた板
transition: transform $duration $easing;
}
#scene-swap:checked ~ .scene__frame .scene__wipe { transform: skewX(-14deg) translateX(115%); }
.scene__inner { transition: opacity $dur-quick linear calc(#{$duration} / 2); } // 板が隠す中央で交代
どこで壊れるか — 落とし穴ひとつ
カートへ飛ぶ点の最初の版は、確かに動いているのに着地がどこかずれていました。カードからカートまでの距離を目分量で計算して translateX(247px) を入れていたところ、Playwright で実際の座標を測るとバッジの中心は予想より右にあり、点はバッジに26px届かない空中で消えていました。getBoundingClientRect で出発点と到着点の中心を測り直して translateX(273px) と translateY(-71px) に直したところ、点はバッジのど真ん中に着地するようになりました。同じ検査で、カゴの持ち手を描く疑似要素が親の位置基準を掴めずステージの隅に浮いていたのも見つかり、親に position: relative を1行足して貼り直しました。飛ぶ動きは目分量ではなく座標で測って狙うべきで、場面ではなく、指が下りるたびに変わる側はスクロール アニメーション CSS のみ 9選が受け持ちます。この9ファイルの圧縮パスワードは 2aq7sbjtであり、どれもこの実測を経てから公開しています。
アクセシビリティ
9種すべて prefers-reduced-motion: reduce では動きだけを切り、結果の状態を残します。01はブラインドが開いた状態で見出しがすぐ見え、02は揺れずにまっすぐ吊るされ、03・04・08はカード・シーン・パネルが遷移なしで即座に切り替わり、05はチップがすぐスロットに収まり、06は点が瞬間移動してバッジだけが膨らみ、07は破れる演出なしで即座に消え、09は板が通過せず場面だけが変わります。クリック対象はすべて実際のラジオ・チェックボックスとラベルで結ばれているためキーボードとスクリーンリーダーでも操作でき、飾りの要素は aria-hidden で読み上げ順から外しています。transform による遷移がなめらかな理由は MDN の transform リファレンスに、動きを減らす設定の意味は MDN の prefers-reduced-motionにまとまっています。ページ間の遷移は CSS ページ遷移アニメーション9種に、タブパネルの遷移は タブメニュー遷移まとめに別途まとめています。
FAQ
本当にJavaScriptを1行も書かなくていいのですか?
この9種はすべてCSSだけで動きます。クリックが必要なものは、見えないラジオ・チェックボックスを置いて :checked の状態にCSSが従う形にしてあるので、貼り付けた瞬間からそのまま動きます。React版は状態管理のために useState を使います。切替がボタン1個の中で起きる形はcss ボタン ホバー アニメーション 9選で扱っています。
css トランジション サンプルを自分のサイトに付けるとき、何を変えればよいですか?
動く部分のクラス名と距離の値を、自分の画面の大きさに合わせるだけです。特に05のチップ飛行と06のカート飛行は、チップ・カード・カート印の実際の位置によって translate の数値が変わるので、開発者ツールで要素の座標を測ってから入力するのが確実です。
View Transitions API でも同じ効果は出せますか?
View Transitions API は文書や大きな領域全体が変わる瞬間のためのブラウザ機能で、この記事のような小さな状態変化には合わず、対応状況もブラウザごとにまだ差があります。この9種は対応の差が出ないよう transform と opacity だけを使い、どこでも同じ見た目で動くようにしています。









