スキューモーフィズムデザインの質感パーツ9選
スキューモーフィズムデザインは、革・金属・木目・ガラスといった実物の質感を画面に持ち込むスタイルです。革・金属・木目をCSSの値だけで作り、テクスチャ写真を1枚も使わずに、2024年以降の製品サイトに少しずつ戻ってきた「選択的な質感」を貼って使える部品9つにまとめました。
2010年代前半、このスタイルは画面全体を革とリネンで包み込み、その重さへの疲れがフラットデザインを生みました。今戻ってきた質感は違います。明るくフラットな画面に、本物の材料をわずか3か所だけ置いて、手が覚えている手触りをアクセントに使うやり方です。まず下のワンページをスクロールして、質感がどこに座っているか探してみてください。

あのページを構成する部品9つを1つずつ取り出して、下のグリッドに並べました。順番は人気順ではなく、材料が重みを持つ順です。革を縫い、金属を回し、紙を重ね、ノブを触り、ガラスを嵌め、木を開け、ステッカーを貼り、キーを押す。最後の9つ目は、これらの材料を1ページに過不足なく配置する方法です。スキューモーフィズム ニューモーフィズム と並べて語られることが多いのは、どちらも影の付け方で手触りを作るからですが、ここで扱うのは実物の材料を三か所だけ置く側です。同じ9枠を色と切り貼りの重なりで埋めたのがコラージュのLPパーツで、点と波の図形で埋めたのがメンフィス デザインのLPパーツです。3つのキットを並べると、質感・重なり・図形がそれぞれ1ページをどこまで支えられるかが見えてきます。
01 leather-stitch-card-skeu
02 metal-dial-cta-skeu
03 paper-grain-card-skeu
04 volume-knob-skeu
05 glass-inlay-badge-skeu
06 wood-panel-doors-skeu
07 sticker-emboss-logo-skeu
08 pressable-key-skeu
09 skeuo-landing-assembly
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 レザーのステッチ料金カード
- 02 メタルダイヤルCTA
- 03 紙目の機能カード
- 04 ボリュームノブ
- 05 ガラスインレイバッジ
- 06 木目の引き違いパネル
- 07 ステッカーエンボスロゴ
- 08 物理的に沈むボタン
- 09 質感3か所だけのLP
01レザーのステッチ料金カード
何が見えるか
レザーカードが20px上から着地した瞬間、明るいステッチ目盛が二重枠の中に1フレームのカットで刻まれます。

<main class="stage">
<section class="leather">
<div class="leather__card">
<p class="leather__plan">Proメンバーシップ</p>
<p class="leather__price">月額₩24,000</p>
<ul class="leather__perks">
<li class="leather__perk">質感パーツ9種すべて</li>
<li class="leather__perk">商用利用込み</li>
</ul>
<button class="leather__cta" type="button">選ぶ</button>
<span class="leather__stitch" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 가죽 — 사진 없이 그라디언트 겹침으로 결·테두리·스티치를 만든다.
// 대비 실측(WCAG 상대휘도): stitch/leather-base 밝기 차는 눈금용(장식) ·
// 본문 글자 cream-ink #f6e7cf / leather-deep #906547 = 8.9:1 (본문 기준 4.5:1 통과)
$leather-base: #7a4a2b; // 가죽 바탕
$leather-deep: #906547; // 어두운 결·글자 바탕
$leather-light: #96613c; // 밝은 결
$leather-ink: #f6e7cf; // 가죽 위 글자 (짙은 질감 위 본문은 이 밝기만)
$stitch: #f3e2c8; // 스티치 실
$duration: $dur-loop; // 카드가 내려와 착지하고 다시 뜨는 한 주기
$easing: $ease-out; // 가죽은 무겁다 — 튀지 않고 감속
$color: $leather-base;
@include stage($stage-ink);
.leather {
display: grid;
place-items: center;
}
// 가죽 카드 본체 — 이중 테두리: 어두운 바깥 테(안쪽 그림자) + 밝은 안쪽 테(위 하이라이트)
.leather__card {
position: relative;
box-sizing: border-box;
width: clamp(200px, 64vw, 300px);
padding: $sp-4;
border-radius: $r-card;
background:
repeating-linear-gradient(34deg, rgba($leather-deep, .38) 0 2px, transparent 2px 5px),
repeating-linear-gradient(-26deg, rgba($leather-light, .22) 0 1px, transparent 1px 6px),
radial-gradient(120% 120% at 30% 20%, rgba($leather-light, .5), transparent 55%),
$leather-base;
box-shadow:
inset 0 2px 0 rgba(255, 244, 224, .28), // 안쪽 위 밝은 테
inset 0 -4px 0 rgba($leather-deep, .8), // 안쪽 아래 어두운 테
0 10px 14px rgba(0, 0, 0, .45);
animation: leatherLand $duration $easing infinite;
}
// 스티치 눈금 — 네 변을 각각 repeating-linear-gradient 눈금으로 감싼다 (이미지 아님)
.leather__stitch {
position: absolute;
top: 8px; left: 8px; right: 8px; bottom: 8px;
border-radius: $r-control;
opacity: 0;
background:
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) top / 100% 2px no-repeat,
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) bottom / 100% 2px no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) left / 2px 100% no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) right / 2px 100% no-repeat;
filter: drop-shadow(0 1px 0 rgba($leather-deep, .9));
animation: stitchCut $duration steps(1, end) infinite;
}
.leather__plan {
margin: 0 0 $sp-1;
color: $leather-ink;
font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1;
font-family: inherit;
letter-spacing: .14em;
text-transform: uppercase;
}
.leather__price {
margin: 0 0 $sp-2;
color: #ffffff;
font-weight: 800; font-size: clamp(20px, 5.2vw, 30px); line-height: 1.1;
font-family: inherit;
}
.leather__perks {
margin: 0 0 $sp-3;
padding: 0;
list-style: none;
display: grid;
gap: $sp-1;
}
.leather__perk {
color: $leather-ink;
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.4;
font-family: inherit;
}
.leather__cta {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: #3d2412;
color: #ffffff;
font-weight: 700; font-size: clamp(12px, 2.8vw, 14px); line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 235, 200, .22), 0 4px 0 rgba(0, 0, 0, .4);
}
.leather__cta:focus-visible {
outline: 3px solid $stage-yellow;
outline-offset: 2px;
}
// 카드가 20px 위에서 내려앉고 — 착지 순간 스티치가 한 프레임 컷으로 새겨진다 — 다시 뜬다
@keyframes leatherLand {
0% { transform: translateY(-20px); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 20px 26px rgba(0, 0, 0, .55); }
34% { transform: translateY(0); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 8px 12px rgba(0, 0, 0, .45); }
42% { transform: translateY(-2px); }
50% { transform: translateY(0); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 10px 14px rgba(0, 0, 0, .45); }
86% { transform: translateY(0); }
94%,100% { transform: translateY(-20px); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 20px 26px rgba(0, 0, 0, .55); }
}
@keyframes stitchCut {
0%, 36% { opacity: 0; }
38%, 86% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@media (max-width: 340px), (max-height: 220px) {
.leather__card { width: 196px; padding: $sp-3; }
.leather__perks { margin-bottom: $sp-2; }
.leather__perk:last-child { display: none; }
.leather__price { font-size: 20px; line-height: 1; }
}
@media (prefers-reduced-motion: reduce) {
.leather__card { animation: none !important; }
.leather__stitch { animation: none !important; opacity: 1; }
}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);
}
.leather {
display: grid;
place-items: center;
}
.leather__card {
position: relative;
box-sizing: border-box;
width: clamp(200px, 64vw, 300px);
padding: 16px;
border-radius: 16px;
background: repeating-linear-gradient(34deg, rgba(144, 101, 71, 0.38) 0 2px, transparent 2px 5px), repeating-linear-gradient(-26deg, rgba(150, 97, 60, 0.22) 0 1px, transparent 1px 6px), radial-gradient(120% 120% at 30% 20%, rgba(150, 97, 60, 0.5), transparent 55%), #7a4a2b;
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 10px 14px rgba(0, 0, 0, 0.45);
animation: leatherLand 2s ease-out infinite;
}
.leather__stitch {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 8px;
border-radius: 12px;
opacity: 0;
background: repeating-linear-gradient(90deg, #f3e2c8 0 8px, transparent 8px 16px) top/100% 2px no-repeat, repeating-linear-gradient(90deg, #f3e2c8 0 8px, transparent 8px 16px) bottom/100% 2px no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 8px, transparent 8px 16px) left/2px 100% no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 8px, transparent 8px 16px) right/2px 100% no-repeat;
filter: drop-shadow(0 1px 0 rgba(144, 101, 71, 0.9));
animation: stitchCut 2s steps(1, end) infinite;
}
.leather__plan {
margin: 0 0 4px;
color: #f6e7cf;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.leather__price {
margin: 0 0 8px;
color: #ffffff;
font-weight: 800;
font-size: clamp(20px, 5.2vw, 30px);
line-height: 1.1;
font-family: inherit;
}
.leather__perks {
margin: 0 0 12px;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
}
.leather__perk {
color: #f6e7cf;
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.4;
font-family: inherit;
}
.leather__cta {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #3d2412;
color: #ffffff;
font-weight: 700;
font-size: clamp(12px, 2.8vw, 14px);
line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 235, 200, 0.22), 0 4px 0 rgba(0, 0, 0, 0.4);
}
.leather__cta:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 2px;
}
@keyframes leatherLand {
0% {
transform: translateY(-20px);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 20px 26px rgba(0, 0, 0, 0.55);
}
34% {
transform: translateY(0);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 8px 12px rgba(0, 0, 0, 0.45);
}
42% {
transform: translateY(-2px);
}
50% {
transform: translateY(0);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 10px 14px rgba(0, 0, 0, 0.45);
}
86% {
transform: translateY(0);
}
94%, 100% {
transform: translateY(-20px);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 20px 26px rgba(0, 0, 0, 0.55);
}
}
@keyframes stitchCut {
0%, 36% {
opacity: 0;
}
38%, 86% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.leather__card {
width: 196px;
padding: 12px;
}
.leather__perks {
margin-bottom: 8px;
}
.leather__perk:last-child {
display: none;
}
.leather__price {
font-size: 20px;
line-height: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.leather__card {
animation: none !important;
}
.leather__stitch {
animation: none !important;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./LeatherStitchCard.module.scss";
export type LeatherStitchCardProps = {
plan?: string;
price?: string;
perk1?: string;
perk2?: string;
cta?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LeatherStitchCard({
plan = "Pro 멤버십",
price = "월 24,000원",
perk1 = "질감 부품 9종 전체",
perk2 = "상업 이용 포함",
cta = "선택하기",
duration = 2000,
easing = "ease-out",
color = "#7a4a2b",
}: LeatherStitchCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.leather} style={vars}>
<div className={styles.card}>
<p className={styles.plan}>{plan}</p>
<p className={styles.price}>{price}</p>
<ul className={styles.perks}>
<li className={styles.perk}>{perk1}</li>
<li className={styles.perk}>{perk2}</li>
</ul>
<button type="button" className={styles.cta}>{cta}</button>
<span className={styles.stitch} aria-hidden="true" />
</div>
</section>
);
}01 · レザーのステッチ料金カード — 計測 累積面積 23.101% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ステッチは画像ではなく、4辺それぞれにrepeating-linear-gradientの目盛を巻いたものなので、背景色を変えても作り直す必要がありません。
.leather__stitch {
position: absolute;
top: 8px; left: 8px; right: 8px; bottom: 8px;
border-radius: $r-control;
opacity: 0;
background:
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) top / 100% 2px no-repeat,
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) bottom / 100% 2px no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) left / 2px 100% no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) right / 2px 100% no-repeat;
animation: stitchCut $duration steps(1, end) infinite;
}
@keyframes stitchCut {
0%, 36% { opacity: 0; }
38%, 86% { opacity: 1; }
88%, 100% { opacity: 0; }
}
02メタルダイヤルCTA
スキューモーフィズム ui のメタルダイヤルは画面でどう見えるか
ダイヤルが左右32度ずつ回ると、conic-gradientの陰影が面を流れて、削り出した金属のように見えます。

