Skeuomorphism Design Kit: 9 Tactile Landing Parts
Skeuomorphism design brings the feel of real materials — leather, metal, wood, glass — onto the screen.
The early-2010s version of this style wrapped whole apps in stitched leather and linen, and the weight of it is exactly why flat design took over. What returned is narrower and smarter: a bright, flat page with real material in only three places. Scroll the one-pager below and try to find those three spots before reading on.

The nine parts that page is built from are pulled out one by one into the grid below. The order follows how materials earn their weight: leather gets sewn, metal gets machined, paper gets stacked, a knob gets turned by hand, glass gets set, wood gets opened, a sticker gets pressed on, a key gets pressed down — and the ninth part puts all of it on one page without overdoing it. Most skeuomorphism design examples still show the 2010s version, which is why the three-spot rule matters more than any single material. The same nine slots filled with layered scraps and color live in collage maximalist landing sections, and the dot-and-squiggle version lives in Memphis pattern landing sections. Set the three kits side by side and you can see how far texture, overlap, and shape each carry a page on their own.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Leather stitch pricing card
- 02 Metal dial CTA
- 03 Paper grain feature card
- 04 Volume knob widget
- 05 Glass inlay badge
- 06 Wood grain sliding panels
- 07 Sticker emboss logo
- 08 Physically pressable button
- 09 One page with three texture spots
01Leather stitch pricing card
What you see
The leather card lands from 20px above, and the instant it touches down, the pale stitch ticks appear inside the double border as a single-frame cut.

<main class="stage">
<section class="leather">
<div class="leather__card">
<p class="leather__plan">Pro membership</p>
<p class="leather__price">₩24,000 / month</p>
<ul class="leather__perks">
<li class="leather__perk">All 9 texture parts</li>
<li class="leather__perk">Commercial use included</li>
</ul>
<button class="leather__cta" type="button">Choose plan</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 · Leather stitch pricing card — Measured area 23.101% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The stitching is not an image: each of the four edges carries its own repeating-linear-gradient of thread marks.
.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; }
}
02Metal dial CTA
How a skeuomorphic ui design metal dial reads on screen
The dial swings 32 degrees each way, and because the whole disc rotates, the conic-gradient shading streams across its face and reads as machined metal.

<main class="stage">
<section class="metaldial">
<p class="metaldial__label">Start free</p>
<button class="metaldial__dial" type="button" aria-label="Start free">
<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">Turn the dial to explore</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 · Metal dial CTA — Measured area 17.386% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The element is a real button — the disc itself is the conversion control.
.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); }
}
03Paper grain feature card
What moves
Three grained sheets sit in a stack, and the top sheet slides 48px up to expose the grain of the one beneath.

<main class="stage">
<section class="paperstack" aria-label="Three grained paper cards">
<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">Second</span>
<span class="paperstack__line"></span>
</article>
<article class="paperstack__sheet paperstack__sheet--1">
<p class="paperstack__eyebrow">Feature card</p>
<h2 class="paperstack__title">Fast checkout</h2>
<p class="paperstack__desc">A paper card with visible grain</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 · Paper grain feature card — Measured area 34.744% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The grain is three 1px repeating-linear-gradient layers set in different directions, so no noise image or base64 blob is ever needed.
.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); }
}
04Volume knob widget
What you see
The knob sweeps 120 degrees each way between the low tick and the high one, its pale pointer line traveling across the dial marks.

<main class="stage">
<section class="volknob" aria-label="Volume knob">
<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">Volume</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 · Volume knob widget — Measured area 8.684% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Because the pointer stays inside the knob's circle, the knob never appears to grow as it turns.
.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); }
}
05Glass inlay badge
What happens on screen
The badge sits in a recess carved out of a brushed metal plate, and a slanted highlight bar sweeps across it from left to right.

<main class="stage">
<section class="glassbadge">
<div class="glassbadge__plate">
<p class="glassbadge__caption">Member badge</p>
<div class="glassbadge__inlay" role="img" aria-label="Glass badge">
<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">Monthly members only</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 · Glass inlay badge — Measured area 10.794% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
As the bar crosses the center, the mint core inside the glass brightens once — light passing through, not a glow effect.
.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; }
}
06Wood grain sliding panels
What moves
Two wood-grain leaves part to reveal a bright slot inside, stay open a beat, then close again.

<main class="stage">
<section class="wooddoors" aria-label="Wood grain sliding panels">
<div class="wooddoors__frame">
<div class="wooddoors__slot">
<p class="wooddoors__slot-title">The second story</p>
<p class="wooddoors__slot-desc">Bright content behind the wood</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 · Wood grain sliding panels — Measured area 38.896% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The grain is painted on the panels themselves, so when a leaf slides, the grain travels with it — the same thing a photo of wood would do, at none of the weight.
.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); }
}
07Sticker emboss logo
What you see
The embossed sticker drops in tilted from 24px up and seats itself at minus three degrees.

<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">Four shadow layers carve the relief</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 · Sticker emboss logo — Measured area 20.042% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The relief is four layers of text-shadow: a bright line below, a dark line above, and two side corrections — all four together make the letters read as carved rather than printed.
.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); }
}
08Physically pressable button
What happens on screen
The keycap sinks 8px while the side wall beneath it shrinks to a quarter via scaleY.

