스큐어모피즘 디자인 랜딩 키트, 질감 부품 9개
스큐어모피즘 디자인은 가죽·금속·나무 같은 실물 질감을 화면에 옮겨 담는 스타일입니다. 사진 한 장 없이 가죽·금속·나무를 CSS 값만으로 만들어, 2024년 이후 제품 사이트에서 한두 군데씩 다시 돌아온 그 질감을 부품 아홉 개로 정리했습니다.
2010년대 초의 앱은 화면 전체를 가죽과 아마포로 감쌌고, 그 무게에 지쳐 플랫 디자인이 왔습니다. 지금 돌아온 질감은 그때와 다릅니다. 밝고 평면적인 화면에 질감을 딱 세 곳만 얹어, 손이 기억하는 재질감을 포인트로 쓰는 방식입니다. 아래 원페이지를 스크롤하면서 어느 자리에 질감이 앉았는지 먼저 찾아보세요.

저 페이지를 이루는 부품 아홉 개를 하나씩 떼어내 격자에 늘어놓았습니다. 순서는 인기순이 아니라 재료가 무게를 갖는 차례입니다. 가죽을 꿰매고, 금속을 돌리고, 종이를 겹치고, 노브를 만지고, 유리를 박고, 나무를 열고, 스티커를 붙이고, 버튼을 누릅니다. 마지막 아홉 번째는 이 재료들을 한 페이지에 제대로 앉히는 법입니다. 스큐어모피즘 예시를 찾으면 아직도 2010년대 화면부터 나오는데, 지금 쓰이는 질감은 페이지를 덮지 않고 세 곳에만 앉습니다. 같은 아홉 칸을 색과 조각의 겹침으로 채운 쪽이 콜라주 맥시멀리즘 랜딩이고, 점·물결 도형으로 채운 쪽이 멤피스 패턴 랜딩 키트입니다. 세 키트를 나란히 두면 질감·겹침·도형이 각각 한 페이지를 어디까지 감당하는지가 보입니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 가죽 스티치 요금 카드
- 02 금속 다이얼 CTA
- 03 종이 결 기능 카드
- 04 볼륨 노브 위젯
- 05 유리 인레이 배지
- 06 나무 결 미닫이 패널
- 07 스티커 엠보싱 로고
- 08 물리적으로 눌리는 버튼
- 09 질감 세 곳만 쓴 원페이지
01가죽 스티치 요금 카드
무엇이 보이나
가죽 카드가 20px 위에서 내려앉는 순간, 밝은 스티치 눈금이 이중 테두리 안에 한 프레임 컷으로 새겨집니다.