<main class="stage">
<section class="metaldial">
<p class="metaldial__label">無料で始める</p>
<button class="metaldial__dial" type="button" aria-label="無料で始める">
<span class="metaldial__rim" aria-hidden="true"></span>
<span class="metaldial__face" aria-hidden="true"></span>
<span class="metaldial__cap" aria-hidden="true"></span>
<span class="metaldial__pointer" aria-hidden="true"></span>
</button>
<p class="metaldial__hint">ダイヤルを回して確かめて</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 금속 다이얼 — conic-gradient 음영이 회전과 함께 흘러 표면이 금속으로 보인다.
$metal-hi: #f4f5f8; // 가장 밝은 하이라이트
$metal-mid: #aab0bc; // 중간 톤
$metal-lo: #6f7481; // 어두운 톤
$metal-edge: #4b4f59; // 테두리
$knurl: #2c2f36; // 테두리 노치 (깎인 홈)
$duration: $dur-loop; // 좌우로 한 번 도는 주기
$easing: $ease-spring; // 물리 다이얼은 끝에서 살짝 돌아온다
$color: $metal-hi;
@include stage($stage-yellow);
.metaldial {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.metaldial__label {
margin: 0;
color: $subject-ink;
font-weight: 800; font-size: clamp(14px, 3.6vw, 18px); line-height: 1.2;
font-family: inherit;
}
// 다이얼 = CTA 버튼. 원반이 통째로 돌아야 음영이 표면을 흐른다
.metaldial__dial {
position: relative;
width: clamp(120px, 38vw, 168px);
aspect-ratio: 1;
border: 0;
border-radius: 50%;
padding: 0;
cursor: pointer;
background: transparent;
animation: dialSwing $duration $easing infinite;
}
.metaldial__dial:focus-visible {
outline: 3px solid $subject-blue;
outline-offset: 4px;
}
// 바깥 테두리 — 노치(knurl): 반복 conic 어두운 눈금을 금속 링 위에 깎는다
.metaldial__rim {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: 50%;
background:
repeating-conic-gradient(from 0deg, $knurl 0deg 2deg, transparent 2deg 7deg) padding-box,
conic-gradient(from 200deg, $metal-hi, $metal-lo 18%, $metal-mid 38%, $metal-hi 55%, $metal-edge 72%, $metal-mid 88%, $metal-hi);
box-shadow: 0 8px 14px rgba(23, 20, 26, .35);
}
// 다이얼 면 — conic 음영 4계절. 이 면이 돌면 하이라이트가 표면을 흐른다
.metaldial__face {
position: absolute;
top: 12%; left: 12%; right: 12%; bottom: 12%;
border-radius: 50%;
background:
conic-gradient(from 210deg,
$metal-hi 0deg, $metal-lo 70deg, $metal-mid 120deg,
$metal-hi 180deg, $metal-edge 250deg, $metal-mid 300deg, $metal-hi 360deg);
box-shadow: inset 0 2px 3px rgba(255, 255, 255, .65), inset 0 -3px 5px rgba(0, 0, 0, .4);
}
// 중앙 캡 — 파란 인레이. 정지 축 역할
.metaldial__cap {
position: absolute;
top: 36%; left: 36%; right: 36%; bottom: 36%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, $subject-blue 55%, #5d74af);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, .5), 0 2px 4px rgba(0, 0, 0, .45);
}
// 지침 — 캡에서 바깥으로 뻗는 밝은 선. 방향을 가리킨다
.metaldial__pointer {
position: absolute;
top: 8%; left: calc(50% - 2px);
width: 4px;
height: 34%;
border-radius: $r-pill;
background: $metal-hi;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .25);
}
.metaldial__hint {
margin: 0;
color: $subject-ink;
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.4;
font-family: inherit;
}
// 32도씩 왕복 — conic 하이라이트가 면을 흘러 다니고 노치·지침이 함께 돈다
@keyframes dialSwing {
0%, 6% { transform: rotate(-32deg); }
48%, 56% { transform: rotate(32deg); }
94%, 100% { transform: rotate(-32deg); }
}
@media (max-width: 340px), (max-height: 220px) {
.metaldial { gap: $sp-2; }
.metaldial__label { font-size: 13px; }
.metaldial__hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.metaldial__dial { animation: none !important; 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);
}
.metaldial {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.metaldial__label {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(14px, 3.6vw, 18px);
line-height: 1.2;
font-family: inherit;
}
.metaldial__dial {
position: relative;
width: clamp(120px, 38vw, 168px);
aspect-ratio: 1;
border: 0;
border-radius: 50%;
padding: 0;
cursor: pointer;
background: transparent;
animation: dialSwing 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.metaldial__dial:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.metaldial__rim {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
background: repeating-conic-gradient(from 0deg, #2c2f36 0deg 2deg, transparent 2deg 7deg) padding-box, conic-gradient(from 200deg, #f4f5f8, #6f7481 18%, #aab0bc 38%, #f4f5f8 55%, #4b4f59 72%, #aab0bc 88%, #f4f5f8);
box-shadow: 0 8px 14px rgba(23, 20, 26, 0.35);
}
.metaldial__face {
position: absolute;
top: 12%;
left: 12%;
right: 12%;
bottom: 12%;
border-radius: 50%;
background: conic-gradient(from 210deg, #f4f5f8 0deg, #6f7481 70deg, #aab0bc 120deg, #f4f5f8 180deg, #4b4f59 250deg, #aab0bc 300deg, #f4f5f8 360deg);
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.65), inset 0 -3px 5px rgba(0, 0, 0, 0.4);
}
.metaldial__cap {
position: absolute;
top: 36%;
left: 36%;
right: 36%;
bottom: 36%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, #2f6df6 55%, #5d74af);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.5), 0 2px 4px rgba(0, 0, 0, 0.45);
}
.metaldial__pointer {
position: absolute;
top: 8%;
left: calc(50% - 2px);
width: 4px;
height: 34%;
border-radius: 999px;
background: #f4f5f8;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.metaldial__hint {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.4;
font-family: inherit;
}
@keyframes dialSwing {
0%, 6% {
transform: rotate(-32deg);
}
48%, 56% {
transform: rotate(32deg);
}
94%, 100% {
transform: rotate(-32deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.metaldial {
gap: 8px;
}
.metaldial__label {
font-size: 13px;
}
.metaldial__hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.metaldial__dial {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./MetalDialCta.module.scss";
export type MetalDialCtaProps = {
label?: string;
hint?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MetalDialCta({
label = "무료로 시작",
hint = "다이얼을 돌려 살펴 보세요",
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4f5f8",
}: MetalDialCtaProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.metaldial} style={vars}>
<p className={styles.label}>{label}</p>
<button type="button" className={styles.dial} aria-label={label}>
<span className={styles.rim} aria-hidden="true" />
<span className={styles.face} aria-hidden="true" />
<span className={styles.cap} aria-hidden="true" />
<span className={styles.pointer} aria-hidden="true" />
</button>
<p className={styles.hint}>{hint}</p>
</section>
);
}02 · メタルダイヤルCTA — 計測 累積面積 17.386% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
要素は本物のボタン要素で、円盤そのものがコンバージョンを取る操作になっています。
.metaldial__dial {
animation: dialSwing $duration $ease-spring infinite;
}
.metaldial__face {
background:
conic-gradient(from 210deg,
$metal-hi 0deg, $metal-lo 70deg, $metal-mid 120deg,
$metal-hi 180deg, $metal-edge 250deg, $metal-mid 300deg, $metal-hi 360deg);
box-shadow: inset 0 2px 3px rgba(255, 255, 255, .65), inset 0 -3px 5px rgba(0, 0, 0, .4);
}
@keyframes dialSwing {
0%, 6% { transform: rotate(-32deg); }
48%, 56% { transform: rotate(32deg); }
94%, 100% { transform: rotate(-32deg); }
}
03紙目の機能カード
どんな動きか
目の入った紙3枚が重なり、一番上の紙が48pxだけ上へ滑って、下の紙の目を現します。

<main class="stage">
<section class="paperstack" aria-label="紙目のカード3枚">
<article class="paperstack__sheet paperstack__sheet--3" aria-hidden="true">
<span class="paperstack__line"></span><span class="paperstack__line"></span>
</article>
<article class="paperstack__sheet paperstack__sheet--2" aria-hidden="true">
<span class="paperstack__tag">2番目</span>
<span class="paperstack__line"></span>
</article>
<article class="paperstack__sheet paperstack__sheet--1">
<p class="paperstack__eyebrow">機能カード</p>
<h2 class="paperstack__title">スムーズな決済</h2>
<p class="paperstack__desc">目の見える紙のカード</p>
</article>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 종이 — base64 노이즈 없이 repeating-linear-gradient 세 겹으로 미세한 결을 만든다.
// 결 방향을 섞는다(0deg·90deg·48deg) — 한 방향만 쓰면 줄무늬로 보인다
$grain-a: rgba(122, 105, 78, .06); // 어두운 결 — 세로
$grain-b: rgba(122, 105, 78, .05); // 어두운 결 — 가로
$grain-c: rgba(255, 255, 255, .5); // 밝은 결 — 비스듬한 실
$paper-face: $subject-cream; // 종이 면 (토큰)
$paper-ink: #574a38; // 종이 위 글자 — cream 대비 7.1:1
$duration: $dur-loop; // 맨 위 시트가 올라갔다 돌아오는 주기
$easing: $ease-spring; // 종이가 살짝 튀어 오른다
$color: $subject-cream;
@include stage($stage-orange);
.paperstack {
position: relative;
width: clamp(200px, 62vw, 280px);
}
// 시트 공통 — 결 그라디언트 + 아래 두 장은 살짝 비틀려 겹침을 보여준다
.paperstack__sheet {
position: absolute;
top: 0; left: 0;
box-sizing: border-box;
width: 100%;
padding: $sp-4;
border-radius: $r-card;
background:
repeating-linear-gradient(0deg, $grain-a 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, $grain-b 0 1px, transparent 1px 4px),
repeating-linear-gradient(48deg, $grain-c 0 1px, transparent 1px 7px),
$paper-face;
box-shadow: 0 6px 10px rgba(23, 20, 26, .3);
}
.paperstack__sheet--2 {
top: 10px; left: 8px;
transform: rotate(1.6deg);
height: 152px;
}
.paperstack__sheet--3 {
top: 20px; left: -6px;
transform: rotate(-2.2deg);
height: 148px;
}
// 맨 위 시트 — 48px 위로 미끄러져 아래 시트의 결을 드러낸다
.paperstack__sheet--1 {
position: relative;
height: 156px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-1;
z-index: 1;
animation: sheetSlide $duration $easing infinite;
}
.paperstack__eyebrow {
margin: 0;
color: #8a6d3f;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
letter-spacing: .12em;
text-transform: uppercase;
}
.paperstack__title {
margin: 0;
color: $paper-ink;
font-weight: 800; font-size: clamp(16px, 4.2vw, 22px); line-height: 1.2;
font-family: inherit;
}
.paperstack__desc {
margin: 0;
color: $paper-ink;
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.4;
font-family: inherit;
}
.paperstack__tag {
margin: $sp-2 0 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: #e9dcc2;
color: #574a38;
font-weight: 700; font-size: 10px; line-height: 1;
font-family: inherit;
}
.paperstack__line {
display: block;
height: 8px;
margin: $sp-2 0 0;
border-radius: $r-pill;
background: rgba(87, 74, 56, .16);
}
// 0%·12% 정지 → 46%까지 48px 상승 → 잠시 멈춤 → 90%까지 복귀. 텔레포트 없다
@keyframes sheetSlide {
0%, 10% { transform: translateY(0); }
46%, 58% { transform: translateY(-48px); }
88%, 100% { transform: translateY(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.paperstack { width: 208px; }
.paperstack__sheet--1 { height: 128px; padding: $sp-3; }
.paperstack__sheet--2, .paperstack__sheet--3 { height: 124px; }
.paperstack__sheet--2 { top: 8px; }
.paperstack__sheet--3 { top: 16px; }
.paperstack__desc { display: none; }
@keyframes sheetSlide {
0%, 10% { transform: translateY(0); }
46%, 58% { transform: translateY(-20px); }
88%, 100% { transform: translateY(0); }
}
}
@media (prefers-reduced-motion: reduce) {
.paperstack__sheet--1 { animation: none !important; transform: 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);
}
.paperstack {
position: relative;
width: clamp(200px, 62vw, 280px);
}
.paperstack__sheet {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 100%;
padding: 16px;
border-radius: 16px;
background: repeating-linear-gradient(0deg, rgba(122, 105, 78, 0.06) 0 1px, transparent 1px 3px), repeating-linear-gradient(90deg, rgba(122, 105, 78, 0.05) 0 1px, transparent 1px 4px), repeating-linear-gradient(48deg, rgba(255, 255, 255, 0.5) 0 1px, transparent 1px 7px), #fff7e6;
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.3);
}
.paperstack__sheet--2 {
top: 10px;
left: 8px;
transform: rotate(1.6deg);
height: 152px;
}
.paperstack__sheet--3 {
top: 20px;
left: -6px;
transform: rotate(-2.2deg);
height: 148px;
}
.paperstack__sheet--1 {
position: relative;
height: 156px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
z-index: 1;
animation: sheetSlide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.paperstack__eyebrow {
margin: 0;
color: #8a6d3f;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.paperstack__title {
margin: 0;
color: #574a38;
font-weight: 800;
font-size: clamp(16px, 4.2vw, 22px);
line-height: 1.2;
font-family: inherit;
}
.paperstack__desc {
margin: 0;
color: #574a38;
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.4;
font-family: inherit;
}
.paperstack__tag {
margin: 8px 0 0;
border-radius: 4px;
padding: 4px 8px;
background: #e9dcc2;
color: #574a38;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
}
.paperstack__line {
display: block;
height: 8px;
margin: 8px 0 0;
border-radius: 999px;
background: rgba(87, 74, 56, 0.16);
}
@keyframes sheetSlide {
0%, 10% {
transform: translateY(0);
}
46%, 58% {
transform: translateY(-48px);
}
88%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.paperstack {
width: 208px;
}
.paperstack__sheet--1 {
height: 128px;
padding: 12px;
}
.paperstack__sheet--2, .paperstack__sheet--3 {
height: 124px;
}
.paperstack__sheet--2 {
top: 8px;
}
.paperstack__sheet--3 {
top: 16px;
}
.paperstack__desc {
display: none;
}
@keyframes sheetSlide {
0%, 10% {
transform: translateY(0);
}
46%, 58% {
transform: translateY(-20px);
}
88%, 100% {
transform: translateY(0);
}
}
}
@media (prefers-reduced-motion: reduce) {
.paperstack__sheet--1 {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./PaperGrainCard.module.scss";
export type PaperGrainCardProps = {
eyebrow?: string;
title?: string;
desc?: string;
tag?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PaperGrainCard({
eyebrow = "기능 카드",
title = "빠른 결제",
desc = "결이 보이는 종이 카드",
tag = "두 번째",
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: PaperGrainCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.paperstack} style={vars} aria-label="종이 결 카드 세 장">
<article className={`${styles.sheet} ${styles.sheet3}`} aria-hidden="true">
<span className={styles.line}></span><span className={styles.line}></span>
</article>
<article className={`${styles.sheet} ${styles.sheet2}`} aria-hidden="true">
<span className={styles.tag}>{tag}</span>
<span className={styles.line}></span>
</article>
<article className={`${styles.sheet} ${styles.sheet1}`}>
<p className={styles.eyebrow}>{eyebrow}</p>
<h2 className={styles.title}>{title}</h2>
<p className={styles.desc}>{desc}</p>
</article>
</section>
);
}03 · 紙目の機能カード — 計測 累積面積 34.744% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
目は方向の異なる1pxのrepeating-linear-gradient3層なので、ノイズ画像もbase64も不要です。
.paperstack__sheet {
background:
repeating-linear-gradient(0deg, $grain-a 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, $grain-b 0 1px, transparent 1px 4px),
repeating-linear-gradient(48deg, $grain-c 0 1px, transparent 1px 7px),
$paper-face;
}
@keyframes sheetSlide {
0%, 10% { transform: translateY(0); }
46%, 58% { transform: translateY(-48px); }
88%, 100% { transform: translateY(0); }
}
04ボリュームノブ
何が見えるか
ノブが目盛りの最小から最大まで左右120度ずつ回り、明るい指針線が目盛りの上を通っていきます。

<main class="stage">
<section class="volknob" aria-label="ボリュームノブ">
<span class="volknob__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/></g></svg>
</span>
<div class="volknob__panel">
<span class="volknob__tick volknob__tick--1" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--2" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--3" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--4" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--5" aria-hidden="true"></span>
<div class="volknob__knob">
<span class="volknob__needle" aria-hidden="true"></span>
</div>
</div>
<p class="volknob__label">音量調整</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 볼륨 노브 — 어두운 몸통에 밝은 지침. 몸통이 돌면 위 하이라이트 대비로 회전이 읽힌다
$knob-body: #2b2831; // 노브 몸통
$knob-face: #38343f; // 노브 윗면
$knob-line: #efe8f7; // 지침 선 (밝게)
$panel-bg: #eceaf1; // 눈금 판
$duration: $dur-loop; // 최소 → 최대 → 최소 한 주기
$easing: $ease-out; // 손을 떼면 감속하며 멈춘다
$color: $knob-body;
@include stage($stage-paper);
.volknob {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.volknob__icon {
width: 28px;
height: 28px;
color: $subject-ink;
}
// 눈금 판 — 눈금 다섯 개를 원주 위에 미리 돌려 놓는다 (정적)
.volknob__panel {
position: relative;
width: clamp(132px, 42vw, 176px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(130% 130% at 30% 24%, #ffffff, $panel-bg 70%);
box-shadow: inset 0 2px 4px rgba(23, 20, 26, .18), 0 3px 8px rgba(23, 20, 26, .14);
}
.volknob__tick {
position: absolute;
top: 6px; left: calc(50% - 2px);
width: 4px;
height: 10px;
border-radius: $r-pill;
background: #a9a4b4;
transform-origin: 50% calc(clamp(66px, 21vw, 88px) - 11px); // 패널 중심까지 = 반지름 - 눈금 중심 y
}
.volknob__tick--1 { transform: rotate(-120deg); }
.volknob__tick--2 { transform: rotate(-60deg); }
.volknob__tick--3 { transform: rotate(0deg); }
.volknob__tick--4 { transform: rotate(60deg); }
.volknob__tick--5 { transform: rotate(120deg); }
// 노브 몸통 — 도는 주인공. 윗면 그라디언트가 살짝 비스듬해 회전이 읽힌다
.volknob__knob {
position: absolute;
top: 20%; left: 20%; right: 20%; bottom: 20%;
border-radius: 50%;
background:
radial-gradient(120% 120% at 34% 26%, rgba(255, 255, 255, .32), transparent 42%),
conic-gradient(from 250deg, $knob-face, $knob-body 90deg, $knob-face 170deg, $knob-body 260deg, $knob-face 340deg);
box-shadow: 0 6px 10px rgba(23, 20, 26, .35), inset 0 1px 2px rgba(255, 255, 255, .22);
animation: knobSweep $duration $easing infinite;
}
.volknob__knob:focus-visible {
outline: 3px solid $subject-blue;
outline-offset: 3px;
}
// 지침 선 — 노브 안에서 위로 뻗는다
.volknob__needle {
position: absolute;
top: 6%; left: calc(50% - 2px);
width: 4px;
height: 30%;
border-radius: $r-pill;
background: $knob-line;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .3);
}
.volknob__label {
margin: 0;
color: $subject-ink;
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 최소 눈금(-120도) 에서 최대(+120도) 까지 돌고 돌아온다 — 양 끝에서 잠시 멈춘다
@keyframes knobSweep {
0%, 8% { transform: rotate(-120deg); }
46%, 56% { transform: rotate(120deg); }
92%, 100% { transform: rotate(-120deg); }
}
@media (max-width: 340px), (max-height: 220px) {
.volknob__icon { width: 20px; height: 20px; }
.volknob__label { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.volknob__knob { animation: none !important; transform: 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);
}
.volknob {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.volknob__icon {
width: 28px;
height: 28px;
color: #17141a;
}
.volknob__panel {
position: relative;
width: clamp(132px, 42vw, 176px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(130% 130% at 30% 24%, #ffffff, #eceaf1 70%);
box-shadow: inset 0 2px 4px rgba(23, 20, 26, 0.18), 0 3px 8px rgba(23, 20, 26, 0.14);
}
.volknob__tick {
position: absolute;
top: 6px;
left: calc(50% - 2px);
width: 4px;
height: 10px;
border-radius: 999px;
background: #a9a4b4;
transform-origin: 50% calc(clamp(66px, 21vw, 88px) - 11px);
}
.volknob__tick--1 {
transform: rotate(-120deg);
}
.volknob__tick--2 {
transform: rotate(-60deg);
}
.volknob__tick--3 {
transform: rotate(0deg);
}
.volknob__tick--4 {
transform: rotate(60deg);
}
.volknob__tick--5 {
transform: rotate(120deg);
}
.volknob__knob {
position: absolute;
top: 20%;
left: 20%;
right: 20%;
bottom: 20%;
border-radius: 50%;
background: radial-gradient(120% 120% at 34% 26%, rgba(255, 255, 255, 0.32), transparent 42%), conic-gradient(from 250deg, #38343f, #2b2831 90deg, #38343f 170deg, #2b2831 260deg, #38343f 340deg);
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.35), inset 0 1px 2px rgba(255, 255, 255, 0.22);
animation: knobSweep 2s ease-out infinite;
}
.volknob__knob:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.volknob__needle {
position: absolute;
top: 6%;
left: calc(50% - 2px);
width: 4px;
height: 30%;
border-radius: 999px;
background: #efe8f7;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.volknob__label {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes knobSweep {
0%, 8% {
transform: rotate(-120deg);
}
46%, 56% {
transform: rotate(120deg);
}
92%, 100% {
transform: rotate(-120deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.volknob__icon {
width: 20px;
height: 20px;
}
.volknob__label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.volknob__knob {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./VolumeKnob.module.scss";
export type VolumeKnobProps = {
label?: string;
/** 스크린리더용 섹션 이름 (기본 "볼륨 노브") */
ariaLabel?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function VolumeKnob({
label = "음량 조절",
ariaLabel = "볼륨 노브",
duration = 2000,
easing = "ease-out",
color = "#2b2831",
}: VolumeKnobProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.volknob} style={vars} aria-label={ariaLabel}>
<span className={styles.icon} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/></g></svg>
</span>
<div className={styles.panel}>
<span className={`${styles.tick} ${styles.tick1}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick2}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick3}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick4}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick5}`} aria-hidden="true" />
<div className={styles.knob}>
<span className={styles.needle} aria-hidden="true" />
</div>
</div>
<p className={styles.label}>{label}</p>
</section>
);
}04 · ボリュームノブ — 計測 累積面積 8.684% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
回転を円の内側だけで完結させているので、角度が進んでもノブが大きく見えることはありません。
.volknob__knob {
background:
radial-gradient(120% 120% at 34% 26%, rgba(255, 255, 255, .32), transparent 42%),
conic-gradient(from 250deg, $knob-face, $knob-body 90deg, $knob-face 170deg,
$knob-body 260deg, $knob-face 340deg);
box-shadow: 0 6px 10px rgba(23, 20, 26, .35), inset 0 1px 2px rgba(255, 255, 255, .22);
animation: knobSweep $duration $ease-out infinite;
}
@keyframes knobSweep {
0%, 8% { transform: rotate(-120deg); }
46%, 56% { transform: rotate(120deg); }
92%, 100% { transform: rotate(-120deg); }
}
05ガラスインレイバッジ
画面で起きること
ブラシメタルの板に彫った窪みにガラスバッジが嵌っており、斜めのハイライト帯が左から右へ撫でていきます。

<main class="stage">
<section class="glassbadge">
<div class="glassbadge__plate">
<p class="glassbadge__caption">正会員バッジ</p>
<div class="glassbadge__inlay" role="img" aria-label="ガラスバッジ">
<span class="glassbadge__core" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2"/><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18m-11.55 39.29c-4.79 5-9.75 10.17-12.38 16.52c-2.52 6.1-2.63 13.07-2.73 19.82c-.1 7-.21 14.33-3.32 17.43s-10.39 3.22-17.43 3.32c-6.75.1-13.72.21-19.82 2.73c-6.35 2.63-11.52 7.59-16.52 12.38S132 224 128 224s-9.15-4.92-14.11-9.69s-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73c-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82c-2.63-6.35-7.59-11.52-12.38-16.52S32 132 32 128s4.92-9.15 9.69-14.11s9.75-10.17 12.38-16.52c2.52-6.1 2.63-13.07 2.73-19.82c.1-7 .21-14.33 3.32-17.43s10.39-3.22 17.43-3.32c6.75-.1 13.72-.21 19.82-2.73c6.35-2.63 11.52-7.59 16.52-12.38S124 32 128 32s9.15 4.92 14.11 9.69s10.17 9.75 16.52 12.38c6.1 2.52 13.07 2.63 19.82 2.73c7 .1 14.33.21 17.43 3.32s3.22 10.39 3.32 17.43c.1 6.75.21 13.72 2.73 19.82c2.63 6.35 7.59 11.52 12.38 16.52S224 124 224 128s-4.92 9.15-9.69 14.11m-40.65-43.77a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
<span class="glassbadge__sheen" aria-hidden="true"></span>
<span class="glassbadge__flare" aria-hidden="true"></span>
<span class="glassbadge__gloss" aria-hidden="true"></span>
</div>
</div>
<p class="glassbadge__note">月間利用者限定</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 유리 인레이 — 브러시드 메탈 판에 파인 자리(inset)에 민트 코어를 박고 유리 광이 훑고 지나간다.
$brush-hi: #565b66; // 브러시 밝은 결
$brush-lo: #31353e; // 브러시 어두운 결
$inset-rim: #22252c; // 인레이 파인 테두리
$core: $subject-mint; // 유리 안 코어 (토큰)
$duration: $dur-loop; // 광선이 한 번 훑고 지나가는 주기
$easing: $ease-out; // 광선은 감속하며 지나간다
$color: $core;
@include stage($stage-ink);
.glassbadge {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
// 브러시드 메탈 판 — 1px 결을 가로로 촘촘하게 깐다 (이미지 아님)
.glassbadge__plate {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: clamp(212px, 66vw, 300px);
padding: $sp-4;
border-radius: $r-card;
background:
repeating-linear-gradient(90deg, $brush-hi 0 1px, $brush-lo 1px 3px, $brush-hi 3px 4px, $inset-rim 4px 7px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .14), inset 0 -2px 0 rgba(0, 0, 0, .5), 0 10px 16px rgba(0, 0, 0, .5);
}
.glassbadge__caption {
margin: 0;
color: #ffffff;
font-weight: 800; font-size: clamp(12px, 3vw, 15px); line-height: 1;
font-family: inherit;
letter-spacing: .08em;
}
// 유리 인레이 — 판보다 낮게 파인 자리. overflow 로 광선을 자른다
.glassbadge__inlay {
position: relative;
width: clamp(104px, 32vw, 140px);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: radial-gradient(130% 130% at 32% 26%, rgba(255, 255, 255, .3), transparent 46%), $inset-rim;
box-shadow: inset 0 4px 8px rgba(0, 0, 0, .75), inset 0 -2px 4px rgba(255, 255, 255, .12);
}
// 민트 코어 — 유리 안에서 한 단 낮게 떠 있는 인레이
.glassbadge__core {
position: absolute;
top: 16%; left: 16%; right: 16%; bottom: 16%;
display: grid;
place-items: center;
border-radius: 50%;
color: #0c2c20;
font-size: clamp(26px, 8vw, 36px);
background: radial-gradient(120% 120% at 34% 28%, #d6ebe2, $core 58%, #4fa183);
box-shadow: inset 0 -3px 6px rgba(0, 0, 0, .35), 0 2px 5px rgba(0, 0, 0, .55);
}
// 플레어 — 광선이 코어를 지나는 순간 코어가 한 번 밝아진다 (opacity 만)
.glassbadge__flare {
position: absolute;
top: 16%; left: 16%; right: 16%; bottom: 16%;
border-radius: 50%;
background: radial-gradient(120% 120% at 42% 34%, rgba(255, 255, 255, .55), transparent 64%);
opacity: 0;
animation: flareUp $duration $easing infinite;
}
// 광선 — 배지보다 길게 만들고 translateX 로 민다 (transform 이니 합성기가 처리한다)
.glassbadge__sheen {
position: absolute;
top: -20%; left: 0;
width: 44%;
height: 140%;
transform: translateX(-140%) rotate(24deg);
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .4) 45%, rgba(255, 255, 255, .55) 50%, rgba(255, 255, 255, .4) 55%, transparent);
animation: sheenSweep $duration $easing infinite;
}
// 유리 위쪽 고광 — 정적인 초승달
.glassbadge__gloss {
position: absolute;
top: 6%; left: 18%;
width: 64%;
height: 36%;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, .34), transparent 70%);
}
.glassbadge__note {
margin: 0;
color: rgba(255, 255, 255, .82);
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 광선: 왼쪽 바깥에서 출발 → 중앙(코어 위) 을 지나 → 오른쪽 바깥으로. 처음과 끝이 같다
@keyframes sheenSweep {
0%, 12% { transform: translateX(-140%) rotate(24deg); }
82%, 100% { transform: translateX(340%) rotate(24deg); }
}
// 코어 플레어는 광선이 지나가는 40~52% 구간에만 잠깐 올랐다 진다 (유리를 통과하는 빛)
@keyframes flareUp {
0%, 36% { opacity: 0; }
44%, 50% { opacity: 1; }
58%, 100% { opacity: 0; }
}
@media (max-width: 340px), (max-height: 220px) {
.glassbadge__plate { padding: $sp-3; gap: $sp-1; }
.glassbadge__note { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.glassbadge__sheen { animation: none !important; transform: translateX(120%) rotate(24deg); }
.glassbadge__flare { animation: none !important; opacity: 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);
}
.glassbadge {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.glassbadge__plate {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: clamp(212px, 66vw, 300px);
padding: 16px;
border-radius: 16px;
background: repeating-linear-gradient(90deg, #565b66 0 1px, #31353e 1px 3px, #565b66 3px 4px, #22252c 4px 7px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), inset 0 -2px 0 rgba(0, 0, 0, 0.5), 0 10px 16px rgba(0, 0, 0, 0.5);
}
.glassbadge__caption {
margin: 0;
color: #ffffff;
font-weight: 800;
font-size: clamp(12px, 3vw, 15px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
}
.glassbadge__inlay {
position: relative;
width: clamp(104px, 32vw, 140px);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: radial-gradient(130% 130% at 32% 26%, rgba(255, 255, 255, 0.3), transparent 46%), #22252c;
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.75), inset 0 -2px 4px rgba(255, 255, 255, 0.12);
}
.glassbadge__core {
position: absolute;
top: 16%;
left: 16%;
right: 16%;
bottom: 16%;
display: grid;
place-items: center;
border-radius: 50%;
color: #0c2c20;
font-size: clamp(26px, 8vw, 36px);
background: radial-gradient(120% 120% at 34% 28%, #d6ebe2, #4cd4a6 58%, #4fa183);
box-shadow: inset 0 -3px 6px rgba(0, 0, 0, 0.35), 0 2px 5px rgba(0, 0, 0, 0.55);
}
.glassbadge__flare {
position: absolute;
top: 16%;
left: 16%;
right: 16%;
bottom: 16%;
border-radius: 50%;
background: radial-gradient(120% 120% at 42% 34%, rgba(255, 255, 255, 0.55), transparent 64%);
opacity: 0;
animation: flareUp 2s ease-out infinite;
}
.glassbadge__sheen {
position: absolute;
top: -20%;
left: 0;
width: 44%;
height: 140%;
transform: translateX(-140%) rotate(24deg);
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.4) 45%, rgba(255, 255, 255, 0.55) 50%, rgba(255, 255, 255, 0.4) 55%, transparent);
animation: sheenSweep 2s ease-out infinite;
}
.glassbadge__gloss {
position: absolute;
top: 6%;
left: 18%;
width: 64%;
height: 36%;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.34), transparent 70%);
}
.glassbadge__note {
margin: 0;
color: rgba(255, 255, 255, 0.82);
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes sheenSweep {
0%, 12% {
transform: translateX(-140%) rotate(24deg);
}
82%, 100% {
transform: translateX(340%) rotate(24deg);
}
}
@keyframes flareUp {
0%, 36% {
opacity: 0;
}
44%, 50% {
opacity: 1;
}
58%, 100% {
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.glassbadge__plate {
padding: 12px;
gap: 4px;
}
.glassbadge__note {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.glassbadge__sheen {
animation: none !important;
transform: translateX(120%) rotate(24deg);
}
.glassbadge__flare {
animation: none !important;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./GlassInlayBadge.module.scss";
export type GlassInlayBadgeProps = {
caption?: string;
note?: string;
/** 인레이 role="img" 라벨 (기본 "유리 배지") */
ariaLabel?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GlassInlayBadge({
caption = "정회원 배지",
note = "한 달 이용자 한정",
ariaLabel = "유리 배지",
duration = 2000,
easing = "ease-out",
color = "#4cd4a6",
}: GlassInlayBadgeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.glassbadge} style={vars}>
<div className={styles.plate}>
<p className={styles.caption}>{caption}</p>
<div className={styles.inlay} role="img" aria-label={ariaLabel}>
<span className={styles.core} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2"/><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18m-11.55 39.29c-4.79 5-9.75 10.17-12.38 16.52c-2.52 6.1-2.63 13.07-2.73 19.82c-.1 7-.21 14.33-3.32 17.43s-10.39 3.22-17.43 3.32c-6.75.1-13.72.21-19.82 2.73c-6.35 2.63-11.52 7.59-16.52 12.38S132 224 128 224s-9.15-4.92-14.11-9.69s-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73c-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82c-2.63-6.35-7.59-11.52-12.38-16.52S32 132 32 128s4.92-9.15 9.69-14.11s9.75-10.17 12.38-16.52c2.52-6.1 2.63-13.07 2.73-19.82c.1-7 .21-14.33 3.32-17.43s10.39-3.22 17.43-3.32c6.75-.1 13.72-.21 19.82-2.73c6.35-2.63 11.52-7.59 16.52-12.38S124 32 128 32s9.15 4.92 14.11 9.69s10.17 9.75 16.52 12.38c6.1 2.52 13.07 2.63 19.82 2.73c7 .1 14.33.21 17.43 3.32s3.22 10.39 3.32 17.43c.1 6.75.21 13.72 2.73 19.82c2.63 6.35 7.59 11.52 12.38 16.52S224 124 224 128s-4.92 9.15-9.69 14.11m-40.65-43.77a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
<span className={styles.sheen} aria-hidden="true" />
<span className={styles.flare} aria-hidden="true" />
<span className={styles.gloss} aria-hidden="true" />
</div>
</div>
<p className={styles.note}>{note}</p>
</section>
);
}05 · ガラスインレイバッジ — 計測 累積面積 10.794% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
帯が中心を通る瞬間にガラスの中のコアが一度だけ明るくなるところまで含めて、光がガラスを透過する感覚を作ります。
.glassbadge__sheen {
position: absolute;
top: -20%; left: 0;
width: 44%;
height: 140%;
transform: translateX(-140%) rotate(24deg);
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .4) 45%,
rgba(255, 255, 255, .55) 50%, rgba(255, 255, 255, .4) 55%, transparent);
animation: sheenSweep $duration $ease-out infinite;
}
@keyframes flareUp {
0%, 36% { opacity: 0; }
44%, 50% { opacity: 1; }
58%, 100% { opacity: 0; }
}
06木目の引き違いパネル
どんな動きか
木目のパネル2枚が左右に開いて内側の明るいスロットを現し、しばらく開いたままになってからまた閉じます。目はパネル自身に塗ってあるので、パネルが動けば目も一緒に流れます。

<main class="stage">
<section class="wooddoors" aria-label="木目の引き違いパネル">
<div class="wooddoors__frame">
<div class="wooddoors__slot">
<p class="wooddoors__slot-title">2番目の物語</p>
<p class="wooddoors__slot-desc">木を開けると明るい中身</p>
</div>
<div class="wooddoors__leaf wooddoors__leaf--l" aria-hidden="true">
<span class="wooddoors__knot"></span>
<span class="wooddoors__handle"></span>
</div>
<div class="wooddoors__leaf wooddoors__leaf--r" aria-hidden="true">
<span class="wooddoors__knot wooddoors__knot--low"></span>
<span class="wooddoors__handle"></span>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 나무 — 세로 결 repeating-linear-gradient + 매듭 radial-gradient. 사진 없이 결이 흐른다
$wood-a: #b57e46; // 결 밝은 톤
$wood-b: #a96f38; // 결 중간
$wood-c: #c08a4e; // 결 가장 밝게
$wood-d: #96622e; // 결 어두운 톤
$wood-lo: #906a47; // 패널 아래 그늘
$brass: #ddd3ba; // 놋쇠 손잡이
$duration: $dur-loop; // 열리고 잠시 머물고 닫히는 한 주기
$easing: $ease-spring; // 미닫이는 끝에서 살짝 튕긴다
$color: $wood-a;
@include stage($stage-yellow);
.wooddoors {
display: grid;
place-items: center;
}
// 틀 — 안쪽 내용을 감싸고 두 짝이 틀 밖으로 나가면 잘린다 (overflow)
.wooddoors__frame {
position: relative;
width: clamp(212px, 66vw, 300px);
aspect-ratio: 16 / 10;
border-radius: $r-card;
overflow: hidden;
background: #2d1c0d;
box-shadow: 0 10px 16px rgba(23, 20, 26, .35), inset 0 0 0 4px #241608;
}
// 틀 안 밝은 슬롯 — 나무가 열려야 보이는 내용
.wooddoors__slot {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
}
.wooddoors__slot-title {
margin: 0;
color: #574a38;
font-weight: 800; font-size: clamp(13px, 3.4vw, 17px); line-height: 1.2;
font-family: inherit;
}
.wooddoors__slot-desc {
margin: 0;
color: #7a6c56;
font-weight: 500; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.3;
font-family: inherit;
}
// 나무 짝 — 세로 결 줄무늬가 짝과 함께 움직인다 (gradient 가 배경이라 결이 "흐르는" 게 아니라 짝이 통째로 이동)
.wooddoors__leaf {
position: absolute;
top: 0; bottom: 0;
width: 50.5%; // 가운데 살짝 겹친다 — 닫았을 때 틈이 안 보인다
background:
radial-gradient(ellipse 14px 22px at 62% 30%, rgba(74, 44, 12, .55) 0 38%, rgba($wood-c, .5) 42% 52%, transparent 60%),
repeating-linear-gradient(90deg,
$wood-a 0 12px, $wood-b 12px 15px, $wood-c 15px 27px, $wood-d 27px 30px, $wood-a 30px 38px, $wood-lo 38px 41px);
box-shadow: inset 0 2px 0 rgba(255, 230, 190, .3), inset 0 -4px 0 rgba(45, 25, 6, .55);
animation: doorsOpenL $duration $easing infinite;
}
.wooddoors__leaf--l { left: 0; }
.wooddoors__leaf--r {
right: 0;
animation-name: doorsOpenR;
}
// 매듭(나이테)과 놋쇠 손잡이
.wooddoors__knot {
position: absolute;
top: 22%; left: 16%;
width: 28px;
height: 44px;
border-radius: 50%;
background: repeating-radial-gradient(ellipse at 50% 50%, rgba(74, 44, 12, .5) 0 2px, rgba($wood-c, .4) 2px 5px);
}
.wooddoors__knot--low { top: 48%; left: auto; right: 20%; }
.wooddoors__handle {
position: absolute;
top: calc(50% - 8px);
width: 8px;
height: 16px;
border-radius: $r-pill;
background: linear-gradient(90deg, #c9a24e, $brass 55%, #b09760);
box-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
.wooddoors__leaf--l .wooddoors__handle { right: 12px; }
.wooddoors__leaf--r .wooddoors__handle { left: 12px; }
// 두 짝이 90% 넘게 열렸다 닫힌다 — 결 줄무늬가 짝을 타고 이동한다. 텔레포트 없다
@keyframes doorsOpenL {
0%, 10% { transform: translateX(0); }
42%, 60% { transform: translateX(-92%); }
88%, 100% { transform: translateX(0); }
}
@keyframes doorsOpenR {
0%, 10% { transform: translateX(0); }
42%, 60% { transform: translateX(92%); }
88%, 100% { transform: translateX(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.wooddoors__slot { padding: $sp-3; }
.wooddoors__knot { width: 20px; height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.wooddoors__leaf { animation: none !important; transform: translateX(-92%); }
.wooddoors__leaf--r { transform: translateX(92%); }
}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);
}
.wooddoors {
display: grid;
place-items: center;
}
.wooddoors__frame {
position: relative;
width: clamp(212px, 66vw, 300px);
aspect-ratio: 16/10;
border-radius: 16px;
overflow: hidden;
background: #2d1c0d;
box-shadow: 0 10px 16px rgba(23, 20, 26, 0.35), inset 0 0 0 4px #241608;
}
.wooddoors__slot {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 16px;
border-radius: 16px;
background: #fff7e6;
}
.wooddoors__slot-title {
margin: 0;
color: #574a38;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 17px);
line-height: 1.2;
font-family: inherit;
}
.wooddoors__slot-desc {
margin: 0;
color: #7a6c56;
font-weight: 500;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.3;
font-family: inherit;
}
.wooddoors__leaf {
position: absolute;
top: 0;
bottom: 0;
width: 50.5%;
background: radial-gradient(ellipse 14px 22px at 62% 30%, rgba(74, 44, 12, 0.55) 0 38%, rgba(192, 138, 78, 0.5) 42% 52%, transparent 60%), repeating-linear-gradient(90deg, #b57e46 0 12px, #a96f38 12px 15px, #c08a4e 15px 27px, #96622e 27px 30px, #b57e46 30px 38px, #906a47 38px 41px);
box-shadow: inset 0 2px 0 rgba(255, 230, 190, 0.3), inset 0 -4px 0 rgba(45, 25, 6, 0.55);
animation: doorsOpenL 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wooddoors__leaf--l {
left: 0;
}
.wooddoors__leaf--r {
right: 0;
animation-name: doorsOpenR;
}
.wooddoors__knot {
position: absolute;
top: 22%;
left: 16%;
width: 28px;
height: 44px;
border-radius: 50%;
background: repeating-radial-gradient(ellipse at 50% 50%, rgba(74, 44, 12, 0.5) 0 2px, rgba(192, 138, 78, 0.4) 2px 5px);
}
.wooddoors__knot--low {
top: 48%;
left: auto;
right: 20%;
}
.wooddoors__handle {
position: absolute;
top: calc(50% - 8px);
width: 8px;
height: 16px;
border-radius: 999px;
background: linear-gradient(90deg, #c9a24e, #ddd3ba 55%, #b09760);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.wooddoors__leaf--l .wooddoors__handle {
right: 12px;
}
.wooddoors__leaf--r .wooddoors__handle {
left: 12px;
}
@keyframes doorsOpenL {
0%, 10% {
transform: translateX(0);
}
42%, 60% {
transform: translateX(-92%);
}
88%, 100% {
transform: translateX(0);
}
}
@keyframes doorsOpenR {
0%, 10% {
transform: translateX(0);
}
42%, 60% {
transform: translateX(92%);
}
88%, 100% {
transform: translateX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.wooddoors__slot {
padding: 12px;
}
.wooddoors__knot {
width: 20px;
height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.wooddoors__leaf {
animation: none !important;
transform: translateX(-92%);
}
.wooddoors__leaf--r {
transform: translateX(92%);
}
}import type { CSSProperties } from "react";
import styles from "./WoodPanelDoors.module.scss";
export type WoodPanelDoorsProps = {
title?: string;
desc?: string;
/** 스크린리더용 섹션 이름 (기본 "나무 결 미닫이 패널") */
ariaLabel?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WoodPanelDoors({
title = "두 번째 이야기",
desc = "나무를 열면 밝은 내용",
ariaLabel = "나무 결 미닫이 패널",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b57e46",
}: WoodPanelDoorsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.wooddoors} style={vars} aria-label={ariaLabel}>
<div className={styles.frame}>
<div className={styles.slot}>
<p className={styles.slotTitle}>{title}</p>
<p className={styles.slotDesc}>{desc}</p>
</div>
<div className={`${styles.leaf} ${styles.leafL}`} aria-hidden="true">
<span className={styles.knot} />
<span className={styles.handle} />
</div>
<div className={`${styles.leaf} ${styles.leafR}`} aria-hidden="true">
<span className={`${styles.knot} ${styles.knotLow}`} />
<span className={styles.handle} />
</div>
</div>
</section>
);
}06 · 木目の引き違いパネル — 計測 累積面積 38.896% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
木の写真と同じ効果を、重さなしで得られます。
.wooddoors__leaf {
background:
radial-gradient(ellipse 14px 22px at 62% 30%, rgba(74, 44, 12, .55) 0 38%,
rgba($wood-c, .5) 42% 52%, transparent 60%),
repeating-linear-gradient(90deg,
$wood-a 0 12px, $wood-b 12px 15px, $wood-c 15px 27px, $wood-d 27px 30px,
$wood-a 30px 38px, $wood-lo 38px 41px);
}
@keyframes doorsOpenL {
0%, 10% { transform: translateX(0); }
42%, 60% { transform: translateX(-92%); }
88%, 100% { transform: translateX(0); }
}
07ステッカーエンボスロゴ
何が見えるか
エンボスロゴのステッカーが24px上から傾いたまま降りて、マイナス3度の位置に収まります。彫刻はtext-shadow4層です。

<main class="stage">
<section class="emboss">
<div class="emboss__shadow" aria-hidden="true"></div>
<div class="emboss__sticker">
<p class="emboss__brand">STUDIO</p>
<p class="emboss__sub">QUALITY PRESS</p>
</div>
<p class="emboss__caption">4枚の影で彫刻を作る</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 엠보싱 — text-shadow 네 겹(아래 밝게·위 어둡게·좌우 보정)으로 글자를 파낸다.
// 로고 글자색은 스티커 바탕과 거의 같게 — 명암은 그림자가 만든다 (진짜 음각의 원리)
$sticker-face: #f4ead7; // 스티커 면
$sticker-rim: #ffffff; // 흰 테 (오려낸 자국)
$emboss-ink: #e2d4b8; // 로고 본체 — 면과 아주 가깝게
$emboss-dark: rgba(96, 74, 48, .62); // 위로 진 그늘
$emboss-light: rgba(255, 255, 255, .95); // 아래로 받은 빛
$duration: $dur-loop; // 스티커가 내려와 붙고 다시 뜨는 한 주기
$easing: $ease-pop; // 손으로 누르는 탄성
$color: $sticker-face;
@include stage($stage-orange);
.emboss {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
// 바닥 그림자 — 스티커가 높이 떠 있을 때 크고 연하다
.emboss__shadow {
width: clamp(160px, 50vw, 216px);
height: 16px;
border-radius: 50%;
background: radial-gradient(50% 50% at 50% 50%, rgba(23, 20, 26, .5), transparent 70%);
animation: shadowShrink $duration $easing infinite;
}
// 스티커 — 흰 테를 두르고 살짝 기울여 붙인다
.emboss__sticker {
box-sizing: border-box;
width: clamp(176px, 54vw, 236px);
padding: $sp-4 $sp-5;
border: 6px solid $sticker-rim;
border-radius: $r-card;
background:
radial-gradient(130% 130% at 30% 22%, rgba(255, 255, 255, .55), transparent 46%),
$sticker-face;
box-shadow: 0 4px 8px rgba(23, 20, 26, .28);
text-align: center;
animation: stickerApply $duration $easing infinite;
}
// 음각 로고 — 네 겹 text-shadow. 아래는 빛, 위는 그늘 → 파인 글자로 읽힌다
.emboss__brand {
margin: 0;
color: $emboss-ink;
font-weight: 900; font-size: clamp(24px, 7vw, 38px); line-height: 1.05;
font-family: inherit;
letter-spacing: .12em;
text-shadow:
0 1px 0 $emboss-light,
0 -1px 0 $emboss-dark,
1px 0 0 rgba(255, 255, 255, .55),
-1px 0 0 rgba(96, 74, 48, .35);
}
.emboss__sub {
margin: $sp-1 0 0;
color: $emboss-ink;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
letter-spacing: .34em;
text-shadow:
0 1px 0 $emboss-light,
0 -1px 0 $emboss-dark;
}
.emboss__caption {
margin: 0;
color: $subject-ink;
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 24px 위·기울어짐·1.12배에서 내려와 −3도로 착지 → 살짝 들렸다 정착 → 다시 뜬다
@keyframes stickerApply {
0% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
40% { transform: translateY(0) rotate(-3deg) scale(1); }
50% { transform: translateY(-2px) rotate(-3.6deg) scale(1.01); }
60%, 88% { transform: translateY(0) rotate(-3deg) scale(1); }
100% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
}
// 그림자는 착지 순간 작고 진해진다
@keyframes shadowShrink {
0% { transform: scaleX(1.4); opacity: .55; }
40%, 88% { transform: scaleX(.9); opacity: .9; }
100% { transform: scaleX(1.4); opacity: .55; }
}
@media (max-width: 340px), (max-height: 220px) {
.emboss__sticker { padding: $sp-3 $sp-4; border-width: 4px; }
.emboss__caption { display: none; }
.emboss { gap: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.emboss__sticker { animation: none !important; transform: rotate(-3deg); }
.emboss__shadow { animation: none !important; transform: scaleX(.9); opacity: .9; }
}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);
}
.emboss {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.emboss__shadow {
width: clamp(160px, 50vw, 216px);
height: 16px;
border-radius: 50%;
background: radial-gradient(50% 50% at 50% 50%, rgba(23, 20, 26, 0.5), transparent 70%);
animation: shadowShrink 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.emboss__sticker {
box-sizing: border-box;
width: clamp(176px, 54vw, 236px);
padding: 16px 20px;
border: 6px solid #ffffff;
border-radius: 16px;
background: radial-gradient(130% 130% at 30% 22%, rgba(255, 255, 255, 0.55), transparent 46%), #f4ead7;
box-shadow: 0 4px 8px rgba(23, 20, 26, 0.28);
text-align: center;
animation: stickerApply 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.emboss__brand {
margin: 0;
color: #e2d4b8;
font-weight: 900;
font-size: clamp(24px, 7vw, 38px);
line-height: 1.05;
font-family: inherit;
letter-spacing: 0.12em;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.95), 0 -1px 0 rgba(96, 74, 48, 0.62), 1px 0 0 rgba(255, 255, 255, 0.55), -1px 0 0 rgba(96, 74, 48, 0.35);
}
.emboss__sub {
margin: 4px 0 0;
color: #e2d4b8;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.34em;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.95), 0 -1px 0 rgba(96, 74, 48, 0.62);
}
.emboss__caption {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes stickerApply {
0% {
transform: translateY(-24px) rotate(-8deg) scale(1.12);
}
40% {
transform: translateY(0) rotate(-3deg) scale(1);
}
50% {
transform: translateY(-2px) rotate(-3.6deg) scale(1.01);
}
60%, 88% {
transform: translateY(0) rotate(-3deg) scale(1);
}
100% {
transform: translateY(-24px) rotate(-8deg) scale(1.12);
}
}
@keyframes shadowShrink {
0% {
transform: scaleX(1.4);
opacity: 0.55;
}
40%, 88% {
transform: scaleX(0.9);
opacity: 0.9;
}
100% {
transform: scaleX(1.4);
opacity: 0.55;
}
}
@media (max-width: 340px), (max-height: 220px) {
.emboss__sticker {
padding: 12px 16px;
border-width: 4px;
}
.emboss__caption {
display: none;
}
.emboss {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.emboss__sticker {
animation: none !important;
transform: rotate(-3deg);
}
.emboss__shadow {
animation: none !important;
transform: scaleX(0.9);
opacity: 0.9;
}
}import type { CSSProperties } from "react";
import styles from "./StickerEmbossLogo.module.scss";
export type StickerEmbossLogoProps = {
brand?: string;
sub?: string;
caption?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StickerEmbossLogo({
brand = "STUDIO",
sub = "QUALITY PRESS",
caption = "네 겹 그림자로 음각을 낸다",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#f4ead7",
}: StickerEmbossLogoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.emboss} style={vars}>
<div className={styles.shadow} aria-hidden="true" />
<div className={styles.sticker}>
<p className={styles.brand}>{brand}</p>
<p className={styles.sub}>{sub}</p>
</div>
<p className={styles.caption}>{caption}</p>
</section>
);
}07 · ステッカーエンボスロゴ — 計測 累積面積 20.042% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
下に明るい光1px、上に暗い影1px、左右の補正2px — この4つが揃って初めて、印刷ではなく彫った文字に見えます。
.emboss__brand {
color: $emboss-ink;
text-shadow:
0 1px 0 $emboss-light,
0 -1px 0 $emboss-dark,
1px 0 0 rgba(255, 255, 255, .55),
-1px 0 0 rgba(96, 74, 48, .35);
}
@keyframes stickerApply {
0% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
40% { transform: translateY(0) rotate(-3deg) scale(1); }
50% { transform: translateY(-2px) rotate(-3.6deg) scale(1.01); }
60%, 88% { transform: translateY(0) rotate(-3deg) scale(1); }
100% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
}
08物理的に沈むボタン
画面で起きること
キーキャップが8px沈む間、下の側壁要素がscaleYで4分の1に縮みます。

<main class="stage">
<section class="physkey">
<div class="physkey__well">
<span class="physkey__wall" aria-hidden="true"></span>
<button class="physkey__cap" type="button">
<span class="physkey__label">始める</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
</div>
<p class="physkey__caption">8px沈み、側壁が縮む</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 키 — 윗면(cap)·옆벽(wall)·홈(well) 셋으로 물리적 눌림을 만든다.
// 벽은 box-shadow 가 아니라 별도 요소: scaleY 로 줄어드는 것도 transform 이다
$key-face-1: #b5c0da; // 윗면 밝은 모서리
$key-face-2: $subject-blue; // 윗면 (토큰)
$key-face-3: #5d74af; // 윗면 어두운 모서리
$wall: #5067a2; // 옆벽
$wall-deep: #475b90; // 옆벽 아래
$well: #d7dbe4; // 키가 빠져나가는 홈
$duration: $dur-loop; // 눌림 → 유지 → 복귀 한 주기
$easing: $ease-spring; // 놓으면 스프링처럼 튀어 오른다
$color: $key-face-2;
@include stage($stage-paper);
.physkey {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
// 홈 — 키가 앉는 구멍. 윗면보다 넓다
.physkey__well {
position: relative;
box-sizing: border-box;
width: clamp(180px, 56vw, 236px);
padding: 8px;
border-radius: $r-control;
background: linear-gradient(180deg, #c3c8d4, $well 30%, #e3e6ec);
box-shadow: inset 0 2px 5px rgba(23, 20, 26, .3);
}
// 옆벽 — cap 아래의 두께. 눌리면 scaleY 로 4분의 1이 된다 (transform-origin top)
.physkey__wall {
position: absolute;
left: 8px; right: 8px;
top: 8px;
height: 8px;
border-radius: 0 0 $r-control $r-control;
background: linear-gradient(180deg, $wall, $wall-deep);
transform-origin: 50% 0; // 규칙8: 옆벽 윗변 붙어 압축
animation: wallPress $duration $easing infinite;
}
// 윗면 — 8px 내려앉는다. 라벨과 아이콘이 같이 내려간다
.physkey__cap {
position: relative;
z-index: 1;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-2;
width: 100%;
padding: $sp-3 $sp-4;
border: 0;
border-radius: $r-control;
background: linear-gradient(180deg, $key-face-1 0%, $key-face-2 28%, $key-face-3 100%);
color: #ffffff;
font-weight: 800; font-size: clamp(13px, 3.2vw, 16px); line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 255, 255, .4), inset 0 -2px 0 rgba(0, 0, 0, .25);
cursor: pointer;
animation: capPress $duration $easing infinite;
}
.physkey__cap:focus-visible {
outline: 3px solid #5d74af;
outline-offset: 3px;
}
.physkey__label {
font-family: inherit;
}
.physkey__cap svg {
width: 1.1em;
height: 1.1em;
}
.physkey__caption {
margin: 0;
color: #5c5048;
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 눌림: cap 8px 하강 ↔ 벽 scaleY .25. 놓으면 한 번 튀어 오른 뒤 정지 — 벽도 살짝 늘어난다
@keyframes capPress {
0%, 12% { transform: translateY(0); }
32%, 46% { transform: translateY(8px); }
62% { transform: translateY(-3px); }
76%, 100% { transform: translateY(0); }
}
@keyframes wallPress {
0%, 12% { transform: scaleY(1); }
32%, 46% { transform: scaleY(.25); }
62% { transform: scaleY(1.1); }
76%, 100% { transform: scaleY(1); }
}
@media (max-width: 340px), (max-height: 220px) {
.physkey__well { padding: 4px; width: 188px; }
.physkey__wall { left: 4px; right: 4px; top: 4px; height: 8px; }
.physkey__caption { display: none; }
.physkey { gap: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.physkey__cap { animation: none !important; }
.physkey__wall { animation: none !important; }
}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);
}
.physkey {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.physkey__well {
position: relative;
box-sizing: border-box;
width: clamp(180px, 56vw, 236px);
padding: 8px;
border-radius: 12px;
background: linear-gradient(180deg, #c3c8d4, #d7dbe4 30%, #e3e6ec);
box-shadow: inset 0 2px 5px rgba(23, 20, 26, 0.3);
}
.physkey__wall {
position: absolute;
left: 8px;
right: 8px;
top: 8px;
height: 8px;
border-radius: 0 0 12px 12px;
background: linear-gradient(180deg, #5067a2, #475b90);
transform-origin: 50% 0;
animation: wallPress 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.physkey__cap {
position: relative;
z-index: 1;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
border: 0;
border-radius: 12px;
background: linear-gradient(180deg, #b5c0da 0%, #2f6df6 28%, #5d74af 100%);
color: #ffffff;
font-weight: 800;
font-size: clamp(13px, 3.2vw, 16px);
line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.4), inset 0 -2px 0 rgba(0, 0, 0, 0.25);
cursor: pointer;
animation: capPress 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.physkey__cap:focus-visible {
outline: 3px solid #5d74af;
outline-offset: 3px;
}
.physkey__label {
font-family: inherit;
}
.physkey__cap svg {
width: 1.1em;
height: 1.1em;
}
.physkey__caption {
margin: 0;
color: #5c5048;
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes capPress {
0%, 12% {
transform: translateY(0);
}
32%, 46% {
transform: translateY(8px);
}
62% {
transform: translateY(-3px);
}
76%, 100% {
transform: translateY(0);
}
}
@keyframes wallPress {
0%, 12% {
transform: scaleY(1);
}
32%, 46% {
transform: scaleY(0.25);
}
62% {
transform: scaleY(1.1);
}
76%, 100% {
transform: scaleY(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.physkey__well {
padding: 4px;
width: 188px;
}
.physkey__wall {
left: 4px;
right: 4px;
top: 4px;
height: 8px;
}
.physkey__caption {
display: none;
}
.physkey {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.physkey__cap {
animation: none !important;
}
.physkey__wall {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./PressableKey.module.scss";
export type PressableKeyProps = {
label?: string;
caption?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressableKey({
label = "시작하기",
caption = "8px 내려앉고 벽이 줄어든다",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PressableKeyProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.physkey} style={vars}>
<div className={styles.well}>
<span className={styles.wall} aria-hidden="true" />
<button type="button" className={styles.cap}>
<span className={styles.label}>{label}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
</div>
<p className={styles.caption}>{caption}</p>
</section>
);
}08 · 物理的に沈むボタン — 計測 累積面積 7.858% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
側壁をbox-shadowで描かず独立した要素にしたのは、押し込みの動きを影ではなく変換で処理するためです。
.physkey__wall {
height: 8px;
transform-origin: 50% 0;
animation: wallPress $duration $ease-spring infinite;
}
@keyframes capPress {
0%, 12% { transform: translateY(0); }
32%, 46% { transform: translateY(8px); }
62% { transform: translateY(-3px); }
76%, 100% { transform: translateY(0); }
}
@keyframes wallPress {
0%, 12% { transform: scaleY(1); }
32%, 46% { transform: scaleY(.25); }
62% { transform: scaleY(1.1); }
76%, 100% { transform: scaleY(1); }
}
09質感3か所だけのLP
どんな動きか
ミニLP全体が上下に行ったり来たりしながら各セクションを通ります。質感は木目ナビ・メタルダイヤル・レザー料金カードの3か所だけで、残りは明るいフラットです。

<main class="stage">
<section class="minipage" aria-label="質感3か所だけのミニLP">
<div class="minipage__frame">
<div class="minipage__track">
<header class="minipage__nav">
<span class="minipage__navdot"></span>
<span class="minipage__navlink"></span>
<span class="minipage__navlink"></span>
</header>
<section class="minipage__hero">
<p class="minipage__hero-eyebrow">質感ランディング</p>
<span class="minipage__hero-bar"></span>
<span class="minipage__hero-bar minipage__hero-bar--short"></span>
<span class="minipage__hero-btn">始める</span>
</section>
<section class="minipage__features">
<article class="minipage__fcard"><span class="minipage__fdot"></span><span class="minipage__fline"></span></article>
<article class="minipage__fcard"><span class="minipage__fdot minipage__fdot--b"></span><span class="minipage__fline"></span></article>
<article class="minipage__fcard"><span class="minipage__fdot minipage__fdot--c"></span><span class="minipage__fline"></span></article>
</section>
<section class="minipage__cta">
<span class="minipage__dial" aria-hidden="true"><span class="minipage__dial-cap"></span></span>
<span class="minipage__cta-btn">無料で始める</span>
</section>
<section class="minipage__pricing">
<p class="minipage__plan">Proメンバーシップ</p>
<p class="minipage__price">月額₩24,000</p>
<span class="minipage__stitch" aria-hidden="true"></span>
</section>
<section class="minipage__quote">
<span class="minipage__avatar"></span>
<span class="minipage__qline"></span>
<span class="minipage__qline minipage__qline--short"></span>
</section>
<section class="minipage__faq">
<span class="minipage__qrow"></span>
<span class="minipage__qrow minipage__qrow--open">
<span class="minipage__qbody"></span>
</span>
<span class="minipage__qrow"></span>
</section>
<footer class="minipage__foot">
<span class="minipage__footdot"></span>
<span class="minipage__footdot"></span>
<span class="minipage__footdot"></span>
</footer>
</div>
</div>
<div class="minipage__notes">
<p class="minipage__note minipage__note--tex">質感3か所 — 木目ナビ・メタルダイヤル・レザーカード</p>
<p class="minipage__note">残りの区間は明るいフラットで対比</p>
<p class="minipage__note">スクロールが質感の場所を教える</p>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유형 F 마무리 부품 — 원페이지 미리보기. 질감은 세 곳(나무 내비·금속 다이얼·가죽 카드)에만,
// 나머지 구간은 밝은 평면(230 신뢰 톤 계열)으로 대비시킨다 — 9곳 전부 질감이면 촌스러워진다
$mp-flat: #f7f5f0; // 평면 구간 바탕
$mp-flat-2: #eceae3; // 평면 구간 보조
$mp-ink: #2a2732; // 평면 위 글자
$mp-line: #d8d4c9; // 평면 선·막대
$duration: $dur-loop; // 위 → 아래 → 위 한 번 스크롤
$easing: $ease-out; // 스크롤은 관성을 두고 감속
$color: $mp-flat;
@include stage($stage-ink);
.minipage {
display: flex;
align-items: center;
gap: $sp-4;
}
// 뷰포트 — 안에서 트랙이 스크롤된다
.minipage__frame {
position: relative;
width: clamp(184px, 58vw, 224px);
height: clamp(208px, 64vh, 252px);
border-radius: $r-card;
overflow: hidden;
box-shadow: 0 12px 20px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .08);
}
// 트랙 — 높이가 뷰포트의 정확히 2배. translateY(-50%) 가 한 화면만큼 = 아래 절반
.minipage__track {
display: flex;
flex-direction: column;
height: 200%;
animation: pageScroll $duration $easing infinite;
}
// ① 나무 내비 (질감 1)
.minipage__nav {
display: flex;
align-items: center;
gap: $sp-2;
box-sizing: border-box;
height: 12.5%;
padding: 0 $sp-3;
background: repeating-linear-gradient(90deg, #b57e46 0 10px, #a96f38 10px 13px, #c08a4e 13px 24px, #96622e 24px 27px);
border-bottom: 2px solid rgba(45, 25, 6, .55);
}
.minipage__navdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2d1c0d;
}
.minipage__navlink {
width: 26px;
height: 4px;
border-radius: $r-pill;
background: rgba(45, 25, 6, .5);
}
// ② 히어로 — 평면. 질감 없음
.minipage__hero {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-2;
height: 17%;
padding: $sp-3 $sp-4 0;
background: $mp-flat;
}
.minipage__hero-eyebrow {
margin: 0;
color: $mp-ink;
font-weight: 800; font-size: clamp(13px, 3.4vw, 16px); line-height: 1;
font-family: inherit;
}
.minipage__hero-bar {
width: 76%;
height: 7px;
border-radius: $r-pill;
background: $mp-line;
}
.minipage__hero-bar--short { width: 48%; }
.minipage__hero-btn {
margin-top: $sp-1;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $subject-blue;
color: #ffffff;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
}
// ③ 기능 카드 — 종이 결 (미세 repeating-gradient)
.minipage__features {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
height: 14.5%;
padding: $sp-2 $sp-4;
background: $mp-flat;
}
.minipage__fcard {
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-sm;
background:
repeating-linear-gradient(0deg, rgba(122, 105, 78, .07) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgba(122, 105, 78, .05) 0 1px, transparent 1px 4px),
$subject-cream;
box-shadow: 0 2px 4px rgba(23, 20, 26, .22);
}
.minipage__fdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: $subject-blue;
}
.minipage__fdot--b { background: $subject-mint; }
.minipage__fdot--c { background: $subject-pink; }
.minipage__fline {
width: 80%;
height: 5px;
border-radius: $r-pill;
background: rgba(87, 74, 56, .22);
}
// ④ 다이얼 CTA (질감 2) — 평면 띠 위에 금속 다이얼
.minipage__cta {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
height: 14.5%;
padding: $sp-2 $sp-4;
background: $mp-flat-2;
}
.minipage__dial {
position: relative;
width: clamp(40px, 12vw, 48px);
aspect-ratio: 1;
border-radius: 50%;
background:
conic-gradient(from 210deg, #f4f5f8 0deg, #6f7481 70deg, #aab0bc 120deg, #f4f5f8 180deg, #4b4f59 250deg, #aab0bc 300deg, #f4f5f8 360deg);
box-shadow: 0 3px 5px rgba(23, 20, 26, .4), inset 0 1px 1px rgba(255, 255, 255, .6);
flex: 0 0 auto;
}
.minipage__dial::after {
content: "";
position: absolute;
top: 30%; left: 30%; right: 30%; bottom: 30%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, $subject-blue 55%, #5d74af);
}
.minipage__cta-btn {
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $subject-ink;
color: #ffffff;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
}
// ⑤ 가죽 요금 카드 (질감 3)
.minipage__pricing {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-1;
height: 14.5%;
padding: $sp-2 $sp-4;
background:
repeating-linear-gradient(34deg, rgba(94, 53, 23, .4) 0 2px, transparent 2px 5px),
#7a4a2b;
}
.minipage__plan {
margin: 0;
color: #f6e7cf;
font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); line-height: 1;
font-family: inherit;
letter-spacing: .12em;
}
.minipage__price {
margin: 0;
color: #ffffff;
font-weight: 800; font-size: clamp(12px, 3.2vw, 15px); line-height: 1;
font-family: inherit;
}
.minipage__stitch {
position: absolute;
top: 4px; left: 8px; right: 8px; bottom: 4px;
border-radius: $r-sm;
background:
repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) top / 100% 2px no-repeat,
repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) bottom / 100% 2px no-repeat,
repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) left / 2px 100% no-repeat,
repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) right / 2px 100% no-repeat;
}
// ⑥ 후기 — 평면
.minipage__quote {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-1;
height: 11.5%;
padding: $sp-2 $sp-4;
background: $mp-flat;
}
.minipage__avatar {
order: -1;
width: 14px;
height: 14px;
border-radius: 50%;
background: $subject-mint;
}
.minipage__qline {
width: 88%;
height: 5px;
border-radius: $r-pill;
background: $mp-line;
}
.minipage__qline--short { width: 60%; }
// ⑦ FAQ — 평면 아코디언 (하나 열린 모습)
.minipage__faq {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
height: 10%;
padding: $sp-2 $sp-4;
background: $mp-flat-2;
}
.minipage__qrow {
display: flex;
align-items: center;
box-sizing: border-box;
height: 14px;
border-radius: $r-xs;
background: #ffffff;
box-shadow: 0 1px 2px rgba(23, 20, 26, .16);
padding: 0 $sp-2;
}
.minipage__qrow::before {
content: "";
width: 44%;
height: 4px;
border-radius: $r-pill;
background: $mp-line;
}
.minipage__qrow--open {
height: 26px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: $sp-1;
border: 2px solid $subject-blue;
}
.minipage__qbody {
width: 78%;
height: 4px;
border-radius: $r-pill;
background: $mp-line;
}
// ⑧ 푸터
.minipage__foot {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-1;
height: 5.5%;
padding: 0 $sp-4;
background: $subject-ink;
}
.minipage__footdot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, .4);
}
// 옆 주석 — 480px 에서만. 320px 에서는 프레임이 전부
.minipage__notes {
display: flex;
flex-direction: column;
gap: $sp-2;
max-width: 152px;
}
.minipage__note {
margin: 0;
color: rgba(255, 255, 255, .85);
font-weight: 500; font-size: 11px; line-height: 1.5;
font-family: inherit;
}
.minipage__note--tex {
color: $stage-yellow;
font-weight: 700;
}
// 핑퐁 스크롤 — 트랙이 정확히 200% 라 -50% 가 한 화면. 텔레포트 없이 위로 돌아온다
@keyframes pageScroll {
0%, 10% { transform: translateY(0); }
44%, 54% { transform: translateY(-50%); }
88%, 100% { transform: translateY(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.minipage__notes { display: none; }
.minipage__frame { width: 188px; height: 164px; }
.minipage__hero { padding-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.minipage__track { animation: none !important; transform: 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);
}
.minipage {
display: flex;
align-items: center;
gap: 16px;
}
.minipage__frame {
position: relative;
width: clamp(184px, 58vw, 224px);
height: clamp(208px, 64vh, 252px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 20px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.minipage__track {
display: flex;
flex-direction: column;
height: 200%;
animation: pageScroll 2s ease-out infinite;
}
.minipage__nav {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
height: 12.5%;
padding: 0 12px;
background: repeating-linear-gradient(90deg, #b57e46 0 10px, #a96f38 10px 13px, #c08a4e 13px 24px, #96622e 24px 27px);
border-bottom: 2px solid rgba(45, 25, 6, 0.55);
}
.minipage__navdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2d1c0d;
}
.minipage__navlink {
width: 26px;
height: 4px;
border-radius: 999px;
background: rgba(45, 25, 6, 0.5);
}
.minipage__hero {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
height: 17%;
padding: 12px 16px 0;
background: #f7f5f0;
}
.minipage__hero-eyebrow {
margin: 0;
color: #2a2732;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1;
font-family: inherit;
}
.minipage__hero-bar {
width: 76%;
height: 7px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__hero-bar--short {
width: 48%;
}
.minipage__hero-btn {
margin-top: 4px;
border-radius: 999px;
padding: 4px 12px;
background: #2f6df6;
color: #ffffff;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
}
.minipage__features {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
height: 14.5%;
padding: 8px 16px;
background: #f7f5f0;
}
.minipage__fcard {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: 8px;
background: repeating-linear-gradient(0deg, rgba(122, 105, 78, 0.07) 0 1px, transparent 1px 3px), repeating-linear-gradient(90deg, rgba(122, 105, 78, 0.05) 0 1px, transparent 1px 4px), #fff7e6;
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.22);
}
.minipage__fdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2f6df6;
}
.minipage__fdot--b {
background: #4cd4a6;
}
.minipage__fdot--c {
background: #ff9ebb;
}
.minipage__fline {
width: 80%;
height: 5px;
border-radius: 999px;
background: rgba(87, 74, 56, 0.22);
}
.minipage__cta {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
height: 14.5%;
padding: 8px 16px;
background: #eceae3;
}
.minipage__dial {
position: relative;
width: clamp(40px, 12vw, 48px);
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(from 210deg, #f4f5f8 0deg, #6f7481 70deg, #aab0bc 120deg, #f4f5f8 180deg, #4b4f59 250deg, #aab0bc 300deg, #f4f5f8 360deg);
box-shadow: 0 3px 5px rgba(23, 20, 26, 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.6);
flex: 0 0 auto;
}
.minipage__dial::after {
content: "";
position: absolute;
top: 30%;
left: 30%;
right: 30%;
bottom: 30%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, #2f6df6 55%, #5d74af);
}
.minipage__cta-btn {
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
color: #ffffff;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
}
.minipage__pricing {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
height: 14.5%;
padding: 8px 16px;
background: repeating-linear-gradient(34deg, rgba(94, 53, 23, 0.4) 0 2px, transparent 2px 5px), #7a4a2b;
}
.minipage__plan {
margin: 0;
color: #f6e7cf;
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
}
.minipage__price {
margin: 0;
color: #ffffff;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 15px);
line-height: 1;
font-family: inherit;
}
.minipage__stitch {
position: absolute;
top: 4px;
left: 8px;
right: 8px;
bottom: 4px;
border-radius: 8px;
background: repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) top/100% 2px no-repeat, repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) bottom/100% 2px no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) left/2px 100% no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) right/2px 100% no-repeat;
}
.minipage__quote {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
height: 11.5%;
padding: 8px 16px;
background: #f7f5f0;
}
.minipage__avatar {
order: -1;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4cd4a6;
}
.minipage__qline {
width: 88%;
height: 5px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__qline--short {
width: 60%;
}
.minipage__faq {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
height: 10%;
padding: 8px 16px;
background: #eceae3;
}
.minipage__qrow {
display: flex;
align-items: center;
box-sizing: border-box;
height: 14px;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(23, 20, 26, 0.16);
padding: 0 8px;
}
.minipage__qrow::before {
content: "";
width: 44%;
height: 4px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__qrow--open {
height: 26px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
border: 2px solid #2f6df6;
}
.minipage__qbody {
width: 78%;
height: 4px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__foot {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 4px;
height: 5.5%;
padding: 0 16px;
background: #17141a;
}
.minipage__footdot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
}
.minipage__notes {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 152px;
}
.minipage__note {
margin: 0;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
font-size: 11px;
line-height: 1.5;
font-family: inherit;
}
.minipage__note--tex {
color: #ffd23f;
font-weight: 700;
}
@keyframes pageScroll {
0%, 10% {
transform: translateY(0);
}
44%, 54% {
transform: translateY(-50%);
}
88%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.minipage__notes {
display: none;
}
.minipage__frame {
width: 188px;
height: 164px;
}
.minipage__hero {
padding-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.minipage__track {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./SkeuoLandingAssembly.module.scss";
export type SkeuoLandingAssemblyProps = {
eyebrow?: string;
heroBtn?: string;
ctaBtn?: string;
plan?: string;
price?: string;
noteTex?: string;
noteFlat?: string;
noteScroll?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SkeuoLandingAssembly({
eyebrow = "질감 랜딩",
heroBtn = "시작하기",
ctaBtn = "무료로 시작",
plan = "Pro 멤버십",
price = "월 24,000원",
noteTex = "질감 3곳 — 나무 내비 · 금속 다이얼 · 가죽 카드",
noteFlat = "나머지 구간은 밝은 평면으로 대비",
noteScroll = "스크롤이 질감의 자리를 알려준다",
duration = 2000,
easing = "ease-out",
color = "#f7f5f0",
}: SkeuoLandingAssemblyProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.minipage} style={vars} aria-label="질감 세 곳만 쓴 미니 랜딩">
<div className={styles.frame}>
<div className={styles.track}>
<header className={styles.nav}>
<span className={styles.navdot} />
<span className={styles.navlink} />
<span className={styles.navlink} />
</header>
<section className={styles.hero}>
<p className={styles.heroEyebrow}>{eyebrow}</p>
<span className={styles.heroBar} />
<span className={`${styles.heroBar} ${styles.heroBarShort}`} />
<span className={styles.heroBtn}>{heroBtn}</span>
</section>
<section className={styles.features}>
<article className={styles.fcard}><span className={styles.fdot} /><span className={styles.fline} /></article>
<article className={styles.fcard}><span className={`${styles.fdot} ${styles.fdotB}`} /><span className={styles.fline} /></article>
<article className={styles.fcard}><span className={`${styles.fdot} ${styles.fdotC}`} /><span className={styles.fline} /></article>
</section>
<section className={styles.cta}>
<span className={styles.dial} aria-hidden="true"><span /></span>
<span className={styles.ctaBtn}>{ctaBtn}</span>
</section>
<section className={styles.pricing}>
<p className={styles.plan}>{plan}</p>
<p className={styles.price}>{price}</p>
<span className={styles.stitch} aria-hidden="true" />
</section>
<section className={styles.quote}>
<span className={styles.avatar} />
<span className={styles.qline} />
<span className={`${styles.qline} ${styles.qlineShort}`} />
</section>
<section className={styles.faq}>
<span className={styles.qrow} />
<span className={`${styles.qrow} ${styles.qrowOpen}`}>
<span className={styles.qbody} />
</span>
<span className={styles.qrow} />
</section>
<footer className={styles.foot}>
<span className={styles.footdot} />
<span className={styles.footdot} />
<span className={styles.footdot} />
</footer>
</div>
</div>
<div className={styles.notes}>
<p className={`${styles.note} ${styles.noteTex}`}>{noteTex}</p>
<p className={styles.note}>{noteFlat}</p>
<p className={styles.note}>{noteScroll}</p>
</div>
</section>
);
}09 · 質感3か所だけのLP — 計測 累積面積 32.228% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
トラックをビューポート高さのちょうど2倍にしてtranslateY(-50%)が1画面分だけ動くようにするのが、このプレビューの骨組みです。
.minipage__track {
display: flex;
flex-direction: column;
height: 200%;
animation: pageScroll $duration $ease-out infinite;
}
@keyframes pageScroll {
0%, 10% { transform: translateY(0); }
44%, 54% { transform: translateY(-50%); }
88%, 100% { transform: translateY(0); }
}
tailwind.config カスタム
このスタイルで値を1か所にまとめるべき理由は色ではなく押し込みの厚みです。物理キーは静止時に側壁8px、底付きで2px — この2つの値がずれると、ボタンが浮いて見えたり奥に嵌まりすぎたりします。
theme: {
extend: {
colors: {
paper: "#fbf6ec", sheet: "#ffffff", ink: "#2a2320", stone: "#5c5048",
brand: "#2f6df6", branddeep: "#1c48b8", mint: "#2f9e74",
leather: "#7a4a2b", leatherdeep: "#5e3517", wood: "#a96f38",
},
borderRadius: { card: "16px", control: "12px", chip: "4px", pill: "999px" },
boxShadow: {
keyrest: "inset 0 2px 0 rgba(255,255,255,.4), inset 0 -2px 0 rgba(0,0,0,.25), 0 8px 0 #1a3fa0",
keydown: "inset 0 2px 0 rgba(255,255,255,.4), inset 0 -2px 0 rgba(0,0,0,.25), 0 2px 0 #1a3fa0",
},
},
},
| 役割 | トークン | いつ |
|---|---|---|
| キー側壁 | shadow-keyrest |
ボタンが静止しているとき(8px) |
| 押された壁 | shadow-keydown |
active: のときだけ(2px) |
| 質感面 | tex-leather など |
1ページ最大3か所 |
バイブコーディング プロンプト
設定を置いたあと、Claude CodeやCursorに渡す指示は次のひとかたまりで足ります。色のリストより質感をどこに置くかが先に来る順番が大事です。切り詰める前の原文全文は、zipの中のprompt.mdに収めてあります。
質感は正確に3か所だけに置く。ナビ(木目)・CTA(メタルダイヤル)・おすすめ料金プラン(革)。
質感のない面積は質感のある面積の2倍以上にする。木目は縦方向のrepeating-linear-gradient、
金属ダイヤルは4区切りのconic-gradient、革は2方向のrepeating重ねに上部ハイライト。
ステッチ目盛は4辺それぞれに8pxの糸/8pxの間隔のrepeating-linear-gradientを巻く。
ボタンは静止時の側壁8px、押下時2px — 壁が減った分だけtranslateYで沈む。
この指示どおりにpage/index.htmlを作り、1280pxのビューポートで全体を撮ったのが下の図です。

どこで壊れるか — 落とし穴
一番長く粘ったのは木の上の文字でした。ナビに白いブランド名を直接置いたところ、木目の帯が4色あるため帯ごとにコントラストが変わりました。相対輝度で直接計算すると、同じ白い文字が暗い帯(#96622e)では5.14:1、明るい帯(#c08a4e)では3.01:1まで落ちます。どの1色でも4帯すべてを4.5:1以上にできないので、文字は濃いインレイチップの上に移し、木目は装飾として残しました。質感の上の本文は、測ってから座らせるのが原則です。
2つ目は紙の移動距離でした。一番上の紙を高さの72パーセントだけ上げると、480pxでは問題ないのに320pxで紙の上端がステージからはみ出して切れました。移動距離を48pxのピクセル値に変え、狭い画面ではさらに20pxまで下げて、両方の幅で紙全体が見えるようにしました。幅が変わるときに比率ではなくピクセルで置き直すこの習慣は、メルマガのHTMLテンプレートでも同じように求められます。9つの完成ファイルは、圧縮パスワード 7dcfwy5m で開くzipにそのまま入っています。
アクセシビリティ
質感はコントラストを壊します。WCAG 2.1の最低コントラスト基準の相対輝度の式で、このキットの文字の組み合わせを全部直接計算して表に残しました。
| 文字の組み合わせ | 比率 | 判定 |
|---|---|---|
| 革上の本文 #f6e7cf / 革 #7a4a2b | 6.07:1 | 合格 |
| 革上の料金 #ffffff / 革 #7a4a2b | 7.39:1 | 合格 |
| 紙の本文 #574a38 / 紙 #fff7e6 | 8.07:1 | 合格 |
| 紙の補助 #7a6c56 / 紙 #fff7e6 | 4.80:1 | 合格(際どい) |
| ヒーローボタン #ffffff / ブランド色 #2f6df6 | 4.53:1 | 合格(際どい) |
| 明るい革の目の上の本文 #f6e7cf / #96613c | 4.24:1 | 不合格 — 明るい目の上に本文は置かない |
最後の行が、革カードの文字を暗い目の側にだけ座らせた理由です。ミントコアの上の白いアイコン案(1.86:1)も、濃い青緑のアイコン色に差し替えました。9つすべての動きにMDNのprefers-reduced-motionのメディアクエリを入れ、動作を減らす設定の訪問者には質感が座った静止状態だけを見せます。質感そのものはMDNのconic-gradientとMDNのtext-shadowの値なので、未対応のブラウザでも最悪でも単色の面に落ちるだけで、レイアウトは崩れません。
ほかのスタイルの流行はデザイントレンドのカテゴリーで、このサイトが何を配っているのかは紹介ページにまとめてあります。
FAQ
質感は何か所まで置いていいですか?
1ページに3か所までです。このキットでも木目ナビ・メタルダイヤル・レザー料金カードの3か所だけに質感を置き、残りは明るいフラットにしました。4か所を超えると目が休まる場所が消えて、2010年代の重い画面に戻ってしまいます。
スキューモーフィズムとは 2010年代のように画面全体を革で包むことですか?
カード1枚なら可能ですが、LP全体にはおすすめしません。背景が暗くなる分だけどこでもコントラストを測り直す必要があり、一番明るい木目帯の上では、4.5:1の本文はそもそも置けません。質感はポイントであるときに価値になり、背景になるとコストになります。
Reactプロジェクトにそのまま貼れますか?
zipには9つの部品のReact版(.tsx + .module.scss)が同梱されています。時間・イージング・代表色はpropsで受け取るので、貼ったあとは値を変えるだけで使えます。バニラ版とReact版は同じ測定値で一緒に検証してあります。質感パーツをファーストビューのどこに置くか迷っている段階なら、先にファーストビュー デザインのパターンを見ておくと配置が早く決まります。