<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">Get started</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">Sinks 8px and the wall shrinks</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 · Physically pressable button — Measured area 7.858% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The wall is its own element rather than a painted box-shadow, so the press stays a transform — cheap to animate, and it runs on the compositor.
.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); }
}
09One page with three texture spots
What moves
A miniature landing page scrolls up and down through its own sections. Texture lives in exactly three places — the wood nav, the metal dial, the leather pricing card — and every other band stays bright and flat.

<main class="stage">
<section class="minipage" aria-label="Mini landing with three texture spots">
<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">Texture landing</p>
<span class="minipage__hero-bar"></span>
<span class="minipage__hero-bar minipage__hero-bar--short"></span>
<span class="minipage__hero-btn">Get started</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">Start free</span>
</section>
<section class="minipage__pricing">
<p class="minipage__plan">Pro membership</p>
<p class="minipage__price">₩24,000 / month</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 texture spots — wood nav, metal dial, leather card</p>
<p class="minipage__note">Every other section stays bright and flat</p>
<p class="minipage__note">Scrolling shows where the textures sit</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 · One page with three texture spots — Measured area 32.228% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The trick behind the preview is a track at exactly 200% of the viewport, so translateY(-50%) moves precisely one screen.
.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); }
}
Custom tailwind.config
What has to live in one place here is not the palette but the thickness of a press. A physical key rests on an 8px wall and bottoms out at 2px — drift between those two numbers and the button stops feeling seated.
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",
},
},
},
| Role | Token | When |
|---|---|---|
| Key wall at rest | shadow-keyrest |
While the button is idle (8px) |
| Pressed wall | shadow-keydown |
Only in the active: state (2px) |
| Texture surface | tex-leather and friends |
Three spots per page, maximum |
Vibe-Coding Prompt
With the config planted, the instruction you hand Cursor or Claude Code is one block. The ordering matters: texture placement comes before any color list. The unabridged prompt ships as prompt.md in the zip.
Texture lives in exactly three places: the nav (wood), the CTA (metal dial), and the
recommended pricing plan (leather). Untextured area must be at least twice the textured
area. Wood is a vertical repeating-linear-gradient of grain bands; the metal dial is a
conic-gradient in four stops; leather is two overlapping repeating gradients plus a top
highlight. Stitch ticks wrap each of the four edges as a repeating-linear-gradient of
8px thread and 8px gap. A button rests on an 8px wall and drops to 2px when pressed —
the cap sinks by exactly the wall it loses.
Built from that instruction, page/index.html looks like this at a 1280px viewport.

Where it breaks (the trap)
Text directly on wood was the trap that lasted longest. The nav first carried a white brand name on the raw grain — but the grain repeats through four band colors, and the same white type measured 5.14:1 on the dark band (#96622e) and 3.01:1 on the bright one (#c08a4e). No single color clears 4.5:1 across all four bands, so the type moved onto dark inlay chips and the wood stayed decorative. Measure contrast before seating body text on any texture.
The second trap was travel distance on the paper sheet. Raising the top sheet by 72 percent of its own height looked fine at 480px but clipped the sheet's top edge off the stage at 320px. Switching to a flat 48px — and down to 20px on narrow screens — kept the whole sheet visible at both widths. Trading a percentage for a flat pixel value when the width changes is the same habit that Outlook-safe newsletter templates ask for. The finished nine files are all inside the zip, which opens with the password 7dcfwy5m.
Accessibility
Texture is where contrast goes to die. Every text pairing in this kit was run through the relative-luminance formula from the WCAG 2.1 minimum contrast criterion and recorded below.
| Text pairing | Ratio | Verdict |
|---|---|---|
| Leather body #f6e7cf / leather #7a4a2b | 6.07:1 | pass |
| Leather price #ffffff / leather #7a4a2b | 7.39:1 | pass |
| Paper body #574a38 / paper #fff7e6 | 8.07:1 | pass |
| Paper secondary #7a6c56 / paper #fff7e6 | 4.80:1 | pass, barely |
| Hero button #ffffff / brand #2f6df6 | 4.53:1 | pass, barely |
| Leather body on a bright grain band #96613c | 4.24:1 | fail — no body text on bright bands |
The last row is why the leather card keeps its copy on the dark grain side, and why the white icon idea on the mint core (1.86:1) became a deep-teal icon instead. Every one of the nine parts carries the MDN prefers-reduced-motion media query, so visitors who reduce motion get each part seated and still. The materials themselves are conic-gradient and text-shadow values, so an unsupported browser degrades to flat color, never to a broken layout.
Browse the design trends category for other styles making the rounds; what this site hands out is on the about page.
FAQ
How many texture spots can one skeuomorphic ui css page carry?
Three. This kit uses exactly that many — wood nav, metal dial, leather pricing card — and keeps everything else bright and flat. Past four spots the eye loses its resting place and the page slides back toward the heavy 2010s look.
Can I wrap the whole page in leather like the old apps?
For a single card, yes; for a whole landing, no. Dark texture forces you to re-measure contrast everywhere, and the brightest grain band cannot carry 4.5:1 body text at all. Texture earns its keep as a point of focus and becomes a cost as a background.
Does this drop straight into a React project?
The zip carries React versions (.tsx + .module.scss) of all nine parts alongside the vanilla files. Duration, easing, and the accent color arrive as props, and both flavors were verified against the same measured values. If you are still deciding where a texture part belongs above the fold, hero section design patterns settles that placement call faster.