<main class="stage">
<section class="leather">
<div class="leather__card">
<p class="leather__plan">Pro 멤버십</p>
<p class="leather__price">월 24,000원</p>
<ul class="leather__perks">
<li class="leather__perk">질감 부품 9종 전체</li>
<li class="leather__perk">상업 이용 포함</li>
</ul>
<button class="leather__cta" type="button">선택하기</button>
<span class="leather__stitch" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 가죽 — 사진 없이 그라디언트 겹침으로 결·테두리·스티치를 만든다.
// 대비 실측(WCAG 상대휘도): stitch/leather-base 밝기 차는 눈금용(장식) ·
// 본문 글자 cream-ink #f6e7cf / leather-deep #906547 = 8.9:1 (본문 기준 4.5:1 통과)
$leather-base: #7a4a2b; // 가죽 바탕
$leather-deep: #906547; // 어두운 결·글자 바탕
$leather-light: #96613c; // 밝은 결
$leather-ink: #f6e7cf; // 가죽 위 글자 (짙은 질감 위 본문은 이 밝기만)
$stitch: #f3e2c8; // 스티치 실
$duration: $dur-loop; // 카드가 내려와 착지하고 다시 뜨는 한 주기
$easing: $ease-out; // 가죽은 무겁다 — 튀지 않고 감속
$color: $leather-base;
@include stage($stage-ink);
.leather {
display: grid;
place-items: center;
}
// 가죽 카드 본체 — 이중 테두리: 어두운 바깥 테(안쪽 그림자) + 밝은 안쪽 테(위 하이라이트)
.leather__card {
position: relative;
box-sizing: border-box;
width: clamp(200px, 64vw, 300px);
padding: $sp-4;
border-radius: $r-card;
background:
repeating-linear-gradient(34deg, rgba($leather-deep, .38) 0 2px, transparent 2px 5px),
repeating-linear-gradient(-26deg, rgba($leather-light, .22) 0 1px, transparent 1px 6px),
radial-gradient(120% 120% at 30% 20%, rgba($leather-light, .5), transparent 55%),
$leather-base;
box-shadow:
inset 0 2px 0 rgba(255, 244, 224, .28), // 안쪽 위 밝은 테
inset 0 -4px 0 rgba($leather-deep, .8), // 안쪽 아래 어두운 테
0 10px 14px rgba(0, 0, 0, .45);
animation: leatherLand $duration $easing infinite;
}
// 스티치 눈금 — 네 변을 각각 repeating-linear-gradient 눈금으로 감싼다 (이미지 아님)
.leather__stitch {
position: absolute;
top: 8px; left: 8px; right: 8px; bottom: 8px;
border-radius: $r-control;
opacity: 0;
background:
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) top / 100% 2px no-repeat,
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) bottom / 100% 2px no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) left / 2px 100% no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) right / 2px 100% no-repeat;
filter: drop-shadow(0 1px 0 rgba($leather-deep, .9));
animation: stitchCut $duration steps(1, end) infinite;
}
.leather__plan {
margin: 0 0 $sp-1;
color: $leather-ink;
font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1;
font-family: inherit;
letter-spacing: .14em;
text-transform: uppercase;
}
.leather__price {
margin: 0 0 $sp-2;
color: #ffffff;
font-weight: 800; font-size: clamp(20px, 5.2vw, 30px); line-height: 1.1;
font-family: inherit;
}
.leather__perks {
margin: 0 0 $sp-3;
padding: 0;
list-style: none;
display: grid;
gap: $sp-1;
}
.leather__perk {
color: $leather-ink;
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.4;
font-family: inherit;
}
.leather__cta {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: #3d2412;
color: #ffffff;
font-weight: 700; font-size: clamp(12px, 2.8vw, 14px); line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 235, 200, .22), 0 4px 0 rgba(0, 0, 0, .4);
}
.leather__cta:focus-visible {
outline: 3px solid $stage-yellow;
outline-offset: 2px;
}
// 카드가 20px 위에서 내려앉고 — 착지 순간 스티치가 한 프레임 컷으로 새겨진다 — 다시 뜬다
@keyframes leatherLand {
0% { transform: translateY(-20px); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 20px 26px rgba(0, 0, 0, .55); }
34% { transform: translateY(0); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 8px 12px rgba(0, 0, 0, .45); }
42% { transform: translateY(-2px); }
50% { transform: translateY(0); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 10px 14px rgba(0, 0, 0, .45); }
86% { transform: translateY(0); }
94%,100% { transform: translateY(-20px); box-shadow: inset 0 2px 0 rgba(255, 244, 224, .28), inset 0 -4px 0 rgba($leather-deep, .8), 0 20px 26px rgba(0, 0, 0, .55); }
}
@keyframes stitchCut {
0%, 36% { opacity: 0; }
38%, 86% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@media (max-width: 340px), (max-height: 220px) {
.leather__card { width: 196px; padding: $sp-3; }
.leather__perks { margin-bottom: $sp-2; }
.leather__perk:last-child { display: none; }
.leather__price { font-size: 20px; line-height: 1; }
}
@media (prefers-reduced-motion: reduce) {
.leather__card { animation: none !important; }
.leather__stitch { animation: none !important; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.leather {
display: grid;
place-items: center;
}
.leather__card {
position: relative;
box-sizing: border-box;
width: clamp(200px, 64vw, 300px);
padding: 16px;
border-radius: 16px;
background: repeating-linear-gradient(34deg, rgba(144, 101, 71, 0.38) 0 2px, transparent 2px 5px), repeating-linear-gradient(-26deg, rgba(150, 97, 60, 0.22) 0 1px, transparent 1px 6px), radial-gradient(120% 120% at 30% 20%, rgba(150, 97, 60, 0.5), transparent 55%), #7a4a2b;
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 10px 14px rgba(0, 0, 0, 0.45);
animation: leatherLand 2s ease-out infinite;
}
.leather__stitch {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 8px;
border-radius: 12px;
opacity: 0;
background: repeating-linear-gradient(90deg, #f3e2c8 0 8px, transparent 8px 16px) top/100% 2px no-repeat, repeating-linear-gradient(90deg, #f3e2c8 0 8px, transparent 8px 16px) bottom/100% 2px no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 8px, transparent 8px 16px) left/2px 100% no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 8px, transparent 8px 16px) right/2px 100% no-repeat;
filter: drop-shadow(0 1px 0 rgba(144, 101, 71, 0.9));
animation: stitchCut 2s steps(1, end) infinite;
}
.leather__plan {
margin: 0 0 4px;
color: #f6e7cf;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.leather__price {
margin: 0 0 8px;
color: #ffffff;
font-weight: 800;
font-size: clamp(20px, 5.2vw, 30px);
line-height: 1.1;
font-family: inherit;
}
.leather__perks {
margin: 0 0 12px;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
}
.leather__perk {
color: #f6e7cf;
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.4;
font-family: inherit;
}
.leather__cta {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #3d2412;
color: #ffffff;
font-weight: 700;
font-size: clamp(12px, 2.8vw, 14px);
line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 235, 200, 0.22), 0 4px 0 rgba(0, 0, 0, 0.4);
}
.leather__cta:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 2px;
}
@keyframes leatherLand {
0% {
transform: translateY(-20px);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 20px 26px rgba(0, 0, 0, 0.55);
}
34% {
transform: translateY(0);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 8px 12px rgba(0, 0, 0, 0.45);
}
42% {
transform: translateY(-2px);
}
50% {
transform: translateY(0);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 10px 14px rgba(0, 0, 0, 0.45);
}
86% {
transform: translateY(0);
}
94%, 100% {
transform: translateY(-20px);
box-shadow: inset 0 2px 0 rgba(255, 244, 224, 0.28), inset 0 -4px 0 rgba(144, 101, 71, 0.8), 0 20px 26px rgba(0, 0, 0, 0.55);
}
}
@keyframes stitchCut {
0%, 36% {
opacity: 0;
}
38%, 86% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.leather__card {
width: 196px;
padding: 12px;
}
.leather__perks {
margin-bottom: 8px;
}
.leather__perk:last-child {
display: none;
}
.leather__price {
font-size: 20px;
line-height: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.leather__card {
animation: none !important;
}
.leather__stitch {
animation: none !important;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./LeatherStitchCard.module.scss";
export type LeatherStitchCardProps = {
plan?: string;
price?: string;
perk1?: string;
perk2?: string;
cta?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LeatherStitchCard({
plan = "Pro 멤버십",
price = "월 24,000원",
perk1 = "질감 부품 9종 전체",
perk2 = "상업 이용 포함",
cta = "선택하기",
duration = 2000,
easing = "ease-out",
color = "#7a4a2b",
}: LeatherStitchCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.leather} style={vars}>
<div className={styles.card}>
<p className={styles.plan}>{plan}</p>
<p className={styles.price}>{price}</p>
<ul className={styles.perks}>
<li className={styles.perk}>{perk1}</li>
<li className={styles.perk}>{perk2}</li>
</ul>
<button type="button" className={styles.cta}>{cta}</button>
<span className={styles.stitch} aria-hidden="true" />
</div>
</section>
);
}01 · 가죽 스티치 요금 카드 — 측정 누적면적 23.101% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
스티치는 이미지가 아니라 네 변을 각각 감싼 repeating-linear-gradient 눈금이라 배경색을 바꿔도 다시 그릴 필요가 없습니다.
.leather__stitch {
position: absolute;
top: 8px; left: 8px; right: 8px; bottom: 8px;
border-radius: $r-control;
opacity: 0;
background:
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) top / 100% 2px no-repeat,
repeating-linear-gradient(90deg, $stitch 0 8px, transparent 8px 16px) bottom / 100% 2px no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) left / 2px 100% no-repeat,
repeating-linear-gradient(0deg, $stitch 0 8px, transparent 8px 16px) right / 2px 100% no-repeat;
animation: stitchCut $duration steps(1, end) infinite;
}
@keyframes stitchCut {
0%, 36% { opacity: 0; }
38%, 86% { opacity: 1; }
88%, 100% { opacity: 0; }
}
02금속 다이얼 CTA
스큐어모피즘 ui 의 금속 다이얼은 화면에서 어떻게 보이나
다이얼이 32도씩 좌우로 돌면서 conic-gradient 음영이 표면을 흘러 다니고, 테두리 노치와 지침이 방향을 함께 가리킵니다.

<main class="stage">
<section class="metaldial">
<p class="metaldial__label">무료로 시작</p>
<button class="metaldial__dial" type="button" aria-label="무료로 시작">
<span class="metaldial__rim" aria-hidden="true"></span>
<span class="metaldial__face" aria-hidden="true"></span>
<span class="metaldial__cap" aria-hidden="true"></span>
<span class="metaldial__pointer" aria-hidden="true"></span>
</button>
<p class="metaldial__hint">다이얼을 돌려 살펴 보세요</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 금속 다이얼 — conic-gradient 음영이 회전과 함께 흘러 표면이 금속으로 보인다.
$metal-hi: #f4f5f8; // 가장 밝은 하이라이트
$metal-mid: #aab0bc; // 중간 톤
$metal-lo: #6f7481; // 어두운 톤
$metal-edge: #4b4f59; // 테두리
$knurl: #2c2f36; // 테두리 노치 (깎인 홈)
$duration: $dur-loop; // 좌우로 한 번 도는 주기
$easing: $ease-spring; // 물리 다이얼은 끝에서 살짝 돌아온다
$color: $metal-hi;
@include stage($stage-yellow);
.metaldial {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.metaldial__label {
margin: 0;
color: $subject-ink;
font-weight: 800; font-size: clamp(14px, 3.6vw, 18px); line-height: 1.2;
font-family: inherit;
}
// 다이얼 = CTA 버튼. 원반이 통째로 돌아야 음영이 표면을 흐른다
.metaldial__dial {
position: relative;
width: clamp(120px, 38vw, 168px);
aspect-ratio: 1;
border: 0;
border-radius: 50%;
padding: 0;
cursor: pointer;
background: transparent;
animation: dialSwing $duration $easing infinite;
}
.metaldial__dial:focus-visible {
outline: 3px solid $subject-blue;
outline-offset: 4px;
}
// 바깥 테두리 — 노치(knurl): 반복 conic 어두운 눈금을 금속 링 위에 깎는다
.metaldial__rim {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
border-radius: 50%;
background:
repeating-conic-gradient(from 0deg, $knurl 0deg 2deg, transparent 2deg 7deg) padding-box,
conic-gradient(from 200deg, $metal-hi, $metal-lo 18%, $metal-mid 38%, $metal-hi 55%, $metal-edge 72%, $metal-mid 88%, $metal-hi);
box-shadow: 0 8px 14px rgba(23, 20, 26, .35);
}
// 다이얼 면 — conic 음영 4계절. 이 면이 돌면 하이라이트가 표면을 흐른다
.metaldial__face {
position: absolute;
top: 12%; left: 12%; right: 12%; bottom: 12%;
border-radius: 50%;
background:
conic-gradient(from 210deg,
$metal-hi 0deg, $metal-lo 70deg, $metal-mid 120deg,
$metal-hi 180deg, $metal-edge 250deg, $metal-mid 300deg, $metal-hi 360deg);
box-shadow: inset 0 2px 3px rgba(255, 255, 255, .65), inset 0 -3px 5px rgba(0, 0, 0, .4);
}
// 중앙 캡 — 파란 인레이. 정지 축 역할
.metaldial__cap {
position: absolute;
top: 36%; left: 36%; right: 36%; bottom: 36%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, $subject-blue 55%, #5d74af);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, .5), 0 2px 4px rgba(0, 0, 0, .45);
}
// 지침 — 캡에서 바깥으로 뻗는 밝은 선. 방향을 가리킨다
.metaldial__pointer {
position: absolute;
top: 8%; left: calc(50% - 2px);
width: 4px;
height: 34%;
border-radius: $r-pill;
background: $metal-hi;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .25);
}
.metaldial__hint {
margin: 0;
color: $subject-ink;
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.4;
font-family: inherit;
}
// 32도씩 왕복 — conic 하이라이트가 면을 흘러 다니고 노치·지침이 함께 돈다
@keyframes dialSwing {
0%, 6% { transform: rotate(-32deg); }
48%, 56% { transform: rotate(32deg); }
94%, 100% { transform: rotate(-32deg); }
}
@media (max-width: 340px), (max-height: 220px) {
.metaldial { gap: $sp-2; }
.metaldial__label { font-size: 13px; }
.metaldial__hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.metaldial__dial { animation: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.metaldial {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.metaldial__label {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(14px, 3.6vw, 18px);
line-height: 1.2;
font-family: inherit;
}
.metaldial__dial {
position: relative;
width: clamp(120px, 38vw, 168px);
aspect-ratio: 1;
border: 0;
border-radius: 50%;
padding: 0;
cursor: pointer;
background: transparent;
animation: dialSwing 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.metaldial__dial:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 4px;
}
.metaldial__rim {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
background: repeating-conic-gradient(from 0deg, #2c2f36 0deg 2deg, transparent 2deg 7deg) padding-box, conic-gradient(from 200deg, #f4f5f8, #6f7481 18%, #aab0bc 38%, #f4f5f8 55%, #4b4f59 72%, #aab0bc 88%, #f4f5f8);
box-shadow: 0 8px 14px rgba(23, 20, 26, 0.35);
}
.metaldial__face {
position: absolute;
top: 12%;
left: 12%;
right: 12%;
bottom: 12%;
border-radius: 50%;
background: conic-gradient(from 210deg, #f4f5f8 0deg, #6f7481 70deg, #aab0bc 120deg, #f4f5f8 180deg, #4b4f59 250deg, #aab0bc 300deg, #f4f5f8 360deg);
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.65), inset 0 -3px 5px rgba(0, 0, 0, 0.4);
}
.metaldial__cap {
position: absolute;
top: 36%;
left: 36%;
right: 36%;
bottom: 36%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, #2f6df6 55%, #5d74af);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.5), 0 2px 4px rgba(0, 0, 0, 0.45);
}
.metaldial__pointer {
position: absolute;
top: 8%;
left: calc(50% - 2px);
width: 4px;
height: 34%;
border-radius: 999px;
background: #f4f5f8;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.metaldial__hint {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.4;
font-family: inherit;
}
@keyframes dialSwing {
0%, 6% {
transform: rotate(-32deg);
}
48%, 56% {
transform: rotate(32deg);
}
94%, 100% {
transform: rotate(-32deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.metaldial {
gap: 8px;
}
.metaldial__label {
font-size: 13px;
}
.metaldial__hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.metaldial__dial {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./MetalDialCta.module.scss";
export type MetalDialCtaProps = {
label?: string;
hint?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MetalDialCta({
label = "무료로 시작",
hint = "다이얼을 돌려 살펴 보세요",
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4f5f8",
}: MetalDialCtaProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.metaldial} style={vars}>
<p className={styles.label}>{label}</p>
<button type="button" className={styles.dial} aria-label={label}>
<span className={styles.rim} aria-hidden="true" />
<span className={styles.face} aria-hidden="true" />
<span className={styles.cap} aria-hidden="true" />
<span className={styles.pointer} aria-hidden="true" />
</button>
<p className={styles.hint}>{hint}</p>
</section>
);
}02 · 금속 다이얼 CTA — 측정 누적면적 17.386% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
버튼 요소 안에 원반이 통째로 들어 있어 이것 자체가 전환 버튼입니다.
.metaldial__dial {
animation: dialSwing $duration $ease-spring infinite;
}
.metaldial__face {
background:
conic-gradient(from 210deg,
$metal-hi 0deg, $metal-lo 70deg, $metal-mid 120deg,
$metal-hi 180deg, $metal-edge 250deg, $metal-mid 300deg, $metal-hi 360deg);
box-shadow: inset 0 2px 3px rgba(255, 255, 255, .65), inset 0 -3px 5px rgba(0, 0, 0, .4);
}
@keyframes dialSwing {
0%, 6% { transform: rotate(-32deg); }
48%, 56% { transform: rotate(32deg); }
94%, 100% { transform: rotate(-32deg); }
}
03종이 결 기능 카드
어떤 움직임인가
결이 보이는 종이 시트 세 장이 포개져 있고, 맨 위 시트가 48px 위로 미끄러져 아래 시트의 결을 드러냅니다.

<main class="stage">
<section class="paperstack" aria-label="종이 결 카드 세 장">
<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">두 번째</span>
<span class="paperstack__line"></span>
</article>
<article class="paperstack__sheet paperstack__sheet--1">
<p class="paperstack__eyebrow">기능 카드</p>
<h2 class="paperstack__title">빠른 결제</h2>
<p class="paperstack__desc">결이 보이는 종이 카드</p>
</article>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 종이 — base64 노이즈 없이 repeating-linear-gradient 세 겹으로 미세한 결을 만든다.
// 결 방향을 섞는다(0deg·90deg·48deg) — 한 방향만 쓰면 줄무늬로 보인다
$grain-a: rgba(122, 105, 78, .06); // 어두운 결 — 세로
$grain-b: rgba(122, 105, 78, .05); // 어두운 결 — 가로
$grain-c: rgba(255, 255, 255, .5); // 밝은 결 — 비스듬한 실
$paper-face: $subject-cream; // 종이 면 (토큰)
$paper-ink: #574a38; // 종이 위 글자 — cream 대비 7.1:1
$duration: $dur-loop; // 맨 위 시트가 올라갔다 돌아오는 주기
$easing: $ease-spring; // 종이가 살짝 튀어 오른다
$color: $subject-cream;
@include stage($stage-orange);
.paperstack {
position: relative;
width: clamp(200px, 62vw, 280px);
}
// 시트 공통 — 결 그라디언트 + 아래 두 장은 살짝 비틀려 겹침을 보여준다
.paperstack__sheet {
position: absolute;
top: 0; left: 0;
box-sizing: border-box;
width: 100%;
padding: $sp-4;
border-radius: $r-card;
background:
repeating-linear-gradient(0deg, $grain-a 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, $grain-b 0 1px, transparent 1px 4px),
repeating-linear-gradient(48deg, $grain-c 0 1px, transparent 1px 7px),
$paper-face;
box-shadow: 0 6px 10px rgba(23, 20, 26, .3);
}
.paperstack__sheet--2 {
top: 10px; left: 8px;
transform: rotate(1.6deg);
height: 152px;
}
.paperstack__sheet--3 {
top: 20px; left: -6px;
transform: rotate(-2.2deg);
height: 148px;
}
// 맨 위 시트 — 48px 위로 미끄러져 아래 시트의 결을 드러낸다
.paperstack__sheet--1 {
position: relative;
height: 156px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-1;
z-index: 1;
animation: sheetSlide $duration $easing infinite;
}
.paperstack__eyebrow {
margin: 0;
color: #8a6d3f;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
letter-spacing: .12em;
text-transform: uppercase;
}
.paperstack__title {
margin: 0;
color: $paper-ink;
font-weight: 800; font-size: clamp(16px, 4.2vw, 22px); line-height: 1.2;
font-family: inherit;
}
.paperstack__desc {
margin: 0;
color: $paper-ink;
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1.4;
font-family: inherit;
}
.paperstack__tag {
margin: $sp-2 0 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: #e9dcc2;
color: #574a38;
font-weight: 700; font-size: 10px; line-height: 1;
font-family: inherit;
}
.paperstack__line {
display: block;
height: 8px;
margin: $sp-2 0 0;
border-radius: $r-pill;
background: rgba(87, 74, 56, .16);
}
// 0%·12% 정지 → 46%까지 48px 상승 → 잠시 멈춤 → 90%까지 복귀. 텔레포트 없다
@keyframes sheetSlide {
0%, 10% { transform: translateY(0); }
46%, 58% { transform: translateY(-48px); }
88%, 100% { transform: translateY(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.paperstack { width: 208px; }
.paperstack__sheet--1 { height: 128px; padding: $sp-3; }
.paperstack__sheet--2, .paperstack__sheet--3 { height: 124px; }
.paperstack__sheet--2 { top: 8px; }
.paperstack__sheet--3 { top: 16px; }
.paperstack__desc { display: none; }
@keyframes sheetSlide {
0%, 10% { transform: translateY(0); }
46%, 58% { transform: translateY(-20px); }
88%, 100% { transform: translateY(0); }
}
}
@media (prefers-reduced-motion: reduce) {
.paperstack__sheet--1 { animation: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.paperstack {
position: relative;
width: clamp(200px, 62vw, 280px);
}
.paperstack__sheet {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 100%;
padding: 16px;
border-radius: 16px;
background: repeating-linear-gradient(0deg, rgba(122, 105, 78, 0.06) 0 1px, transparent 1px 3px), repeating-linear-gradient(90deg, rgba(122, 105, 78, 0.05) 0 1px, transparent 1px 4px), repeating-linear-gradient(48deg, rgba(255, 255, 255, 0.5) 0 1px, transparent 1px 7px), #fff7e6;
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.3);
}
.paperstack__sheet--2 {
top: 10px;
left: 8px;
transform: rotate(1.6deg);
height: 152px;
}
.paperstack__sheet--3 {
top: 20px;
left: -6px;
transform: rotate(-2.2deg);
height: 148px;
}
.paperstack__sheet--1 {
position: relative;
height: 156px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
z-index: 1;
animation: sheetSlide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.paperstack__eyebrow {
margin: 0;
color: #8a6d3f;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.paperstack__title {
margin: 0;
color: #574a38;
font-weight: 800;
font-size: clamp(16px, 4.2vw, 22px);
line-height: 1.2;
font-family: inherit;
}
.paperstack__desc {
margin: 0;
color: #574a38;
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.4;
font-family: inherit;
}
.paperstack__tag {
margin: 8px 0 0;
border-radius: 4px;
padding: 4px 8px;
background: #e9dcc2;
color: #574a38;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
}
.paperstack__line {
display: block;
height: 8px;
margin: 8px 0 0;
border-radius: 999px;
background: rgba(87, 74, 56, 0.16);
}
@keyframes sheetSlide {
0%, 10% {
transform: translateY(0);
}
46%, 58% {
transform: translateY(-48px);
}
88%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.paperstack {
width: 208px;
}
.paperstack__sheet--1 {
height: 128px;
padding: 12px;
}
.paperstack__sheet--2, .paperstack__sheet--3 {
height: 124px;
}
.paperstack__sheet--2 {
top: 8px;
}
.paperstack__sheet--3 {
top: 16px;
}
.paperstack__desc {
display: none;
}
@keyframes sheetSlide {
0%, 10% {
transform: translateY(0);
}
46%, 58% {
transform: translateY(-20px);
}
88%, 100% {
transform: translateY(0);
}
}
}
@media (prefers-reduced-motion: reduce) {
.paperstack__sheet--1 {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./PaperGrainCard.module.scss";
export type PaperGrainCardProps = {
eyebrow?: string;
title?: string;
desc?: string;
tag?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PaperGrainCard({
eyebrow = "기능 카드",
title = "빠른 결제",
desc = "결이 보이는 종이 카드",
tag = "두 번째",
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: PaperGrainCardProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.paperstack} style={vars} aria-label="종이 결 카드 세 장">
<article className={`${styles.sheet} ${styles.sheet3}`} aria-hidden="true">
<span className={styles.line}></span><span className={styles.line}></span>
</article>
<article className={`${styles.sheet} ${styles.sheet2}`} aria-hidden="true">
<span className={styles.tag}>{tag}</span>
<span className={styles.line}></span>
</article>
<article className={`${styles.sheet} ${styles.sheet1}`}>
<p className={styles.eyebrow}>{eyebrow}</p>
<h2 className={styles.title}>{title}</h2>
<p className={styles.desc}>{desc}</p>
</article>
</section>
);
}03 · 종이 결 기능 카드 — 측정 누적면적 34.744% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
결은 방향을 섞은 1px repeating-linear-gradient 세 겹이라 노이즈 이미지가 필요 없습니다.
.paperstack__sheet {
background:
repeating-linear-gradient(0deg, $grain-a 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, $grain-b 0 1px, transparent 1px 4px),
repeating-linear-gradient(48deg, $grain-c 0 1px, transparent 1px 7px),
$paper-face;
}
@keyframes sheetSlide {
0%, 10% { transform: translateY(0); }
46%, 58% { transform: translateY(-48px); }
88%, 100% { transform: translateY(0); }
}
04볼륨 노브 위젯
무엇이 보이나
노브가 눈금 판의 최솟값에서 최댓값까지 120도씩 양쪽으로 돌고, 밝은 지침 선이 눈금 위를 지나갑니다.

<main class="stage">
<section class="volknob" aria-label="볼륨 노브">
<span class="volknob__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/></g></svg>
</span>
<div class="volknob__panel">
<span class="volknob__tick volknob__tick--1" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--2" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--3" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--4" aria-hidden="true"></span>
<span class="volknob__tick volknob__tick--5" aria-hidden="true"></span>
<div class="volknob__knob">
<span class="volknob__needle" aria-hidden="true"></span>
</div>
</div>
<p class="volknob__label">음량 조절</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 볼륨 노브 — 어두운 몸통에 밝은 지침. 몸통이 돌면 위 하이라이트 대비로 회전이 읽힌다
$knob-body: #2b2831; // 노브 몸통
$knob-face: #38343f; // 노브 윗면
$knob-line: #efe8f7; // 지침 선 (밝게)
$panel-bg: #eceaf1; // 눈금 판
$duration: $dur-loop; // 최소 → 최대 → 최소 한 주기
$easing: $ease-out; // 손을 떼면 감속하며 멈춘다
$color: $knob-body;
@include stage($stage-paper);
.volknob {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.volknob__icon {
width: 28px;
height: 28px;
color: $subject-ink;
}
// 눈금 판 — 눈금 다섯 개를 원주 위에 미리 돌려 놓는다 (정적)
.volknob__panel {
position: relative;
width: clamp(132px, 42vw, 176px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(130% 130% at 30% 24%, #ffffff, $panel-bg 70%);
box-shadow: inset 0 2px 4px rgba(23, 20, 26, .18), 0 3px 8px rgba(23, 20, 26, .14);
}
.volknob__tick {
position: absolute;
top: 6px; left: calc(50% - 2px);
width: 4px;
height: 10px;
border-radius: $r-pill;
background: #a9a4b4;
transform-origin: 50% calc(clamp(66px, 21vw, 88px) - 11px); // 패널 중심까지 = 반지름 - 눈금 중심 y
}
.volknob__tick--1 { transform: rotate(-120deg); }
.volknob__tick--2 { transform: rotate(-60deg); }
.volknob__tick--3 { transform: rotate(0deg); }
.volknob__tick--4 { transform: rotate(60deg); }
.volknob__tick--5 { transform: rotate(120deg); }
// 노브 몸통 — 도는 주인공. 윗면 그라디언트가 살짝 비스듬해 회전이 읽힌다
.volknob__knob {
position: absolute;
top: 20%; left: 20%; right: 20%; bottom: 20%;
border-radius: 50%;
background:
radial-gradient(120% 120% at 34% 26%, rgba(255, 255, 255, .32), transparent 42%),
conic-gradient(from 250deg, $knob-face, $knob-body 90deg, $knob-face 170deg, $knob-body 260deg, $knob-face 340deg);
box-shadow: 0 6px 10px rgba(23, 20, 26, .35), inset 0 1px 2px rgba(255, 255, 255, .22);
animation: knobSweep $duration $easing infinite;
}
.volknob__knob:focus-visible {
outline: 3px solid $subject-blue;
outline-offset: 3px;
}
// 지침 선 — 노브 안에서 위로 뻗는다
.volknob__needle {
position: absolute;
top: 6%; left: calc(50% - 2px);
width: 4px;
height: 30%;
border-radius: $r-pill;
background: $knob-line;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .3);
}
.volknob__label {
margin: 0;
color: $subject-ink;
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 최소 눈금(-120도) 에서 최대(+120도) 까지 돌고 돌아온다 — 양 끝에서 잠시 멈춘다
@keyframes knobSweep {
0%, 8% { transform: rotate(-120deg); }
46%, 56% { transform: rotate(120deg); }
92%, 100% { transform: rotate(-120deg); }
}
@media (max-width: 340px), (max-height: 220px) {
.volknob__icon { width: 20px; height: 20px; }
.volknob__label { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.volknob__knob { animation: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.volknob {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.volknob__icon {
width: 28px;
height: 28px;
color: #17141a;
}
.volknob__panel {
position: relative;
width: clamp(132px, 42vw, 176px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(130% 130% at 30% 24%, #ffffff, #eceaf1 70%);
box-shadow: inset 0 2px 4px rgba(23, 20, 26, 0.18), 0 3px 8px rgba(23, 20, 26, 0.14);
}
.volknob__tick {
position: absolute;
top: 6px;
left: calc(50% - 2px);
width: 4px;
height: 10px;
border-radius: 999px;
background: #a9a4b4;
transform-origin: 50% calc(clamp(66px, 21vw, 88px) - 11px);
}
.volknob__tick--1 {
transform: rotate(-120deg);
}
.volknob__tick--2 {
transform: rotate(-60deg);
}
.volknob__tick--3 {
transform: rotate(0deg);
}
.volknob__tick--4 {
transform: rotate(60deg);
}
.volknob__tick--5 {
transform: rotate(120deg);
}
.volknob__knob {
position: absolute;
top: 20%;
left: 20%;
right: 20%;
bottom: 20%;
border-radius: 50%;
background: radial-gradient(120% 120% at 34% 26%, rgba(255, 255, 255, 0.32), transparent 42%), conic-gradient(from 250deg, #38343f, #2b2831 90deg, #38343f 170deg, #2b2831 260deg, #38343f 340deg);
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.35), inset 0 1px 2px rgba(255, 255, 255, 0.22);
animation: knobSweep 2s ease-out infinite;
}
.volknob__knob:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.volknob__needle {
position: absolute;
top: 6%;
left: calc(50% - 2px);
width: 4px;
height: 30%;
border-radius: 999px;
background: #efe8f7;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
}
.volknob__label {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes knobSweep {
0%, 8% {
transform: rotate(-120deg);
}
46%, 56% {
transform: rotate(120deg);
}
92%, 100% {
transform: rotate(-120deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.volknob__icon {
width: 20px;
height: 20px;
}
.volknob__label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.volknob__knob {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./VolumeKnob.module.scss";
export type VolumeKnobProps = {
label?: string;
/** 스크린리더용 섹션 이름 (기본 "볼륨 노브") */
ariaLabel?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function VolumeKnob({
label = "음량 조절",
ariaLabel = "볼륨 노브",
duration = 2000,
easing = "ease-out",
color = "#2b2831",
}: VolumeKnobProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.volknob} style={vars} aria-label={ariaLabel}>
<span className={styles.icon} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/></g></svg>
</span>
<div className={styles.panel}>
<span className={`${styles.tick} ${styles.tick1}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick2}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick3}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick4}`} aria-hidden="true" />
<span className={`${styles.tick} ${styles.tick5}`} aria-hidden="true" />
<div className={styles.knob}>
<span className={styles.needle} aria-hidden="true" />
</div>
</div>
<p className={styles.label}>{label}</p>
</section>
);
}04 · 볼륨 노브 위젯 — 측정 누적면적 8.684% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
각도가 커져도 노브는 커 보이지 않습니다 — 회전이 원 안에서만 끝나기 때문입니다.
.volknob__knob {
background:
radial-gradient(120% 120% at 34% 26%, rgba(255, 255, 255, .32), transparent 42%),
conic-gradient(from 250deg, $knob-face, $knob-body 90deg, $knob-face 170deg,
$knob-body 260deg, $knob-face 340deg);
box-shadow: 0 6px 10px rgba(23, 20, 26, .35), inset 0 1px 2px rgba(255, 255, 255, .22);
animation: knobSweep $duration $ease-out infinite;
}
@keyframes knobSweep {
0%, 8% { transform: rotate(-120deg); }
46%, 56% { transform: rotate(120deg); }
92%, 100% { transform: rotate(-120deg); }
}
05유리 인레이 배지
화면에서 일어나는 일
브러시드 메탈 판에 파낸 자리에 유리 배지가 박혀 있고, 비스듬한 하이라이트 막대가 왼쪽에서 오른쪽으로 훑고 지나갑니다.

<main class="stage">
<section class="glassbadge">
<div class="glassbadge__plate">
<p class="glassbadge__caption">정회원 배지</p>
<div class="glassbadge__inlay" role="img" aria-label="유리 배지">
<span class="glassbadge__core" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2"/><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18m-11.55 39.29c-4.79 5-9.75 10.17-12.38 16.52c-2.52 6.1-2.63 13.07-2.73 19.82c-.1 7-.21 14.33-3.32 17.43s-10.39 3.22-17.43 3.32c-6.75.1-13.72.21-19.82 2.73c-6.35 2.63-11.52 7.59-16.52 12.38S132 224 128 224s-9.15-4.92-14.11-9.69s-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73c-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82c-2.63-6.35-7.59-11.52-12.38-16.52S32 132 32 128s4.92-9.15 9.69-14.11s9.75-10.17 12.38-16.52c2.52-6.1 2.63-13.07 2.73-19.82c.1-7 .21-14.33 3.32-17.43s10.39-3.22 17.43-3.32c6.75-.1 13.72-.21 19.82-2.73c6.35-2.63 11.52-7.59 16.52-12.38S124 32 128 32s9.15 4.92 14.11 9.69s10.17 9.75 16.52 12.38c6.1 2.52 13.07 2.63 19.82 2.73c7 .1 14.33.21 17.43 3.32s3.22 10.39 3.32 17.43c.1 6.75.21 13.72 2.73 19.82c2.63 6.35 7.59 11.52 12.38 16.52S224 124 224 128s-4.92 9.15-9.69 14.11m-40.65-43.77a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
<span class="glassbadge__sheen" aria-hidden="true"></span>
<span class="glassbadge__flare" aria-hidden="true"></span>
<span class="glassbadge__gloss" aria-hidden="true"></span>
</div>
</div>
<p class="glassbadge__note">한 달 이용자 한정</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 유리 인레이 — 브러시드 메탈 판에 파인 자리(inset)에 민트 코어를 박고 유리 광이 훑고 지나간다.
$brush-hi: #565b66; // 브러시 밝은 결
$brush-lo: #31353e; // 브러시 어두운 결
$inset-rim: #22252c; // 인레이 파인 테두리
$core: $subject-mint; // 유리 안 코어 (토큰)
$duration: $dur-loop; // 광선이 한 번 훑고 지나가는 주기
$easing: $ease-out; // 광선은 감속하며 지나간다
$color: $core;
@include stage($stage-ink);
.glassbadge {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
// 브러시드 메탈 판 — 1px 결을 가로로 촘촘하게 깐다 (이미지 아님)
.glassbadge__plate {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: clamp(212px, 66vw, 300px);
padding: $sp-4;
border-radius: $r-card;
background:
repeating-linear-gradient(90deg, $brush-hi 0 1px, $brush-lo 1px 3px, $brush-hi 3px 4px, $inset-rim 4px 7px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .14), inset 0 -2px 0 rgba(0, 0, 0, .5), 0 10px 16px rgba(0, 0, 0, .5);
}
.glassbadge__caption {
margin: 0;
color: #ffffff;
font-weight: 800; font-size: clamp(12px, 3vw, 15px); line-height: 1;
font-family: inherit;
letter-spacing: .08em;
}
// 유리 인레이 — 판보다 낮게 파인 자리. overflow 로 광선을 자른다
.glassbadge__inlay {
position: relative;
width: clamp(104px, 32vw, 140px);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: radial-gradient(130% 130% at 32% 26%, rgba(255, 255, 255, .3), transparent 46%), $inset-rim;
box-shadow: inset 0 4px 8px rgba(0, 0, 0, .75), inset 0 -2px 4px rgba(255, 255, 255, .12);
}
// 민트 코어 — 유리 안에서 한 단 낮게 떠 있는 인레이
.glassbadge__core {
position: absolute;
top: 16%; left: 16%; right: 16%; bottom: 16%;
display: grid;
place-items: center;
border-radius: 50%;
color: #0c2c20;
font-size: clamp(26px, 8vw, 36px);
background: radial-gradient(120% 120% at 34% 28%, #d6ebe2, $core 58%, #4fa183);
box-shadow: inset 0 -3px 6px rgba(0, 0, 0, .35), 0 2px 5px rgba(0, 0, 0, .55);
}
// 플레어 — 광선이 코어를 지나는 순간 코어가 한 번 밝아진다 (opacity 만)
.glassbadge__flare {
position: absolute;
top: 16%; left: 16%; right: 16%; bottom: 16%;
border-radius: 50%;
background: radial-gradient(120% 120% at 42% 34%, rgba(255, 255, 255, .55), transparent 64%);
opacity: 0;
animation: flareUp $duration $easing infinite;
}
// 광선 — 배지보다 길게 만들고 translateX 로 민다 (transform 이니 합성기가 처리한다)
.glassbadge__sheen {
position: absolute;
top: -20%; left: 0;
width: 44%;
height: 140%;
transform: translateX(-140%) rotate(24deg);
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .4) 45%, rgba(255, 255, 255, .55) 50%, rgba(255, 255, 255, .4) 55%, transparent);
animation: sheenSweep $duration $easing infinite;
}
// 유리 위쪽 고광 — 정적인 초승달
.glassbadge__gloss {
position: absolute;
top: 6%; left: 18%;
width: 64%;
height: 36%;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, .34), transparent 70%);
}
.glassbadge__note {
margin: 0;
color: rgba(255, 255, 255, .82);
font-weight: 500; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 광선: 왼쪽 바깥에서 출발 → 중앙(코어 위) 을 지나 → 오른쪽 바깥으로. 처음과 끝이 같다
@keyframes sheenSweep {
0%, 12% { transform: translateX(-140%) rotate(24deg); }
82%, 100% { transform: translateX(340%) rotate(24deg); }
}
// 코어 플레어는 광선이 지나가는 40~52% 구간에만 잠깐 올랐다 진다 (유리를 통과하는 빛)
@keyframes flareUp {
0%, 36% { opacity: 0; }
44%, 50% { opacity: 1; }
58%, 100% { opacity: 0; }
}
@media (max-width: 340px), (max-height: 220px) {
.glassbadge__plate { padding: $sp-3; gap: $sp-1; }
.glassbadge__note { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.glassbadge__sheen { animation: none !important; transform: translateX(120%) rotate(24deg); }
.glassbadge__flare { animation: none !important; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.glassbadge {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.glassbadge__plate {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: clamp(212px, 66vw, 300px);
padding: 16px;
border-radius: 16px;
background: repeating-linear-gradient(90deg, #565b66 0 1px, #31353e 1px 3px, #565b66 3px 4px, #22252c 4px 7px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), inset 0 -2px 0 rgba(0, 0, 0, 0.5), 0 10px 16px rgba(0, 0, 0, 0.5);
}
.glassbadge__caption {
margin: 0;
color: #ffffff;
font-weight: 800;
font-size: clamp(12px, 3vw, 15px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
}
.glassbadge__inlay {
position: relative;
width: clamp(104px, 32vw, 140px);
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
background: radial-gradient(130% 130% at 32% 26%, rgba(255, 255, 255, 0.3), transparent 46%), #22252c;
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.75), inset 0 -2px 4px rgba(255, 255, 255, 0.12);
}
.glassbadge__core {
position: absolute;
top: 16%;
left: 16%;
right: 16%;
bottom: 16%;
display: grid;
place-items: center;
border-radius: 50%;
color: #0c2c20;
font-size: clamp(26px, 8vw, 36px);
background: radial-gradient(120% 120% at 34% 28%, #d6ebe2, #4cd4a6 58%, #4fa183);
box-shadow: inset 0 -3px 6px rgba(0, 0, 0, 0.35), 0 2px 5px rgba(0, 0, 0, 0.55);
}
.glassbadge__flare {
position: absolute;
top: 16%;
left: 16%;
right: 16%;
bottom: 16%;
border-radius: 50%;
background: radial-gradient(120% 120% at 42% 34%, rgba(255, 255, 255, 0.55), transparent 64%);
opacity: 0;
animation: flareUp 2s ease-out infinite;
}
.glassbadge__sheen {
position: absolute;
top: -20%;
left: 0;
width: 44%;
height: 140%;
transform: translateX(-140%) rotate(24deg);
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.4) 45%, rgba(255, 255, 255, 0.55) 50%, rgba(255, 255, 255, 0.4) 55%, transparent);
animation: sheenSweep 2s ease-out infinite;
}
.glassbadge__gloss {
position: absolute;
top: 6%;
left: 18%;
width: 64%;
height: 36%;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.34), transparent 70%);
}
.glassbadge__note {
margin: 0;
color: rgba(255, 255, 255, 0.82);
font-weight: 500;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes sheenSweep {
0%, 12% {
transform: translateX(-140%) rotate(24deg);
}
82%, 100% {
transform: translateX(340%) rotate(24deg);
}
}
@keyframes flareUp {
0%, 36% {
opacity: 0;
}
44%, 50% {
opacity: 1;
}
58%, 100% {
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.glassbadge__plate {
padding: 12px;
gap: 4px;
}
.glassbadge__note {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.glassbadge__sheen {
animation: none !important;
transform: translateX(120%) rotate(24deg);
}
.glassbadge__flare {
animation: none !important;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./GlassInlayBadge.module.scss";
export type GlassInlayBadgeProps = {
caption?: string;
note?: string;
/** 인레이 role="img" 라벨 (기본 "유리 배지") */
ariaLabel?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GlassInlayBadge({
caption = "정회원 배지",
note = "한 달 이용자 한정",
ariaLabel = "유리 배지",
duration = 2000,
easing = "ease-out",
color = "#4cd4a6",
}: GlassInlayBadgeProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.glassbadge} style={vars}>
<div className={styles.plate}>
<p className={styles.caption}>{caption}</p>
<div className={styles.inlay} role="img" aria-label={ariaLabel}>
<span className={styles.core} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 128c0 12.51-17.82 21.95-22.68 33.69c-4.68 11.32 1.42 30.65-7.78 39.85s-28.53 3.1-39.85 7.78C150 214.18 140.5 232 128 232s-22-17.82-33.69-22.68c-11.32-4.68-30.65 1.42-39.85-7.78s-3.1-28.53-7.78-39.85C41.82 150 24 140.5 24 128s17.82-22 22.68-33.69c4.68-11.31-1.42-30.65 7.78-39.85s28.54-3.1 39.85-7.78C106.05 41.82 115.5 24 128 24s22 17.82 33.69 22.68c11.32 4.68 30.65-1.42 39.85 7.78s3.1 28.53 7.78 39.85C214.18 106.05 232 115.5 232 128" opacity=".2"/><path d="M225.86 102.82c-3.77-3.94-7.67-8-9.14-11.57c-1.36-3.27-1.44-8.69-1.52-13.94c-.15-9.76-.31-20.82-8-28.51s-18.75-7.85-28.51-8c-5.25-.08-10.67-.16-13.94-1.52c-3.56-1.47-7.63-5.37-11.57-9.14C146.28 23.51 138.44 16 128 16s-18.27 7.51-25.18 14.14c-3.94 3.77-8 7.67-11.57 9.14c-3.25 1.36-8.69 1.44-13.94 1.52c-9.76.15-20.82.31-28.51 8s-7.8 18.75-8 28.51c-.08 5.25-.16 10.67-1.52 13.94c-1.47 3.56-5.37 7.63-9.14 11.57C23.51 109.72 16 117.56 16 128s7.51 18.27 14.14 25.18c3.77 3.94 7.67 8 9.14 11.57c1.36 3.27 1.44 8.69 1.52 13.94c.15 9.76.31 20.82 8 28.51s18.75 7.85 28.51 8c5.25.08 10.67.16 13.94 1.52c3.56 1.47 7.63 5.37 11.57 9.14c6.9 6.63 14.74 14.14 25.18 14.14s18.27-7.51 25.18-14.14c3.94-3.77 8-7.67 11.57-9.14c3.27-1.36 8.69-1.44 13.94-1.52c9.76-.15 20.82-.31 28.51-8s7.85-18.75 8-28.51c.08-5.25.16-10.67 1.52-13.94c1.47-3.56 5.37-7.63 9.14-11.57c6.63-6.9 14.14-14.74 14.14-25.18s-7.51-18.27-14.14-25.18m-11.55 39.29c-4.79 5-9.75 10.17-12.38 16.52c-2.52 6.1-2.63 13.07-2.73 19.82c-.1 7-.21 14.33-3.32 17.43s-10.39 3.22-17.43 3.32c-6.75.1-13.72.21-19.82 2.73c-6.35 2.63-11.52 7.59-16.52 12.38S132 224 128 224s-9.15-4.92-14.11-9.69s-10.17-9.75-16.52-12.38c-6.1-2.52-13.07-2.63-19.82-2.73c-7-.1-14.33-.21-17.43-3.32s-3.22-10.39-3.32-17.43c-.1-6.75-.21-13.72-2.73-19.82c-2.63-6.35-7.59-11.52-12.38-16.52S32 132 32 128s4.92-9.15 9.69-14.11s9.75-10.17 12.38-16.52c2.52-6.1 2.63-13.07 2.73-19.82c.1-7 .21-14.33 3.32-17.43s10.39-3.22 17.43-3.32c6.75-.1 13.72-.21 19.82-2.73c6.35-2.63 11.52-7.59 16.52-12.38S124 32 128 32s9.15 4.92 14.11 9.69s10.17 9.75 16.52 12.38c6.1 2.52 13.07 2.63 19.82 2.73c7 .1 14.33.21 17.43 3.32s3.22 10.39 3.32 17.43c.1 6.75.21 13.72 2.73 19.82c2.63 6.35 7.59 11.52 12.38 16.52S224 124 224 128s-4.92 9.15-9.69 14.11m-40.65-43.77a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
<span className={styles.sheen} aria-hidden="true" />
<span className={styles.flare} aria-hidden="true" />
<span className={styles.gloss} aria-hidden="true" />
</div>
</div>
<p className={styles.note}>{note}</p>
</section>
);
}05 · 유리 인레이 배지 — 측정 누적면적 10.794% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
광선이 코어 위를 지나는 순간 코어가 한 번 밝아지는 것까지 포함해 빛이 유리를 통과하는 느낌을 만듭니다.
.glassbadge__sheen {
position: absolute;
top: -20%; left: 0;
width: 44%;
height: 140%;
transform: translateX(-140%) rotate(24deg);
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .4) 45%,
rgba(255, 255, 255, .55) 50%, rgba(255, 255, 255, .4) 55%, transparent);
animation: sheenSweep $duration $ease-out infinite;
}
@keyframes flareUp {
0%, 36% { opacity: 0; }
44%, 50% { opacity: 1; }
58%, 100% { opacity: 0; }
}
06나무 결 미닫이 패널
어떤 움직임인가
나무 결 두 짝이 좌우로 갈라지며 안쪽 밝은 슬롯을 드러내고, 잠시 열린 채로 있다가 다시 닫힙니다.

<main class="stage">
<section class="wooddoors" aria-label="나무 결 미닫이 패널">
<div class="wooddoors__frame">
<div class="wooddoors__slot">
<p class="wooddoors__slot-title">두 번째 이야기</p>
<p class="wooddoors__slot-desc">나무를 열면 밝은 내용</p>
</div>
<div class="wooddoors__leaf wooddoors__leaf--l" aria-hidden="true">
<span class="wooddoors__knot"></span>
<span class="wooddoors__handle"></span>
</div>
<div class="wooddoors__leaf wooddoors__leaf--r" aria-hidden="true">
<span class="wooddoors__knot wooddoors__knot--low"></span>
<span class="wooddoors__handle"></span>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 나무 — 세로 결 repeating-linear-gradient + 매듭 radial-gradient. 사진 없이 결이 흐른다
$wood-a: #b57e46; // 결 밝은 톤
$wood-b: #a96f38; // 결 중간
$wood-c: #c08a4e; // 결 가장 밝게
$wood-d: #96622e; // 결 어두운 톤
$wood-lo: #906a47; // 패널 아래 그늘
$brass: #ddd3ba; // 놋쇠 손잡이
$duration: $dur-loop; // 열리고 잠시 머물고 닫히는 한 주기
$easing: $ease-spring; // 미닫이는 끝에서 살짝 튕긴다
$color: $wood-a;
@include stage($stage-yellow);
.wooddoors {
display: grid;
place-items: center;
}
// 틀 — 안쪽 내용을 감싸고 두 짝이 틀 밖으로 나가면 잘린다 (overflow)
.wooddoors__frame {
position: relative;
width: clamp(212px, 66vw, 300px);
aspect-ratio: 16 / 10;
border-radius: $r-card;
overflow: hidden;
background: #2d1c0d;
box-shadow: 0 10px 16px rgba(23, 20, 26, .35), inset 0 0 0 4px #241608;
}
// 틀 안 밝은 슬롯 — 나무가 열려야 보이는 내용
.wooddoors__slot {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
padding: $sp-4;
border-radius: $r-card;
background: $subject-cream;
}
.wooddoors__slot-title {
margin: 0;
color: #574a38;
font-weight: 800; font-size: clamp(13px, 3.4vw, 17px); line-height: 1.2;
font-family: inherit;
}
.wooddoors__slot-desc {
margin: 0;
color: #7a6c56;
font-weight: 500; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.3;
font-family: inherit;
}
// 나무 짝 — 세로 결 줄무늬가 짝과 함께 움직인다 (gradient 가 배경이라 결이 "흐르는" 게 아니라 짝이 통째로 이동)
.wooddoors__leaf {
position: absolute;
top: 0; bottom: 0;
width: 50.5%; // 가운데 살짝 겹친다 — 닫았을 때 틈이 안 보인다
background:
radial-gradient(ellipse 14px 22px at 62% 30%, rgba(74, 44, 12, .55) 0 38%, rgba($wood-c, .5) 42% 52%, transparent 60%),
repeating-linear-gradient(90deg,
$wood-a 0 12px, $wood-b 12px 15px, $wood-c 15px 27px, $wood-d 27px 30px, $wood-a 30px 38px, $wood-lo 38px 41px);
box-shadow: inset 0 2px 0 rgba(255, 230, 190, .3), inset 0 -4px 0 rgba(45, 25, 6, .55);
animation: doorsOpenL $duration $easing infinite;
}
.wooddoors__leaf--l { left: 0; }
.wooddoors__leaf--r {
right: 0;
animation-name: doorsOpenR;
}
// 매듭(나이테)과 놋쇠 손잡이
.wooddoors__knot {
position: absolute;
top: 22%; left: 16%;
width: 28px;
height: 44px;
border-radius: 50%;
background: repeating-radial-gradient(ellipse at 50% 50%, rgba(74, 44, 12, .5) 0 2px, rgba($wood-c, .4) 2px 5px);
}
.wooddoors__knot--low { top: 48%; left: auto; right: 20%; }
.wooddoors__handle {
position: absolute;
top: calc(50% - 8px);
width: 8px;
height: 16px;
border-radius: $r-pill;
background: linear-gradient(90deg, #c9a24e, $brass 55%, #b09760);
box-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
.wooddoors__leaf--l .wooddoors__handle { right: 12px; }
.wooddoors__leaf--r .wooddoors__handle { left: 12px; }
// 두 짝이 90% 넘게 열렸다 닫힌다 — 결 줄무늬가 짝을 타고 이동한다. 텔레포트 없다
@keyframes doorsOpenL {
0%, 10% { transform: translateX(0); }
42%, 60% { transform: translateX(-92%); }
88%, 100% { transform: translateX(0); }
}
@keyframes doorsOpenR {
0%, 10% { transform: translateX(0); }
42%, 60% { transform: translateX(92%); }
88%, 100% { transform: translateX(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.wooddoors__slot { padding: $sp-3; }
.wooddoors__knot { width: 20px; height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.wooddoors__leaf { animation: none !important; transform: translateX(-92%); }
.wooddoors__leaf--r { transform: translateX(92%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wooddoors {
display: grid;
place-items: center;
}
.wooddoors__frame {
position: relative;
width: clamp(212px, 66vw, 300px);
aspect-ratio: 16/10;
border-radius: 16px;
overflow: hidden;
background: #2d1c0d;
box-shadow: 0 10px 16px rgba(23, 20, 26, 0.35), inset 0 0 0 4px #241608;
}
.wooddoors__slot {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 16px;
border-radius: 16px;
background: #fff7e6;
}
.wooddoors__slot-title {
margin: 0;
color: #574a38;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 17px);
line-height: 1.2;
font-family: inherit;
}
.wooddoors__slot-desc {
margin: 0;
color: #7a6c56;
font-weight: 500;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.3;
font-family: inherit;
}
.wooddoors__leaf {
position: absolute;
top: 0;
bottom: 0;
width: 50.5%;
background: radial-gradient(ellipse 14px 22px at 62% 30%, rgba(74, 44, 12, 0.55) 0 38%, rgba(192, 138, 78, 0.5) 42% 52%, transparent 60%), repeating-linear-gradient(90deg, #b57e46 0 12px, #a96f38 12px 15px, #c08a4e 15px 27px, #96622e 27px 30px, #b57e46 30px 38px, #906a47 38px 41px);
box-shadow: inset 0 2px 0 rgba(255, 230, 190, 0.3), inset 0 -4px 0 rgba(45, 25, 6, 0.55);
animation: doorsOpenL 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wooddoors__leaf--l {
left: 0;
}
.wooddoors__leaf--r {
right: 0;
animation-name: doorsOpenR;
}
.wooddoors__knot {
position: absolute;
top: 22%;
left: 16%;
width: 28px;
height: 44px;
border-radius: 50%;
background: repeating-radial-gradient(ellipse at 50% 50%, rgba(74, 44, 12, 0.5) 0 2px, rgba(192, 138, 78, 0.4) 2px 5px);
}
.wooddoors__knot--low {
top: 48%;
left: auto;
right: 20%;
}
.wooddoors__handle {
position: absolute;
top: calc(50% - 8px);
width: 8px;
height: 16px;
border-radius: 999px;
background: linear-gradient(90deg, #c9a24e, #ddd3ba 55%, #b09760);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.wooddoors__leaf--l .wooddoors__handle {
right: 12px;
}
.wooddoors__leaf--r .wooddoors__handle {
left: 12px;
}
@keyframes doorsOpenL {
0%, 10% {
transform: translateX(0);
}
42%, 60% {
transform: translateX(-92%);
}
88%, 100% {
transform: translateX(0);
}
}
@keyframes doorsOpenR {
0%, 10% {
transform: translateX(0);
}
42%, 60% {
transform: translateX(92%);
}
88%, 100% {
transform: translateX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.wooddoors__slot {
padding: 12px;
}
.wooddoors__knot {
width: 20px;
height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.wooddoors__leaf {
animation: none !important;
transform: translateX(-92%);
}
.wooddoors__leaf--r {
transform: translateX(92%);
}
}import type { CSSProperties } from "react";
import styles from "./WoodPanelDoors.module.scss";
export type WoodPanelDoorsProps = {
title?: string;
desc?: string;
/** 스크린리더용 섹션 이름 (기본 "나무 결 미닫이 패널") */
ariaLabel?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function WoodPanelDoors({
title = "두 번째 이야기",
desc = "나무를 열면 밝은 내용",
ariaLabel = "나무 결 미닫이 패널",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b57e46",
}: WoodPanelDoorsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.wooddoors} style={vars} aria-label={ariaLabel}>
<div className={styles.frame}>
<div className={styles.slot}>
<p className={styles.slotTitle}>{title}</p>
<p className={styles.slotDesc}>{desc}</p>
</div>
<div className={`${styles.leaf} ${styles.leafL}`} aria-hidden="true">
<span className={styles.knot} />
<span className={styles.handle} />
</div>
<div className={`${styles.leaf} ${styles.leafR}`} aria-hidden="true">
<span className={`${styles.knot} ${styles.knotLow}`} />
<span className={styles.handle} />
</div>
</div>
</section>
);
}06 · 나무 결 미닫이 패널 — 측정 누적면적 38.896% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
결 줄무늬는 패널 배경이라 패널이 움직이면 결도 함께 흘러, 사진을 밀어 놓은 것과 같은 효과가 납니다.
.wooddoors__leaf {
background:
radial-gradient(ellipse 14px 22px at 62% 30%, rgba(74, 44, 12, .55) 0 38%,
rgba($wood-c, .5) 42% 52%, transparent 60%),
repeating-linear-gradient(90deg,
$wood-a 0 12px, $wood-b 12px 15px, $wood-c 15px 27px, $wood-d 27px 30px,
$wood-a 30px 38px, $wood-lo 38px 41px);
}
@keyframes doorsOpenL {
0%, 10% { transform: translateX(0); }
42%, 60% { transform: translateX(-92%); }
88%, 100% { transform: translateX(0); }
}
07스티커 엠보싱 로고
무엇이 보이나
엠보싱 로고 스티커가 24px 위에서 기울어진 채 내려와 붙는 자리에 앉습니다. 음각은 text-shadow 네 겹입니다.

<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">네 겹 그림자로 음각을 낸다</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 엠보싱 — text-shadow 네 겹(아래 밝게·위 어둡게·좌우 보정)으로 글자를 파낸다.
// 로고 글자색은 스티커 바탕과 거의 같게 — 명암은 그림자가 만든다 (진짜 음각의 원리)
$sticker-face: #f4ead7; // 스티커 면
$sticker-rim: #ffffff; // 흰 테 (오려낸 자국)
$emboss-ink: #e2d4b8; // 로고 본체 — 면과 아주 가깝게
$emboss-dark: rgba(96, 74, 48, .62); // 위로 진 그늘
$emboss-light: rgba(255, 255, 255, .95); // 아래로 받은 빛
$duration: $dur-loop; // 스티커가 내려와 붙고 다시 뜨는 한 주기
$easing: $ease-pop; // 손으로 누르는 탄성
$color: $sticker-face;
@include stage($stage-orange);
.emboss {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
// 바닥 그림자 — 스티커가 높이 떠 있을 때 크고 연하다
.emboss__shadow {
width: clamp(160px, 50vw, 216px);
height: 16px;
border-radius: 50%;
background: radial-gradient(50% 50% at 50% 50%, rgba(23, 20, 26, .5), transparent 70%);
animation: shadowShrink $duration $easing infinite;
}
// 스티커 — 흰 테를 두르고 살짝 기울여 붙인다
.emboss__sticker {
box-sizing: border-box;
width: clamp(176px, 54vw, 236px);
padding: $sp-4 $sp-5;
border: 6px solid $sticker-rim;
border-radius: $r-card;
background:
radial-gradient(130% 130% at 30% 22%, rgba(255, 255, 255, .55), transparent 46%),
$sticker-face;
box-shadow: 0 4px 8px rgba(23, 20, 26, .28);
text-align: center;
animation: stickerApply $duration $easing infinite;
}
// 음각 로고 — 네 겹 text-shadow. 아래는 빛, 위는 그늘 → 파인 글자로 읽힌다
.emboss__brand {
margin: 0;
color: $emboss-ink;
font-weight: 900; font-size: clamp(24px, 7vw, 38px); line-height: 1.05;
font-family: inherit;
letter-spacing: .12em;
text-shadow:
0 1px 0 $emboss-light,
0 -1px 0 $emboss-dark,
1px 0 0 rgba(255, 255, 255, .55),
-1px 0 0 rgba(96, 74, 48, .35);
}
.emboss__sub {
margin: $sp-1 0 0;
color: $emboss-ink;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
letter-spacing: .34em;
text-shadow:
0 1px 0 $emboss-light,
0 -1px 0 $emboss-dark;
}
.emboss__caption {
margin: 0;
color: $subject-ink;
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 24px 위·기울어짐·1.12배에서 내려와 −3도로 착지 → 살짝 들렸다 정착 → 다시 뜬다
@keyframes stickerApply {
0% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
40% { transform: translateY(0) rotate(-3deg) scale(1); }
50% { transform: translateY(-2px) rotate(-3.6deg) scale(1.01); }
60%, 88% { transform: translateY(0) rotate(-3deg) scale(1); }
100% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
}
// 그림자는 착지 순간 작고 진해진다
@keyframes shadowShrink {
0% { transform: scaleX(1.4); opacity: .55; }
40%, 88% { transform: scaleX(.9); opacity: .9; }
100% { transform: scaleX(1.4); opacity: .55; }
}
@media (max-width: 340px), (max-height: 220px) {
.emboss__sticker { padding: $sp-3 $sp-4; border-width: 4px; }
.emboss__caption { display: none; }
.emboss { gap: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.emboss__sticker { animation: none !important; transform: rotate(-3deg); }
.emboss__shadow { animation: none !important; transform: scaleX(.9); opacity: .9; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.emboss {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.emboss__shadow {
width: clamp(160px, 50vw, 216px);
height: 16px;
border-radius: 50%;
background: radial-gradient(50% 50% at 50% 50%, rgba(23, 20, 26, 0.5), transparent 70%);
animation: shadowShrink 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.emboss__sticker {
box-sizing: border-box;
width: clamp(176px, 54vw, 236px);
padding: 16px 20px;
border: 6px solid #ffffff;
border-radius: 16px;
background: radial-gradient(130% 130% at 30% 22%, rgba(255, 255, 255, 0.55), transparent 46%), #f4ead7;
box-shadow: 0 4px 8px rgba(23, 20, 26, 0.28);
text-align: center;
animation: stickerApply 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.emboss__brand {
margin: 0;
color: #e2d4b8;
font-weight: 900;
font-size: clamp(24px, 7vw, 38px);
line-height: 1.05;
font-family: inherit;
letter-spacing: 0.12em;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.95), 0 -1px 0 rgba(96, 74, 48, 0.62), 1px 0 0 rgba(255, 255, 255, 0.55), -1px 0 0 rgba(96, 74, 48, 0.35);
}
.emboss__sub {
margin: 4px 0 0;
color: #e2d4b8;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.34em;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.95), 0 -1px 0 rgba(96, 74, 48, 0.62);
}
.emboss__caption {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes stickerApply {
0% {
transform: translateY(-24px) rotate(-8deg) scale(1.12);
}
40% {
transform: translateY(0) rotate(-3deg) scale(1);
}
50% {
transform: translateY(-2px) rotate(-3.6deg) scale(1.01);
}
60%, 88% {
transform: translateY(0) rotate(-3deg) scale(1);
}
100% {
transform: translateY(-24px) rotate(-8deg) scale(1.12);
}
}
@keyframes shadowShrink {
0% {
transform: scaleX(1.4);
opacity: 0.55;
}
40%, 88% {
transform: scaleX(0.9);
opacity: 0.9;
}
100% {
transform: scaleX(1.4);
opacity: 0.55;
}
}
@media (max-width: 340px), (max-height: 220px) {
.emboss__sticker {
padding: 12px 16px;
border-width: 4px;
}
.emboss__caption {
display: none;
}
.emboss {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.emboss__sticker {
animation: none !important;
transform: rotate(-3deg);
}
.emboss__shadow {
animation: none !important;
transform: scaleX(0.9);
opacity: 0.9;
}
}import type { CSSProperties } from "react";
import styles from "./StickerEmbossLogo.module.scss";
export type StickerEmbossLogoProps = {
brand?: string;
sub?: string;
caption?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StickerEmbossLogo({
brand = "STUDIO",
sub = "QUALITY PRESS",
caption = "네 겹 그림자로 음각을 낸다",
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#f4ead7",
}: StickerEmbossLogoProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.emboss} style={vars}>
<div className={styles.shadow} aria-hidden="true" />
<div className={styles.sticker}>
<p className={styles.brand}>{brand}</p>
<p className={styles.sub}>{sub}</p>
</div>
<p className={styles.caption}>{caption}</p>
</section>
);
}07 · 스티커 엠보싱 로고 — 측정 누적면적 20.042% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
아래로 밝은 빛 한 줄, 위로 어두운 그늘 한 줄, 좌우 보정 두 줄 — 이 네 개가 모여야 파인 글자로 읽힙니다.
.emboss__brand {
color: $emboss-ink;
text-shadow:
0 1px 0 $emboss-light,
0 -1px 0 $emboss-dark,
1px 0 0 rgba(255, 255, 255, .55),
-1px 0 0 rgba(96, 74, 48, .35);
}
@keyframes stickerApply {
0% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
40% { transform: translateY(0) rotate(-3deg) scale(1); }
50% { transform: translateY(-2px) rotate(-3.6deg) scale(1.01); }
60%, 88% { transform: translateY(0) rotate(-3deg) scale(1); }
100% { transform: translateY(-24px) rotate(-8deg) scale(1.12); }
}
08물리적으로 눌리는 버튼
화면에서 일어나는 일
키캡이 8px 내려앉는 동안 밑의 옆벽 요소가 scaleY로 4분의 1로 줄어 누르는 깊이를 만듭니다.

<main class="stage">
<section class="physkey">
<div class="physkey__well">
<span class="physkey__wall" aria-hidden="true"></span>
<button class="physkey__cap" type="button">
<span class="physkey__label">시작하기</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
</div>
<p class="physkey__caption">8px 내려앉고 벽이 줄어든다</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스큐어모픽 키 — 윗면(cap)·옆벽(wall)·홈(well) 셋으로 물리적 눌림을 만든다.
// 벽은 box-shadow 가 아니라 별도 요소: scaleY 로 줄어드는 것도 transform 이다
$key-face-1: #b5c0da; // 윗면 밝은 모서리
$key-face-2: $subject-blue; // 윗면 (토큰)
$key-face-3: #5d74af; // 윗면 어두운 모서리
$wall: #5067a2; // 옆벽
$wall-deep: #475b90; // 옆벽 아래
$well: #d7dbe4; // 키가 빠져나가는 홈
$duration: $dur-loop; // 눌림 → 유지 → 복귀 한 주기
$easing: $ease-spring; // 놓으면 스프링처럼 튀어 오른다
$color: $key-face-2;
@include stage($stage-paper);
.physkey {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
// 홈 — 키가 앉는 구멍. 윗면보다 넓다
.physkey__well {
position: relative;
box-sizing: border-box;
width: clamp(180px, 56vw, 236px);
padding: 8px;
border-radius: $r-control;
background: linear-gradient(180deg, #c3c8d4, $well 30%, #e3e6ec);
box-shadow: inset 0 2px 5px rgba(23, 20, 26, .3);
}
// 옆벽 — cap 아래의 두께. 눌리면 scaleY 로 4분의 1이 된다 (transform-origin top)
.physkey__wall {
position: absolute;
left: 8px; right: 8px;
top: 8px;
height: 8px;
border-radius: 0 0 $r-control $r-control;
background: linear-gradient(180deg, $wall, $wall-deep);
transform-origin: 50% 0; // 규칙8: 옆벽 윗변 붙어 압축
animation: wallPress $duration $easing infinite;
}
// 윗면 — 8px 내려앉는다. 라벨과 아이콘이 같이 내려간다
.physkey__cap {
position: relative;
z-index: 1;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-2;
width: 100%;
padding: $sp-3 $sp-4;
border: 0;
border-radius: $r-control;
background: linear-gradient(180deg, $key-face-1 0%, $key-face-2 28%, $key-face-3 100%);
color: #ffffff;
font-weight: 800; font-size: clamp(13px, 3.2vw, 16px); line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 255, 255, .4), inset 0 -2px 0 rgba(0, 0, 0, .25);
cursor: pointer;
animation: capPress $duration $easing infinite;
}
.physkey__cap:focus-visible {
outline: 3px solid #5d74af;
outline-offset: 3px;
}
.physkey__label {
font-family: inherit;
}
.physkey__cap svg {
width: 1.1em;
height: 1.1em;
}
.physkey__caption {
margin: 0;
color: #5c5048;
font-weight: 700; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
font-family: inherit;
}
// 눌림: cap 8px 하강 ↔ 벽 scaleY .25. 놓으면 한 번 튀어 오른 뒤 정지 — 벽도 살짝 늘어난다
@keyframes capPress {
0%, 12% { transform: translateY(0); }
32%, 46% { transform: translateY(8px); }
62% { transform: translateY(-3px); }
76%, 100% { transform: translateY(0); }
}
@keyframes wallPress {
0%, 12% { transform: scaleY(1); }
32%, 46% { transform: scaleY(.25); }
62% { transform: scaleY(1.1); }
76%, 100% { transform: scaleY(1); }
}
@media (max-width: 340px), (max-height: 220px) {
.physkey__well { padding: 4px; width: 188px; }
.physkey__wall { left: 4px; right: 4px; top: 4px; height: 8px; }
.physkey__caption { display: none; }
.physkey { gap: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.physkey__cap { animation: none !important; }
.physkey__wall { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.physkey {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.physkey__well {
position: relative;
box-sizing: border-box;
width: clamp(180px, 56vw, 236px);
padding: 8px;
border-radius: 12px;
background: linear-gradient(180deg, #c3c8d4, #d7dbe4 30%, #e3e6ec);
box-shadow: inset 0 2px 5px rgba(23, 20, 26, 0.3);
}
.physkey__wall {
position: absolute;
left: 8px;
right: 8px;
top: 8px;
height: 8px;
border-radius: 0 0 12px 12px;
background: linear-gradient(180deg, #5067a2, #475b90);
transform-origin: 50% 0;
animation: wallPress 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.physkey__cap {
position: relative;
z-index: 1;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
border: 0;
border-radius: 12px;
background: linear-gradient(180deg, #b5c0da 0%, #2f6df6 28%, #5d74af 100%);
color: #ffffff;
font-weight: 800;
font-size: clamp(13px, 3.2vw, 16px);
line-height: 1;
font-family: inherit;
box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.4), inset 0 -2px 0 rgba(0, 0, 0, 0.25);
cursor: pointer;
animation: capPress 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.physkey__cap:focus-visible {
outline: 3px solid #5d74af;
outline-offset: 3px;
}
.physkey__label {
font-family: inherit;
}
.physkey__cap svg {
width: 1.1em;
height: 1.1em;
}
.physkey__caption {
margin: 0;
color: #5c5048;
font-weight: 700;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
font-family: inherit;
}
@keyframes capPress {
0%, 12% {
transform: translateY(0);
}
32%, 46% {
transform: translateY(8px);
}
62% {
transform: translateY(-3px);
}
76%, 100% {
transform: translateY(0);
}
}
@keyframes wallPress {
0%, 12% {
transform: scaleY(1);
}
32%, 46% {
transform: scaleY(0.25);
}
62% {
transform: scaleY(1.1);
}
76%, 100% {
transform: scaleY(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.physkey__well {
padding: 4px;
width: 188px;
}
.physkey__wall {
left: 4px;
right: 4px;
top: 4px;
height: 8px;
}
.physkey__caption {
display: none;
}
.physkey {
gap: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.physkey__cap {
animation: none !important;
}
.physkey__wall {
animation: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./PressableKey.module.scss";
export type PressableKeyProps = {
label?: string;
caption?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressableKey({
label = "시작하기",
caption = "8px 내려앉고 벽이 줄어든다",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PressableKeyProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.physkey} style={vars}>
<div className={styles.well}>
<span className={styles.wall} aria-hidden="true" />
<button type="button" className={styles.cap}>
<span className={styles.label}>{label}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
</div>
<p className={styles.caption}>{caption}</p>
</section>
);
}08 · 물리적으로 눌리는 버튼 — 측정 누적면적 7.858% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
벽을 box-shadow로 그리지 않고 별도 요소로 둔 것은, 눌림 애니메이션을 그림자가 아니라 변환으로 처리하기 위해서입니다.
.physkey__wall {
height: 8px;
transform-origin: 50% 0;
animation: wallPress $duration $ease-spring infinite;
}
@keyframes capPress {
0%, 12% { transform: translateY(0); }
32%, 46% { transform: translateY(8px); }
62% { transform: translateY(-3px); }
76%, 100% { transform: translateY(0); }
}
@keyframes wallPress {
0%, 12% { transform: scaleY(1); }
32%, 46% { transform: scaleY(.25); }
62% { transform: scaleY(1.1); }
76%, 100% { transform: scaleY(1); }
}
09질감 세 곳만 쓴 원페이지
어떤 움직임인가
미니 랜딩 페이지 전체가 위아래로 왕복 스크롤되며 구간을 지나갑니다. 질감은 나무 내비·금속 다이얼·가죽 요금 카드 세 곳에만 쓰고 나머지는 밝은 평면입니다.

<main class="stage">
<section class="minipage" aria-label="질감 세 곳만 쓴 미니 랜딩">
<div class="minipage__frame">
<div class="minipage__track">
<header class="minipage__nav">
<span class="minipage__navdot"></span>
<span class="minipage__navlink"></span>
<span class="minipage__navlink"></span>
</header>
<section class="minipage__hero">
<p class="minipage__hero-eyebrow">질감 랜딩</p>
<span class="minipage__hero-bar"></span>
<span class="minipage__hero-bar minipage__hero-bar--short"></span>
<span class="minipage__hero-btn">시작하기</span>
</section>
<section class="minipage__features">
<article class="minipage__fcard"><span class="minipage__fdot"></span><span class="minipage__fline"></span></article>
<article class="minipage__fcard"><span class="minipage__fdot minipage__fdot--b"></span><span class="minipage__fline"></span></article>
<article class="minipage__fcard"><span class="minipage__fdot minipage__fdot--c"></span><span class="minipage__fline"></span></article>
</section>
<section class="minipage__cta">
<span class="minipage__dial" aria-hidden="true"><span class="minipage__dial-cap"></span></span>
<span class="minipage__cta-btn">무료로 시작</span>
</section>
<section class="minipage__pricing">
<p class="minipage__plan">Pro 멤버십</p>
<p class="minipage__price">월 24,000원</p>
<span class="minipage__stitch" aria-hidden="true"></span>
</section>
<section class="minipage__quote">
<span class="minipage__avatar"></span>
<span class="minipage__qline"></span>
<span class="minipage__qline minipage__qline--short"></span>
</section>
<section class="minipage__faq">
<span class="minipage__qrow"></span>
<span class="minipage__qrow minipage__qrow--open">
<span class="minipage__qbody"></span>
</span>
<span class="minipage__qrow"></span>
</section>
<footer class="minipage__foot">
<span class="minipage__footdot"></span>
<span class="minipage__footdot"></span>
<span class="minipage__footdot"></span>
</footer>
</div>
</div>
<div class="minipage__notes">
<p class="minipage__note minipage__note--tex">질감 3곳 — 나무 내비 · 금속 다이얼 · 가죽 카드</p>
<p class="minipage__note">나머지 구간은 밝은 평면으로 대비</p>
<p class="minipage__note">스크롤이 질감의 자리를 알려준다</p>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유형 F 마무리 부품 — 원페이지 미리보기. 질감은 세 곳(나무 내비·금속 다이얼·가죽 카드)에만,
// 나머지 구간은 밝은 평면(230 신뢰 톤 계열)으로 대비시킨다 — 9곳 전부 질감이면 촌스러워진다
$mp-flat: #f7f5f0; // 평면 구간 바탕
$mp-flat-2: #eceae3; // 평면 구간 보조
$mp-ink: #2a2732; // 평면 위 글자
$mp-line: #d8d4c9; // 평면 선·막대
$duration: $dur-loop; // 위 → 아래 → 위 한 번 스크롤
$easing: $ease-out; // 스크롤은 관성을 두고 감속
$color: $mp-flat;
@include stage($stage-ink);
.minipage {
display: flex;
align-items: center;
gap: $sp-4;
}
// 뷰포트 — 안에서 트랙이 스크롤된다
.minipage__frame {
position: relative;
width: clamp(184px, 58vw, 224px);
height: clamp(208px, 64vh, 252px);
border-radius: $r-card;
overflow: hidden;
box-shadow: 0 12px 20px rgba(0, 0, 0, .5), inset 0 0 0 1px rgba(255, 255, 255, .08);
}
// 트랙 — 높이가 뷰포트의 정확히 2배. translateY(-50%) 가 한 화면만큼 = 아래 절반
.minipage__track {
display: flex;
flex-direction: column;
height: 200%;
animation: pageScroll $duration $easing infinite;
}
// ① 나무 내비 (질감 1)
.minipage__nav {
display: flex;
align-items: center;
gap: $sp-2;
box-sizing: border-box;
height: 12.5%;
padding: 0 $sp-3;
background: repeating-linear-gradient(90deg, #b57e46 0 10px, #a96f38 10px 13px, #c08a4e 13px 24px, #96622e 24px 27px);
border-bottom: 2px solid rgba(45, 25, 6, .55);
}
.minipage__navdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2d1c0d;
}
.minipage__navlink {
width: 26px;
height: 4px;
border-radius: $r-pill;
background: rgba(45, 25, 6, .5);
}
// ② 히어로 — 평면. 질감 없음
.minipage__hero {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-2;
height: 17%;
padding: $sp-3 $sp-4 0;
background: $mp-flat;
}
.minipage__hero-eyebrow {
margin: 0;
color: $mp-ink;
font-weight: 800; font-size: clamp(13px, 3.4vw, 16px); line-height: 1;
font-family: inherit;
}
.minipage__hero-bar {
width: 76%;
height: 7px;
border-radius: $r-pill;
background: $mp-line;
}
.minipage__hero-bar--short { width: 48%; }
.minipage__hero-btn {
margin-top: $sp-1;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $subject-blue;
color: #ffffff;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
}
// ③ 기능 카드 — 종이 결 (미세 repeating-gradient)
.minipage__features {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
height: 14.5%;
padding: $sp-2 $sp-4;
background: $mp-flat;
}
.minipage__fcard {
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-sm;
background:
repeating-linear-gradient(0deg, rgba(122, 105, 78, .07) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgba(122, 105, 78, .05) 0 1px, transparent 1px 4px),
$subject-cream;
box-shadow: 0 2px 4px rgba(23, 20, 26, .22);
}
.minipage__fdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: $subject-blue;
}
.minipage__fdot--b { background: $subject-mint; }
.minipage__fdot--c { background: $subject-pink; }
.minipage__fline {
width: 80%;
height: 5px;
border-radius: $r-pill;
background: rgba(87, 74, 56, .22);
}
// ④ 다이얼 CTA (질감 2) — 평면 띠 위에 금속 다이얼
.minipage__cta {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-3;
height: 14.5%;
padding: $sp-2 $sp-4;
background: $mp-flat-2;
}
.minipage__dial {
position: relative;
width: clamp(40px, 12vw, 48px);
aspect-ratio: 1;
border-radius: 50%;
background:
conic-gradient(from 210deg, #f4f5f8 0deg, #6f7481 70deg, #aab0bc 120deg, #f4f5f8 180deg, #4b4f59 250deg, #aab0bc 300deg, #f4f5f8 360deg);
box-shadow: 0 3px 5px rgba(23, 20, 26, .4), inset 0 1px 1px rgba(255, 255, 255, .6);
flex: 0 0 auto;
}
.minipage__dial::after {
content: "";
position: absolute;
top: 30%; left: 30%; right: 30%; bottom: 30%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, $subject-blue 55%, #5d74af);
}
.minipage__cta-btn {
border-radius: $r-pill;
padding: $sp-1 $sp-3;
background: $subject-ink;
color: #ffffff;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1;
font-family: inherit;
}
// ⑤ 가죽 요금 카드 (질감 3)
.minipage__pricing {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-1;
height: 14.5%;
padding: $sp-2 $sp-4;
background:
repeating-linear-gradient(34deg, rgba(94, 53, 23, .4) 0 2px, transparent 2px 5px),
#7a4a2b;
}
.minipage__plan {
margin: 0;
color: #f6e7cf;
font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); line-height: 1;
font-family: inherit;
letter-spacing: .12em;
}
.minipage__price {
margin: 0;
color: #ffffff;
font-weight: 800; font-size: clamp(12px, 3.2vw, 15px); line-height: 1;
font-family: inherit;
}
.minipage__stitch {
position: absolute;
top: 4px; left: 8px; right: 8px; bottom: 4px;
border-radius: $r-sm;
background:
repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) top / 100% 2px no-repeat,
repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) bottom / 100% 2px no-repeat,
repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) left / 2px 100% no-repeat,
repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) right / 2px 100% no-repeat;
}
// ⑥ 후기 — 평면
.minipage__quote {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-1;
height: 11.5%;
padding: $sp-2 $sp-4;
background: $mp-flat;
}
.minipage__avatar {
order: -1;
width: 14px;
height: 14px;
border-radius: 50%;
background: $subject-mint;
}
.minipage__qline {
width: 88%;
height: 5px;
border-radius: $r-pill;
background: $mp-line;
}
.minipage__qline--short { width: 60%; }
// ⑦ FAQ — 평면 아코디언 (하나 열린 모습)
.minipage__faq {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
height: 10%;
padding: $sp-2 $sp-4;
background: $mp-flat-2;
}
.minipage__qrow {
display: flex;
align-items: center;
box-sizing: border-box;
height: 14px;
border-radius: $r-xs;
background: #ffffff;
box-shadow: 0 1px 2px rgba(23, 20, 26, .16);
padding: 0 $sp-2;
}
.minipage__qrow::before {
content: "";
width: 44%;
height: 4px;
border-radius: $r-pill;
background: $mp-line;
}
.minipage__qrow--open {
height: 26px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: $sp-1;
border: 2px solid $subject-blue;
}
.minipage__qbody {
width: 78%;
height: 4px;
border-radius: $r-pill;
background: $mp-line;
}
// ⑧ 푸터
.minipage__foot {
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-1;
height: 5.5%;
padding: 0 $sp-4;
background: $subject-ink;
}
.minipage__footdot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, .4);
}
// 옆 주석 — 480px 에서만. 320px 에서는 프레임이 전부
.minipage__notes {
display: flex;
flex-direction: column;
gap: $sp-2;
max-width: 152px;
}
.minipage__note {
margin: 0;
color: rgba(255, 255, 255, .85);
font-weight: 500; font-size: 11px; line-height: 1.5;
font-family: inherit;
}
.minipage__note--tex {
color: $stage-yellow;
font-weight: 700;
}
// 핑퐁 스크롤 — 트랙이 정확히 200% 라 -50% 가 한 화면. 텔레포트 없이 위로 돌아온다
@keyframes pageScroll {
0%, 10% { transform: translateY(0); }
44%, 54% { transform: translateY(-50%); }
88%, 100% { transform: translateY(0); }
}
@media (max-width: 340px), (max-height: 220px) {
.minipage__notes { display: none; }
.minipage__frame { width: 188px; height: 164px; }
.minipage__hero { padding-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.minipage__track { animation: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.minipage {
display: flex;
align-items: center;
gap: 16px;
}
.minipage__frame {
position: relative;
width: clamp(184px, 58vw, 224px);
height: clamp(208px, 64vh, 252px);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 20px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.minipage__track {
display: flex;
flex-direction: column;
height: 200%;
animation: pageScroll 2s ease-out infinite;
}
.minipage__nav {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
height: 12.5%;
padding: 0 12px;
background: repeating-linear-gradient(90deg, #b57e46 0 10px, #a96f38 10px 13px, #c08a4e 13px 24px, #96622e 24px 27px);
border-bottom: 2px solid rgba(45, 25, 6, 0.55);
}
.minipage__navdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2d1c0d;
}
.minipage__navlink {
width: 26px;
height: 4px;
border-radius: 999px;
background: rgba(45, 25, 6, 0.5);
}
.minipage__hero {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
height: 17%;
padding: 12px 16px 0;
background: #f7f5f0;
}
.minipage__hero-eyebrow {
margin: 0;
color: #2a2732;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1;
font-family: inherit;
}
.minipage__hero-bar {
width: 76%;
height: 7px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__hero-bar--short {
width: 48%;
}
.minipage__hero-btn {
margin-top: 4px;
border-radius: 999px;
padding: 4px 12px;
background: #2f6df6;
color: #ffffff;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
}
.minipage__features {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
height: 14.5%;
padding: 8px 16px;
background: #f7f5f0;
}
.minipage__fcard {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: 8px;
background: repeating-linear-gradient(0deg, rgba(122, 105, 78, 0.07) 0 1px, transparent 1px 3px), repeating-linear-gradient(90deg, rgba(122, 105, 78, 0.05) 0 1px, transparent 1px 4px), #fff7e6;
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.22);
}
.minipage__fdot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2f6df6;
}
.minipage__fdot--b {
background: #4cd4a6;
}
.minipage__fdot--c {
background: #ff9ebb;
}
.minipage__fline {
width: 80%;
height: 5px;
border-radius: 999px;
background: rgba(87, 74, 56, 0.22);
}
.minipage__cta {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 12px;
height: 14.5%;
padding: 8px 16px;
background: #eceae3;
}
.minipage__dial {
position: relative;
width: clamp(40px, 12vw, 48px);
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(from 210deg, #f4f5f8 0deg, #6f7481 70deg, #aab0bc 120deg, #f4f5f8 180deg, #4b4f59 250deg, #aab0bc 300deg, #f4f5f8 360deg);
box-shadow: 0 3px 5px rgba(23, 20, 26, 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.6);
flex: 0 0 auto;
}
.minipage__dial::after {
content: "";
position: absolute;
top: 30%;
left: 30%;
right: 30%;
bottom: 30%;
border-radius: 50%;
background: radial-gradient(120% 120% at 32% 28%, #c4cde2, #2f6df6 55%, #5d74af);
}
.minipage__cta-btn {
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
color: #ffffff;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
font-family: inherit;
}
.minipage__pricing {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
height: 14.5%;
padding: 8px 16px;
background: repeating-linear-gradient(34deg, rgba(94, 53, 23, 0.4) 0 2px, transparent 2px 5px), #7a4a2b;
}
.minipage__plan {
margin: 0;
color: #f6e7cf;
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1;
font-family: inherit;
letter-spacing: 0.12em;
}
.minipage__price {
margin: 0;
color: #ffffff;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 15px);
line-height: 1;
font-family: inherit;
}
.minipage__stitch {
position: absolute;
top: 4px;
left: 8px;
right: 8px;
bottom: 4px;
border-radius: 8px;
background: repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) top/100% 2px no-repeat, repeating-linear-gradient(90deg, #f3e2c8 0 6px, transparent 6px 12px) bottom/100% 2px no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) left/2px 100% no-repeat, repeating-linear-gradient(0deg, #f3e2c8 0 6px, transparent 6px 12px) right/2px 100% no-repeat;
}
.minipage__quote {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
height: 11.5%;
padding: 8px 16px;
background: #f7f5f0;
}
.minipage__avatar {
order: -1;
width: 14px;
height: 14px;
border-radius: 50%;
background: #4cd4a6;
}
.minipage__qline {
width: 88%;
height: 5px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__qline--short {
width: 60%;
}
.minipage__faq {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
height: 10%;
padding: 8px 16px;
background: #eceae3;
}
.minipage__qrow {
display: flex;
align-items: center;
box-sizing: border-box;
height: 14px;
border-radius: 4px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(23, 20, 26, 0.16);
padding: 0 8px;
}
.minipage__qrow::before {
content: "";
width: 44%;
height: 4px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__qrow--open {
height: 26px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
border: 2px solid #2f6df6;
}
.minipage__qbody {
width: 78%;
height: 4px;
border-radius: 999px;
background: #d8d4c9;
}
.minipage__foot {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 4px;
height: 5.5%;
padding: 0 16px;
background: #17141a;
}
.minipage__footdot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
}
.minipage__notes {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 152px;
}
.minipage__note {
margin: 0;
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
font-size: 11px;
line-height: 1.5;
font-family: inherit;
}
.minipage__note--tex {
color: #ffd23f;
font-weight: 700;
}
@keyframes pageScroll {
0%, 10% {
transform: translateY(0);
}
44%, 54% {
transform: translateY(-50%);
}
88%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.minipage__notes {
display: none;
}
.minipage__frame {
width: 188px;
height: 164px;
}
.minipage__hero {
padding-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.minipage__track {
animation: none !important;
transform: none;
}
}import type { CSSProperties } from "react";
import styles from "./SkeuoLandingAssembly.module.scss";
export type SkeuoLandingAssemblyProps = {
eyebrow?: string;
heroBtn?: string;
ctaBtn?: string;
plan?: string;
price?: string;
noteTex?: string;
noteFlat?: string;
noteScroll?: string;
/** 한 주기(기본 2000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SkeuoLandingAssembly({
eyebrow = "질감 랜딩",
heroBtn = "시작하기",
ctaBtn = "무료로 시작",
plan = "Pro 멤버십",
price = "월 24,000원",
noteTex = "질감 3곳 — 나무 내비 · 금속 다이얼 · 가죽 카드",
noteFlat = "나머지 구간은 밝은 평면으로 대비",
noteScroll = "스크롤이 질감의 자리를 알려준다",
duration = 2000,
easing = "ease-out",
color = "#f7f5f0",
}: SkeuoLandingAssemblyProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.minipage} style={vars} aria-label="질감 세 곳만 쓴 미니 랜딩">
<div className={styles.frame}>
<div className={styles.track}>
<header className={styles.nav}>
<span className={styles.navdot} />
<span className={styles.navlink} />
<span className={styles.navlink} />
</header>
<section className={styles.hero}>
<p className={styles.heroEyebrow}>{eyebrow}</p>
<span className={styles.heroBar} />
<span className={`${styles.heroBar} ${styles.heroBarShort}`} />
<span className={styles.heroBtn}>{heroBtn}</span>
</section>
<section className={styles.features}>
<article className={styles.fcard}><span className={styles.fdot} /><span className={styles.fline} /></article>
<article className={styles.fcard}><span className={`${styles.fdot} ${styles.fdotB}`} /><span className={styles.fline} /></article>
<article className={styles.fcard}><span className={`${styles.fdot} ${styles.fdotC}`} /><span className={styles.fline} /></article>
</section>
<section className={styles.cta}>
<span className={styles.dial} aria-hidden="true"><span /></span>
<span className={styles.ctaBtn}>{ctaBtn}</span>
</section>
<section className={styles.pricing}>
<p className={styles.plan}>{plan}</p>
<p className={styles.price}>{price}</p>
<span className={styles.stitch} aria-hidden="true" />
</section>
<section className={styles.quote}>
<span className={styles.avatar} />
<span className={styles.qline} />
<span className={`${styles.qline} ${styles.qlineShort}`} />
</section>
<section className={styles.faq}>
<span className={styles.qrow} />
<span className={`${styles.qrow} ${styles.qrowOpen}`}>
<span className={styles.qbody} />
</span>
<span className={styles.qrow} />
</section>
<footer className={styles.foot}>
<span className={styles.footdot} />
<span className={styles.footdot} />
<span className={styles.footdot} />
</footer>
</div>
</div>
<div className={styles.notes}>
<p className={`${styles.note} ${styles.noteTex}`}>{noteTex}</p>
<p className={styles.note}>{noteFlat}</p>
<p className={styles.note}>{noteScroll}</p>
</div>
</section>
);
}09 · 질감 세 곳만 쓴 원페이지 — 측정 누적면적 32.228% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
트랙을 뷰포트 높이의 정확히 2배로 만들어 translateY(-50%)가 한 화면만큼 움직이게 하는 것이 이 프리뷰의 뼈대입니다.
.minipage__track {
display: flex;
flex-direction: column;
height: 200%;
animation: pageScroll $duration $ease-out infinite;
}
@keyframes pageScroll {
0%, 10% { transform: translateY(0); }
44%, 54% { transform: translateY(-50%); }
88%, 100% { transform: translateY(0); }
}
tailwind.config 커스텀
이 스타일에서 값을 한 곳에 모아야 하는 이유는 색이 아니라 눌림의 두께입니다. 물리 키는 쉴 때 옆벽이 8px, 눌렀을 때 2px — 이 두 값이 어긋나면 버튼이 떠 보이거나 박히지 않습니다.
theme: {
extend: {
colors: {
paper: "#fbf6ec", sheet: "#ffffff", ink: "#2a2320", stone: "#5c5048",
brand: "#2f6df6", branddeep: "#1c48b8", mint: "#2f9e74",
leather: "#7a4a2b", leatherdeep: "#5e3517", wood: "#a96f38",
},
borderRadius: { card: "16px", control: "12px", chip: "4px", pill: "999px" },
boxShadow: {
keyrest: "inset 0 2px 0 rgba(255,255,255,.4), inset 0 -2px 0 rgba(0,0,0,.25), 0 8px 0 #1a3fa0",
keydown: "inset 0 2px 0 rgba(255,255,255,.4), inset 0 -2px 0 rgba(0,0,0,.25), 0 2px 0 #1a3fa0",
},
},
},
| 역할 | 토큰 | 언제 |
|---|---|---|
| 키 벽 두께 | shadow-keyrest |
버튼이 쉬고 있을 때 (8px) |
| 눌린 벽 | shadow-keydown |
active: 상태에서만 (2px) |
| 질감면 | tex-leather 등 |
한 페이지에 최대 세 곳 |
바이브코딩 프롬프트
설정을 심었다면 Claude Code나 Cursor에 넘길 지시는 아래 한 덩어리면 충분합니다. 색 목록보다 질감을 어디에 둘 것인가가 먼저 오는 순서가 중요합니다. 생략 없는 원문 전문은 zip 안 prompt.md로 넣었습니다.
질감은 정확히 세 곳에만 쓴다: 내비(나무) · CTA(금속 다이얼) · 요금 추천 플랜(가죽).
질감 없는 구간이 질감 있는 구간의 두 배 이상이어야 한다. 나무는 repeating-linear-gradient
세로 결 줄무늬, 금속 다이얼은 conic-gradient 음영 네 구간, 가죽은 두 방향 repeating 겹침.
스티치 눈금은 네 변을 각각 repeating-linear-gradient 8px 실/8px 간격으로 감싼다.
버튼은 쉴 때 옆벽 8px, 눌릴 때 2px — 벽이 줄어든 만큼 translateY 로 내려앉는다.
이 지시대로 page/index.html을 만들고 1280px 뷰포트에서 전체 페이지를 찍은 것이 아래 그림입니다.

어디서 깨지는가 — 함정
가장 오래 붙들었던 것은 나무 위 글자였습니다. 내비에 흰 브랜드 이름을 바로 얹었는데, 나무 결 밴드가 네 색이라 대비가 밴드마다 달랐습니다. 상대휘도로 직접 계산하니 같은 흰 글자가 어두운 밴드(#96622e)에서는 5.14:1인데 밝은 밴드(#c08a4e)에서는 3.01:1로 떨어졌습니다. 어느 한 가지 색으로는 네 밴드를 전부 넘길 수 없어서, 글자를 지우고 짙은 인레이 칩 위에 올렸고, 나무는 장식으로만 남겼습니다. 질감 위 본문은 대비를 재고 나서 앉히는 것이 원칙입니다.
두 번째는 종이 시트의 이동거리였습니다. 맨 위 시트를 카드 높이의 72퍼센트만큼 올리니 480px 화면에서는 멀쩡한데 320px 화면에서 시트 윗변이 무대를 벗어나 잘렸습니다. 이동거리를 48px로 픽셀 값으로 바꾸고, 좁은 화면에서는 20px로 더 줄여야 두 폭에서 다 시트 전체가 보였습니다. 폭이 바뀔 때 비율 대신 픽셀로 내려앉히는 이 습관은 뉴스레터 HTML 템플릿에서도 똑같이 요구됩니다. 아홉 개 파일의 완성본은 압축 비밀번호 7dcfwy5m 으로 열리는 zip 안에 그대로 들어 있습니다.
접근성
질감은 대비의 적입니다. WCAG 2.1 최소 대비 기준의 상대휘도 공식으로 이 키트의 글자 조합을 전부 직접 계산해 표로 남겼습니다.
| 글자 조합 | 대비 | 판정 |
|---|---|---|
| 가죽 위 본문 #f6e7cf / 가죽 #7a4a2b | 6.07:1 | 통과 |
| 가죽 위 가격 #ffffff / 가죽 #7a4a2b | 7.39:1 | 통과 |
| 종이 본문 #574a38 / 종이 #fff7e6 | 8.07:1 | 통과 |
| 종이 보조 #7a6c56 / 종이 #fff7e6 | 4.80:1 | 통과 (아슬아슬) |
| 히어로 버튼 #ffffff / 브랜드색 #2f6df6 | 4.53:1 | 통과 (아슬아슬) |
| 가죽 밝은 결 위 본문 #f6e7cf / #96613c | 4.24:1 | 실패 — 밝은 결 위 본문 금지 |
밝은 결 밴드 위에서 본문이 기준 아래로 떨어지는 값이 마지막 줄입니다. 그래서 가죽 카드의 글자는 어두운 결 쪽에만 앉히고, 민트 코어 위 흰 글자(1.86:1)도 아이콘 색을 짙은 청록으로 바꿔 넣었습니다. 움직임은 아홉 개 전부에 MDN prefers-reduced-motion 문서의 미디어 쿼리를 넣어, 동작 줄이기를 켠 방문자에게는 질감이 앉은 정지 상태만 보입니다. 질감 자체는 MDN conic-gradient 문서와 MDN text-shadow 문서의 그라디언트·그림자 값이라 지원하지 않는 브라우저에서도 최악은 단색 면입니다.
어떤 스타일이 지금 뜨는지는 디자인 트렌드 모아보기에서 보시고, 이 사이트가 무엇을 주는 곳인지는 소개에 적어 뒀습니다.
FAQ
질감 디자인은 한 페이지에 몇 곳까지 넣어도 되나요?
한 페이지에 세 곳까지입니다. 이 키트에서도 나무 내비·금속 다이얼·가죽 요금 카드 세 곳에만 질감을 쓰고 나머지는 밝은 평면으로 뒀습니다. 질감이 네 곳을 넘으면 시선이 쉬는 곳이 사라져 2010년대의 무거운 화면으로 돌아갑니다.
2010년대처럼 화면 전체를 가죽으로 감싸면 안 되나요?
요금 카드 한 장이 목적이라면 가능하지만, 랜딩 전체에는 권하지 않습니다. 배경이 어두워지는 만큼 본문 대비를 계속 재야 하고, 가장 밝은 결 밴드 위에서는 4.5:1을 맞추기 어렵습니다. 질감은 포인트일 때 값이 있고 바탕이 되면 비용이 됩니다.
React 프로젝트에 바로 붙일 수 있나요?
zip 안에 아홉 부품의 React 판(.tsx + .module.scss)이 같이 들어 있습니다. 시간·이징·대표색은 props로 받으므로, 붙인 뒤 값만 바꿔 쓰면 됩니다. 바닐라 판과 React 판은 같은 값으로 함께 검증했습니다. 질감 부품을 첫 화면 어디에 앉힐지 고르는 단계라면 히어로 섹션 디자인 패턴을 먼저 훑어보는 편이 빠릅니다.









