css generator 9종 — 슬라이더로 만드는 css 그림자 효과
css generator 는 CSS 값을 손으로 만지는 웹 화면입니다 — 슬라이더를 끌면 코드가 바뀝니다. 그림자·그라디언트·모서리·타이밍 같은 CSS 속성을 눈으로 맞추고, 아래 코드 상자에서 그대로 복사해 씁니다. 아홉 개를 직접 만들었고 격자에서 지금 돌아갑니다.
01 gen-box-shadow-stack
02 gen-gradient-angle-stops
03 gen-border-radius-8handles
04 gen-clip-path-polygon
05 gen-filter-chain-presets
06 gen-grid-columns-gap
07 gen-text-stroke-outline
08 gen-type-scale-ratio
09 gen-animation-timing-preview
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 그림자 겹쳐 쌓기
- 02 각도와 정지점 그라디언트
- 03 여덟 손잡이 모서리
- 04 다각형 꼭짓점 자르기
- 05 필터 체인 프리셋
- 06 격자 열과 간격
- 07 글자 테두리 두께
- 08 타이포 배율 사다리
- 09 타이밍 값 미리보기
순서는 화면을 꾸미는 순서입니다. 카드가 떠 보이게 그림자를 깔고(01), 배경에 색 흐름을 깔고(02), 모서리를 말랑하게 다듬고(03), 사진을 모양대로 자르고(04), 글자에 테두리를 입히고(07), 글자 크기 단계를 정하고(08), 마지막에 움직임 속도를 맞춥니다(09). 그 사이에 칸 배치(06)와 색 보정(05)이 끼어 있습니다. 아홉 개 모두 값이 사용자 지정 속성 한 곳에 머물러 미리보기·손잡이·코드 상자가 같은 값을 읽는 것이 뼈대입니다. 같은 creative 기둥에서 시드로 무늬를 만드는 css 배경 패턴과 손으로 물리를 만지는 물리 시뮬레이션 만들기가 이 편의 이웃입니다.
01그림자 겹쳐 쌓기
가로·세로·번짐·퍼짐 네 축을 슬라이더로 잡고 같은 그림자를 두 겹 겹쳐 깊이를 만드는, 카드와 버튼에 들어가는 그림자 결정 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true"><span class="gen__chip">Card</span></div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">가로</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="-24" max="24" step="2" value="0" data-var="--shadow-x" aria-label="가로 거리">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">세로</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="-24" max="24" step="2" value="10" data-var="--shadow-y" aria-label="세로 거리">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">번짐</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="48" step="2" value="20" data-var="--shadow-blur" aria-label="번짐 정도">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">퍼짐</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="-12" max="12" step="2" value="-4" data-var="--shadow-spread" aria-label="퍼짐 정도">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">box-shadow:</span> <span class="gen__v gen__v--x"></span>px <span class="gen__v gen__v--y"></span>px <span class="gen__v gen__v--blur"></span>px <span class="gen__v gen__v--spread"></span>px rgba(23,20,26,.34)</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 그림자 겹쳐 쌓기 — 네 축을 사용자 지정 속성 네 개에 모으고, 미리보기 카드와 코드 상자가
// **같은 속성**을 읽는다. 슬라이더를 끌면 JS 가 속성을 갱신하고, 격자 미리보기(.is-demo)는
// 같은 속성을 CSS 키프레임으로 움직인다 — 그래서 손으로 만질 때와 자동 재생이 같은 그림이다.
// 네이티브 thumb 은 투명으로 두고 손잡이는 `.gen__thumb` 이 그린다. 값이 속성 하나라
// 자동 재생에서도 손잡이가 같이 움직인다 (input 의 value 는 CSS 로 못 움직인다).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-deep;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
// 네 축을 정수로 등록한다. 등록해야 키프레임에서 보간되고 counter() 로도 읽힌다
@property --shadow-x { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --shadow-y { syntax: "<integer>"; inherits: true; initial-value: 10; }
@property --shadow-blur { syntax: "<integer>"; inherits: true; initial-value: 20; }
@property --shadow-spread { syntax: "<integer>"; inherits: true; initial-value: -4; }
@include stage($stage-paper);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--shadow-x: 0;
--shadow-y: 10;
--shadow-blur: 20;
--shadow-spread: -4;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(23, 20, 26, .04);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 네 속성을 그대로 box-shadow 로 읽는다
.gen__preview {
flex: 0 0 auto;
display: grid; place-items: center;
box-sizing: border-box; width: 76px; height: 56px;
border-radius: $r-control;
background: $subject-cream;
box-shadow:
calc(var(--shadow-x) * 1px) calc(var(--shadow-y) * 1px) calc(var(--shadow-blur) * 1px) calc(var(--shadow-spread) * 1px) rgba(23, 20, 26, .34),
calc(var(--shadow-x) * .5px) calc(var(--shadow-y) * .5px) calc(var(--shadow-blur) * .5px) 0 rgba(23, 20, 26, .22);
}
.gen__chip {
color: $subject-ink;
font-family: $mono; font-weight: 700; font-size: 11px; line-height: 1.4;
}
// ── 슬라이더 네 줄 (2열)
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $sp-1 $sp-2; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 26px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
// 손잡이 — 값(사용자 지정 속성)에서 위치를 계산한다. 축마다 최소·최대가 달라 --pct 를 줄마다 준다
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc((var(--shadow-x) + 24) / 48 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc((var(--shadow-y) + 24) / 48 * 100); }
.gen__row:nth-child(3) .gen__thumb { --pct: calc(var(--shadow-blur) / 48 * 100); }
.gen__row:nth-child(4) .gen__thumb { --pct: calc((var(--shadow-spread) + 12) / 24 * 100); }
// ── 코드 출력
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
// 코드 숫자는 counter() 로 그린다 — 속성이 곧 글자라 자동 재생에서도 숫자가 같이 바뀐다.
// textContent 로 쓰면 JS 가 도는 순간에만 바뀌어 미리보기 루프와 코드가 갈라진다
.gen__v { font-weight: 700; }
.gen__v--x::after { counter-reset: v var(--shadow-x); content: counter(v); }
.gen__v--y::after { counter-reset: v var(--shadow-y); content: counter(v); }
.gen__v--blur::after { counter-reset: v var(--shadow-blur); content: counter(v); }
.gen__v--spread::after { counter-reset: v var(--shadow-spread); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 네 속성을 그대로 움직인다. 미리보기·손잡이·코드 숫자가 한 소스에서 나온다
@keyframes shadowX { 0%, 100% { --shadow-x: 0; } 30% { --shadow-x: -18; } 65% { --shadow-x: 16; } }
@keyframes shadowY { 0%, 100% { --shadow-y: 10; } 40% { --shadow-y: 24; } 80% { --shadow-y: 2; } }
@keyframes shadowBlur { 0%, 100% { --shadow-blur: 20; } 45% { --shadow-blur: 44; } 85% { --shadow-blur: 6; } }
@keyframes shadowSpread { 0%, 100% { --shadow-spread: -4; } 50% { --shadow-spread: 10; } }
.gen.is-demo {
animation-name: shadowX, shadowY, shadowBlur, shadowSpread;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview { width: 64px; height: 48px; }
.gen__code { font-size: 9px; }
}@property --shadow-x {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --shadow-y {
syntax: "<integer>";
inherits: true;
initial-value: 10;
}
@property --shadow-blur {
syntax: "<integer>";
inherits: true;
initial-value: 20;
}
@property --shadow-spread {
syntax: "<integer>";
inherits: true;
initial-value: -4;
}
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);
}
.gen {
min-width: 0;
--shadow-x: 0;
--shadow-y: 10;
--shadow-blur: 20;
--shadow-spread: -4;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
flex: 0 0 auto;
display: grid;
place-items: center;
box-sizing: border-box;
width: 76px;
height: 56px;
border-radius: 12px;
background: #fff7e6;
box-shadow: calc(var(--shadow-x) * 1px) calc(var(--shadow-y) * 1px) calc(var(--shadow-blur) * 1px) calc(var(--shadow-spread) * 1px) rgba(23, 20, 26, 0.34), calc(var(--shadow-x) * 0.5px) calc(var(--shadow-y) * 0.5px) calc(var(--shadow-blur) * 0.5px) 0 rgba(23, 20, 26, 0.22);
}
.gen__chip {
color: #17141a;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 26px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #0a4fe8;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc((var(--shadow-x) + 24) / 48 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc((var(--shadow-y) + 24) / 48 * 100);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: calc(var(--shadow-blur) / 48 * 100);
}
.gen__row:nth-child(4) .gen__thumb {
--pct: calc((var(--shadow-spread) + 12) / 24 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--x::after {
counter-reset: v var(--shadow-x);
content: counter(v);
}
.gen__v--y::after {
counter-reset: v var(--shadow-y);
content: counter(v);
}
.gen__v--blur::after {
counter-reset: v var(--shadow-blur);
content: counter(v);
}
.gen__v--spread::after {
counter-reset: v var(--shadow-spread);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@keyframes shadowX {
0%, 100% {
--shadow-x: 0;
}
30% {
--shadow-x: -18;
}
65% {
--shadow-x: 16;
}
}
@keyframes shadowY {
0%, 100% {
--shadow-y: 10;
}
40% {
--shadow-y: 24;
}
80% {
--shadow-y: 2;
}
}
@keyframes shadowBlur {
0%, 100% {
--shadow-blur: 20;
}
45% {
--shadow-blur: 44;
}
85% {
--shadow-blur: 6;
}
}
@keyframes shadowSpread {
0%, 100% {
--shadow-spread: -4;
}
50% {
--shadow-spread: 10;
}
}
.gen.is-demo {
animation-name: shadowX, shadowY, shadowBlur, shadowSpread;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 64px;
height: 48px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 슬라이더 값은 전부 gen 요소의 사용자 지정 속성 네 개에 모인다. 미리보기 카드의 box-shadow 도,
// 코드 상자의 숫자(counter() 로 그린다)도, 손잡이 위치도 같은 속성을 읽는다 — 그래서 자동 재생
// 루프가 속성만 움직여도 셋이 함께 바뀐다. JS 는 조작할 때 속성을 넣고 복사용 문자열만 만든다.
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function shadowText() {
var x = readVar('--shadow-x'), y = readVar('--shadow-y');
var blur = readVar('--shadow-blur'), spread = readVar('--shadow-spread');
// 두 겹 중 아래 겹은 절반 거리·절반 번짐으로 깔아 깊이를 만든다
return 'box-shadow: ' + x + 'px ' + y + 'px ' + blur + 'px ' + spread + 'px rgba(23,20,26,.34),\n'
+ ' ' + Math.round(x / 2) + 'px ' + Math.round(y / 2) + 'px ' + Math.round(blur / 2) + 'px 0 rgba(23,20,26,.22);';
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
// 클립보드는 iframe·http 환경에서 막힐 수 있다. writeText 는 **거부된 프로미스**를 돌려주므로
// try/catch 로는 못 잡는다 — 반드시 catch 를 달아 선택 방식으로 떨어뜨린다 (조작 실측에서 잡았다)
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = shadowText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './ShadowStack.module.scss';
type Axis = { key: string; cssVar: string; label: string; min: number; max: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
// 바닐라 판과 같은 네 축. 값은 사용자 지정 속성으로 내려보내고 미리보기·손잡이·코드가 함께 읽는다
const AXES: Axis[] = [
{ key: 'x', cssVar: '--shadow-x', label: '가로', min: -24, max: 24 },
{ key: 'y', cssVar: '--shadow-y', label: '세로', min: -24, max: 24 },
{ key: 'blur', cssVar: '--shadow-blur', label: '번짐', min: 0, max: 48 },
{ key: 'spread', cssVar: '--shadow-spread', label: '퍼짐', min: -12, max: 12 }
];
export type ShadowStackProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function ShadowStack({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#5b8cf8' }: ShadowStackProps) {
const [values, setValues] = useState<Record<string, number>>({ x: 0, y: 10, blur: 20, spread: -4 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
const { x, y, blur, spread } = values;
return `box-shadow: ${x}px ${y}px ${blur}px ${spread}px rgba(23,20,26,.34),\n ${Math.round(x / 2)}px ${Math.round(y / 2)}px ${Math.round(blur / 2)}px 0 rgba(23,20,26,.22);`;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color,
'--shadow-x': values.x,
'--shadow-y': values.y,
'--shadow-blur': values.blur,
'--shadow-spread': values.spread
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true"><span className={styles.chip}>Card</span></div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={2}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>box-shadow:</span> {values.x}px {values.y}px {values.blur}px {values.spread}px rgba(23,20,26,.34)
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}01 · 그림자 겹쳐 쌓기 — 측정 누적면적 10.931% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
네 값이 한 곳에 머무는 구조
// 네 축을 정수로 등록한다. 등록해야 키프레임에서 보간되고 counter() 로도 읽힌다
@property --shadow-x { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --shadow-y { syntax: "<integer>"; inherits: true; initial-value: 10; }
@property --shadow-blur { syntax: "<integer>"; inherits: true; initial-value: 20; }
@property --shadow-spread { syntax: "<integer>"; inherits: true; initial-value: -4; }
// 코드 상자의 숫자도 같은 속성을 카운터로 읽는다 — 화면과 코드가 한 값을 본다
.gen__v--x::after { counter-reset: v var(--shadow-x); content: counter(v); }
값이 등록된 속성 넷에만 머물러 있으니 미리보기 카드의 box-shadow·슬라이더 손잡이 위치·코드 상자의 숫자가 전부 같은 곳을 보고, 격자 미리보기는 이 속성을 그대로 키프레임으로 움직여 누적면적 10.93%·강도 110.3·움직인 프레임 23/23 로 측정됐습니다.
두 겹으로 깊이를 만드는 법
box-shadow:
calc(var(--shadow-x) * 1px) calc(var(--shadow-y) * 1px) calc(var(--shadow-blur) * 1px) calc(var(--shadow-spread) * 1px) rgba(23, 20, 26, .34),
calc(var(--shadow-x) * .5px) calc(var(--shadow-y) * .5px) calc(var(--shadow-blur) * .5px) 0 rgba(23, 20, 26, .22);
아래 겹은 거리와 번짐을 절반으로 줄인 같은 그림자라, 한 겹만 쓸 때보다 값을 키워도 가장자리가 지저분해지지 않아 디자인 시스템의 그림자 단계를 정할 때 기준으로 씁니다.
02각도와 정지점 그라디언트
각도 슬라이더가 linear-gradient 의 방향을 돌리고 정지점 슬라이더 두 개가 색이 갈리는 지점을 밀고 당기는, 히어로 배경과 버튼 채움을 만드는 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true"><span class="gen__angle-mark"></span></div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">각도</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="360" step="5" value="45" data-var="--grad-angle" aria-label="그라디언트 각도">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">갈림 A</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="100" step="5" value="30" data-var="--grad-stop-a" aria-label="첫 색 갈림 지점">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">갈림 B</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="100" step="5" value="75" data-var="--grad-stop-b" aria-label="둘 색 갈림 지점">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">linear-gradient(</span><span class="gen__v gen__v--angle"></span>deg, #2f6df6 <span class="gen__v gen__v--a"></span>%, #ff9ebb <span class="gen__v gen__v--b"></span>%)</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 각도와 정지점 그라디언트 — 각도·갈림 두 지점을 사용자 지정 속성 셋에 모은다.
// 미리보기·손잡이·코드 숫자 전부 같은 속성을 읽는다(01 과 같은 체계).
// 각도 눈금 마크가 미리보기 모서리를 따라 돈다 — 값이 방향이라는 걸 눈으로 보여 준다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-light;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$track: rgba(255, 255, 255, .2);
$rail: 4px;
$thumb: 12px;
@property --grad-angle { syntax: "<integer>"; inherits: true; initial-value: 45; }
@property --grad-stop-a { syntax: "<integer>"; inherits: true; initial-value: 30; }
@property --grad-stop-b { syntax: "<integer>"; inherits: true; initial-value: 75; }
@include stage($stage-ink);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--grad-angle: 45;
--grad-stop-a: 30;
--grad-stop-b: 75;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 각도가 속성 하나로 방향을 돌린다
.gen__preview {
position: relative; flex: 0 0 auto;
box-sizing: border-box; width: 132px; height: 64px;
border-radius: $r-sm; overflow: hidden;
background: linear-gradient(calc(var(--grad-angle) * 1deg), $subject-blue calc(var(--grad-stop-a) * 1%), $subject-pink calc(var(--grad-stop-b) * 1%));
}
// 각도 눈금 — 미리보기 왼쪽 위 모서리에서 회전하는 짧은 막대
.gen__angle-mark {
position: absolute; left: 50%; top: 50%;
width: 18px; height: 4px; margin: -2px 0 0 -18px;
border-radius: $r-pill; background: $subject-cream;
transform-origin: 18px 2px;
transform: rotate(calc(var(--grad-angle) * 1deg));
opacity: .9;
}
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: $sp-1; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 40px;
color: $subject-cream;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc(var(--grad-angle) / 360 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: var(--grad-stop-a); }
.gen__row:nth-child(3) .gen__thumb { --pct: var(--grad-stop-b); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--angle::after { counter-reset: v var(--grad-angle); content: counter(v); }
.gen__v--a::after { counter-reset: v var(--grad-stop-a); content: counter(v); }
.gen__v--b::after { counter-reset: v var(--grad-stop-b); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기
@keyframes gradAngle { 0%, 100% { --grad-angle: 45; } 35% { --grad-angle: 210; } 70% { --grad-angle: 315; } }
@keyframes gradStopA { 0%, 100% { --grad-stop-a: 30; } 40% { --grad-stop-a: 45; } 80% { --grad-stop-a: 10; } }
@keyframes gradStopB { 0%, 100% { --grad-stop-b: 75; } 50% { --grad-stop-b: 55; } 85% { --grad-stop-b: 90; } }
.gen.is-demo {
animation-name: gradAngle, gradStopA, gradStopB;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview { width: 104px; height: 56px; }
.gen__code { font-size: 9px; }
}@property --grad-angle {
syntax: "<integer>";
inherits: true;
initial-value: 45;
}
@property --grad-stop-a {
syntax: "<integer>";
inherits: true;
initial-value: 30;
}
@property --grad-stop-b {
syntax: "<integer>";
inherits: true;
initial-value: 75;
}
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);
}
.gen {
min-width: 0;
--grad-angle: 45;
--grad-stop-a: 30;
--grad-stop-b: 75;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: 132px;
height: 64px;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(calc(var(--grad-angle) * 1deg), #2f6df6 calc(var(--grad-stop-a) * 1%), #ff9ebb calc(var(--grad-stop-b) * 1%));
}
.gen__angle-mark {
position: absolute;
left: 50%;
top: 50%;
width: 18px;
height: 4px;
margin: -2px 0 0 -18px;
border-radius: 999px;
background: #fff7e6;
transform-origin: 18px 2px;
transform: rotate(calc(var(--grad-angle) * 1deg));
opacity: 0.9;
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 40px;
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #5b8cf8;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc(var(--grad-angle) / 360 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: var(--grad-stop-a);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: var(--grad-stop-b);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--angle::after {
counter-reset: v var(--grad-angle);
content: counter(v);
}
.gen__v--a::after {
counter-reset: v var(--grad-stop-a);
content: counter(v);
}
.gen__v--b::after {
counter-reset: v var(--grad-stop-b);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@keyframes gradAngle {
0%, 100% {
--grad-angle: 45;
}
35% {
--grad-angle: 210;
}
70% {
--grad-angle: 315;
}
}
@keyframes gradStopA {
0%, 100% {
--grad-stop-a: 30;
}
40% {
--grad-stop-a: 45;
}
80% {
--grad-stop-a: 10;
}
}
@keyframes gradStopB {
0%, 100% {
--grad-stop-b: 75;
}
50% {
--grad-stop-b: 55;
}
85% {
--grad-stop-b: 90;
}
}
.gen.is-demo {
animation-name: gradAngle, gradStopA, gradStopB;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 104px;
height: 56px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 세 값 모두 gen 의 사용자 지정 속성 — 미리보기·손잡이·코드 숫자가 같은 곳을 읽는다
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function gradientText() {
return 'linear-gradient(' + readVar('--grad-angle') + 'deg, #2f6df6 ' + readVar('--grad-stop-a') + '%, #ff9ebb ' + readVar('--grad-stop-b') + '%);';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = gradientText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './GradientStops.module.scss';
type Axis = { key: string; cssVar: string; label: string; min: number; max: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'angle', cssVar: '--grad-angle', label: '각도', min: 0, max: 360 },
{ key: 'stopA', cssVar: '--grad-stop-a', label: '갈림 A', min: 0, max: 100 },
{ key: 'stopB', cssVar: '--grad-stop-b', label: '갈림 B', min: 0, max: 100 }
];
export type GradientStopsProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function GradientStops({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#5b8cf8' }: GradientStopsProps) {
const [values, setValues] = useState<Record<string, number>>({ angle: 45, stopA: 30, stopB: 75 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
return `linear-gradient(${values.angle}deg, #2f6df6 ${values.stopA}%, #ff9ebb ${values.stopB}%);`;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color,
'--grad-angle': values.angle,
'--grad-stop-a': values.stopA,
'--grad-stop-b': values.stopB
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true"><span className={styles.angleMark}></span></div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={5}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>linear-gradient(</span>{values.angle}deg, #2f6df6 {values.stopA}%, #ff9ebb {values.stopB}%)
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}02 · 각도와 정지점 그라디언트 — 측정 누적면적 8.818% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
각도 하나가 방향을 정한다
// 각도는 정수 하나. calc 이 도 단위를 붙이고 정지점 둘이 색 경계를 민다
.gen__preview {
background: linear-gradient(
calc(var(--grad-angle) * 1deg),
$subject-blue calc(var(--grad-stop-a) * 1%),
$subject-pink calc(var(--grad-stop-b) * 1%)
);
}
각도는 --grad-angle 정수 하나이고 calc 가 도 단위를 붙이는데, 45도에서 315도로 도는 동안 색이 통째로 밀리는 면적이 넓어 밝기 변화가 크게 잡혀 측정은 누적면적 8.82%·강도 70.1·움직인 프레임 23/23 이 나왔습니다.
css 텍스트 그라디언트에도 같은 값이 쓰인다
.gen__row:nth-child(1) .gen__thumb { --pct: calc(var(--grad-angle) / 360 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: var(--grad-stop-a); }
.gen__row:nth-child(3) .gen__thumb { --pct: var(--grad-stop-b); }
손잡이 위치도 같은 속성에서 계산하고, 갈림점 A 를 0 근처로, B 를 100 근처로 밀면 두 색이 넓게 섞이고 두 값을 가까이 붙이면 선에 가까운 경계가 됩니다 — 배경이 아니라 글자 채움에 쓸 때도 같은 감각입니다.
03여덟 손잡이 모서리
모서리 네 곳의 가로 반경과 공통 세로 반경을 따로 잡아 슬래시 문법으로 적는, 말랑한 블롭 모양을 만드는 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview-wrap" aria-hidden="true">
<div class="gen__preview"></div>
<span class="gen__dot gen__dot--tl"></span><span class="gen__dot gen__dot--tr"></span>
<span class="gen__dot gen__dot--br"></span><span class="gen__dot gen__dot--bl"></span>
</div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">왼위</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="12" data-var="--r-tl" aria-label="왼쪽 위 모서리 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">오위</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="48" data-var="--r-tr" aria-label="오른쪽 위 모서리 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">오아</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="12" data-var="--r-br" aria-label="오른쪽 아래 모서리 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">왼아</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="48" data-var="--r-bl" aria-label="왼쪽 아래 모서리 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">세로 반경</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="28" data-var="--r-vy" aria-label="모서리 세로 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">border-radius:</span> <span class="gen__v gen__v--tl"></span>px <span class="gen__v gen__v--tr"></span>px <span class="gen__v gen__v--br"></span>px <span class="gen__v gen__v--bl"></span>px / <span class="gen__v gen__v--vy"></span>px</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 여덟 손잡이 모서리 — 네 모서리 가로 반경 + 공통 세로 반경을 사용자 지정 속성에 모은다.
// 슬래시 문법(/ 뒤 세로 반경)이 비대칭 블롭을 만든다. 모서리 점 네 개가 미리보기 위에
// 반경 값을 따라 붙는다 — 어느 모서리가 움직이는지 눈으로 보인다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
@property --r-tl { syntax: "<integer>"; inherits: true; initial-value: 12; }
@property --r-tr { syntax: "<integer>"; inherits: true; initial-value: 48; }
@property --r-br { syntax: "<integer>"; inherits: true; initial-value: 12; }
@property --r-bl { syntax: "<integer>"; inherits: true; initial-value: 48; }
@property --r-vy { syntax: "<integer>"; inherits: true; initial-value: 28; }
@include stage($stage-yellow);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--r-tl: 12;
--r-tr: 48;
--r-br: 12;
--r-bl: 48;
--r-vy: 28;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 슬래시 문법 그대로
.gen__preview-wrap { position: relative; flex: 0 0 auto; width: 124px; height: 84px; }
.gen__preview {
box-sizing: border-box; width: 124px; height: 84px;
background: $subject-blue;
// 모서리별 longhand 두 값(가로 세로)은 슬래시 문법과 동치다 — 값이 속성마다 붙어 있어
// 어느 모서리가 움직이는지 읽기도 쉽다
border-top-left-radius: calc(var(--r-tl) * 1px) calc(var(--r-vy) * 1px);
border-top-right-radius: calc(var(--r-tr) * 1px) calc(var(--r-vy) * 1px);
border-bottom-right-radius: calc(var(--r-br) * 1px) calc(var(--r-vy) * 1px);
border-bottom-left-radius: calc(var(--r-bl) * 1px) calc(var(--r-vy) * 1px);
}
// 모서리 점 — 각 모서리 반경만큼 안쪽으로 붙는다
.gen__dot {
position: absolute; width: 8px; height: 8px; margin: -4px;
border-radius: 50%; background: $subject-ink;
}
.gen__dot--tl { left: calc(var(--r-tl) * 1px); top: calc(var(--r-vy) * 1px); }
.gen__dot--tr { right: calc(var(--r-tr) * 1px); top: calc(var(--r-vy) * 1px); }
.gen__dot--br { right: calc(var(--r-br) * 1px); bottom: calc(var(--r-vy) * 1px); }
.gen__dot--bl { left: calc(var(--r-bl) * 1px); bottom: calc(var(--r-vy) * 1px); }
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $sp-1 $sp-2; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 42px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc(var(--r-tl) / 80 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc(var(--r-tr) / 80 * 100); }
.gen__row:nth-child(3) .gen__thumb { --pct: calc(var(--r-br) / 80 * 100); }
.gen__row:nth-child(4) .gen__thumb { --pct: calc(var(--r-bl) / 80 * 100); }
.gen__row:nth-child(5) .gen__thumb { --pct: calc(var(--r-vy) / 80 * 100); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--tl::after { counter-reset: v var(--r-tl); content: counter(v); }
.gen__v--tr::after { counter-reset: v var(--r-tr); content: counter(v); }
.gen__v--br::after { counter-reset: v var(--r-br); content: counter(v); }
.gen__v--bl::after { counter-reset: v var(--r-bl); content: counter(v); }
.gen__v--vy::after { counter-reset: v var(--r-vy); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 왼위/오아 가 한 쌍, 오위/왼아 가 다른 쌍으로 엇갈려 찌그러진다
@keyframes rTL { 0%, 100% { --r-tl: 12; } 50% { --r-tl: 64; } }
@keyframes rTR { 0%, 100% { --r-tr: 48; } 50% { --r-tr: 8; } }
@keyframes rBR { 0%, 100% { --r-br: 12; } 50% { --r-br: 64; } }
@keyframes rBL { 0%, 100% { --r-bl: 48; } 50% { --r-bl: 8; } }
@keyframes rVY { 0%, 100% { --r-vy: 28; } 40% { --r-vy: 68; } 80% { --r-vy: 16; } }
.gen.is-demo {
animation-name: rTL, rTR, rBR, rBL, rVY;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview-wrap, .gen__preview { width: 100px; height: 72px; }
.gen__code { font-size: 9px; }
}@property --r-tl {
syntax: "<integer>";
inherits: true;
initial-value: 12;
}
@property --r-tr {
syntax: "<integer>";
inherits: true;
initial-value: 48;
}
@property --r-br {
syntax: "<integer>";
inherits: true;
initial-value: 12;
}
@property --r-bl {
syntax: "<integer>";
inherits: true;
initial-value: 48;
}
@property --r-vy {
syntax: "<integer>";
inherits: true;
initial-value: 28;
}
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);
}
.gen {
min-width: 0;
--r-tl: 12;
--r-tr: 48;
--r-br: 12;
--r-bl: 48;
--r-vy: 28;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview-wrap {
position: relative;
flex: 0 0 auto;
width: 124px;
height: 84px;
}
.gen__preview {
box-sizing: border-box;
width: 124px;
height: 84px;
background: #2f6df6;
border-top-left-radius: calc(var(--r-tl) * 1px) calc(var(--r-vy) * 1px);
border-top-right-radius: calc(var(--r-tr) * 1px) calc(var(--r-vy) * 1px);
border-bottom-right-radius: calc(var(--r-br) * 1px) calc(var(--r-vy) * 1px);
border-bottom-left-radius: calc(var(--r-bl) * 1px) calc(var(--r-vy) * 1px);
}
.gen__dot {
position: absolute;
width: 8px;
height: 8px;
margin: -4px;
border-radius: 50%;
background: #17141a;
}
.gen__dot--tl {
left: calc(var(--r-tl) * 1px);
top: calc(var(--r-vy) * 1px);
}
.gen__dot--tr {
right: calc(var(--r-tr) * 1px);
top: calc(var(--r-vy) * 1px);
}
.gen__dot--br {
right: calc(var(--r-br) * 1px);
bottom: calc(var(--r-vy) * 1px);
}
.gen__dot--bl {
left: calc(var(--r-bl) * 1px);
bottom: calc(var(--r-vy) * 1px);
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 42px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc(var(--r-tl) / 80 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc(var(--r-tr) / 80 * 100);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: calc(var(--r-br) / 80 * 100);
}
.gen__row:nth-child(4) .gen__thumb {
--pct: calc(var(--r-bl) / 80 * 100);
}
.gen__row:nth-child(5) .gen__thumb {
--pct: calc(var(--r-vy) / 80 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--tl::after {
counter-reset: v var(--r-tl);
content: counter(v);
}
.gen__v--tr::after {
counter-reset: v var(--r-tr);
content: counter(v);
}
.gen__v--br::after {
counter-reset: v var(--r-br);
content: counter(v);
}
.gen__v--bl::after {
counter-reset: v var(--r-bl);
content: counter(v);
}
.gen__v--vy::after {
counter-reset: v var(--r-vy);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes rTL {
0%, 100% {
--r-tl: 12;
}
50% {
--r-tl: 64;
}
}
@keyframes rTR {
0%, 100% {
--r-tr: 48;
}
50% {
--r-tr: 8;
}
}
@keyframes rBR {
0%, 100% {
--r-br: 12;
}
50% {
--r-br: 64;
}
}
@keyframes rBL {
0%, 100% {
--r-bl: 48;
}
50% {
--r-bl: 8;
}
}
@keyframes rVY {
0%, 100% {
--r-vy: 28;
}
40% {
--r-vy: 68;
}
80% {
--r-vy: 16;
}
}
.gen.is-demo {
animation-name: rTL, rTR, rBR, rBL, rVY;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview-wrap, .gen__preview {
width: 100px;
height: 72px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 네 모서리 가로 반경 + 공통 세로 반경 — 다섯 값이면 슬래시 문법이 성립한다
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function radiusText() {
return 'border-radius: ' + readVar('--r-tl') + 'px ' + readVar('--r-tr') + 'px ' + readVar('--r-br') + 'px ' + readVar('--r-bl') + 'px / ' + readVar('--r-vy') + 'px;';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = radiusText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './RadiusHandles.module.scss';
type Axis = { key: string; label: string; min: number; max: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'tl', label: '왼위', min: 0, max: 80 },
{ key: 'tr', label: '오위', min: 0, max: 80 },
{ key: 'br', label: '오아', min: 0, max: 80 },
{ key: 'bl', label: '왼아', min: 0, max: 80 },
{ key: 'vy', label: '세로 반경', min: 0, max: 80 }
];
export type RadiusHandlesProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function RadiusHandles({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#17141a' }: RadiusHandlesProps) {
const [values, setValues] = useState<Record<string, number>>({ tl: 12, tr: 48, br: 12, bl: 48, vy: 28 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
return `border-radius: ${values.tl}px ${values.tr}px ${values.br}px ${values.bl}px / ${values.vy}px;`;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color,
'--r-tl': values.tl,
'--r-tr': values.tr,
'--r-br': values.br,
'--r-bl': values.bl,
'--r-vy': values.vy
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.previewWrap} aria-hidden="true">
<div className={styles.preview}></div>
<span className={styles.dot} style={{ left: values.tl, top: values.vy }}></span>
<span className={styles.dot} style={{ right: values.tr, top: values.vy }}></span>
<span className={styles.dot} style={{ right: values.br, bottom: values.vy }}></span>
<span className={styles.dot} style={{ left: values.bl, bottom: values.vy }}></span>
</div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={4}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${(values[axis.key] / axis.max) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>border-radius:</span> {values.tl}px {values.tr}px {values.br}px {values.bl}px / {values.vy}px
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}03 · 여덟 손잡이 모서리 — 측정 누적면적 5.821% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
모서리별 longhand 가 슬래시 문법과 같다
// 모서리별 longhand 두 값(가로 세로)은 슬래시 문법과 동치다 — 값이 속성마다 붙어 있어
// 어느 모서리가 움직이는지 읽기도 쉽다
border-top-left-radius: calc(var(--r-tl) * 1px) calc(var(--r-vy) * 1px);
border-top-right-radius: calc(var(--r-tr) * 1px) calc(var(--r-vy) * 1px);
border-bottom-right-radius: calc(var(--r-br) * 1px) calc(var(--r-vy) * 1px);
border-bottom-left-radius: calc(var(--r-bl) * 1px) calc(var(--r-vy) * 1px);
오른쪽 위와 왼쪽 아래를 크게, 왼쪽 위와 오른쪽 아래를 작게 하면 물방울처럼 기울어진 모양이 되는데, 코드 상자는 border-radius: 12px 48px 12px 48px / 28px 꼴의 짧은 문법으로 내놓고, 미리보기는 위처럼 모서리별로 풀어 둡니다.
모서리 점이 값을 따라 붙는다
.gen__dot--tl { left: calc(var(--r-tl) * 1px); top: calc(var(--r-vy) * 1px); }
.gen__dot--tr { right: calc(var(--r-tr) * 1px); top: calc(var(--r-vy) * 1px); }
.gen__dot--br { right: calc(var(--r-br) * 1px); bottom: calc(var(--r-vy) * 1px); }
.gen__dot--bl { left: calc(var(--r-bl) * 1px); bottom: calc(var(--r-vy) * 1px); }
검은 점 네 개가 반경값만큼 안쪽으로 붙어 어느 모서리가 움직이는지 보여 주고, 측정은 누적면적 5.82%·강도 133.6·움직인 프레임 22/23 입니다.
04다각형 꼭짓점 자르기
여섯 꼭짓점의 반경을 슬라이더로 끌면 그 자리에서 사진이 잘리고 좌표 목록이 그대로 clip-path 코드가 되는 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview-frame" aria-hidden="true"><div class="gen__preview"></div></div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">꼭짓점1</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="20" max="100" step="5" value="90" data-var="--v0" aria-label="첫 번째 꼭짓점 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">꼭짓점2</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="20" max="100" step="5" value="60" data-var="--v1" aria-label="두 번째 꼭짓점 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">꼭짓점3</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="20" max="100" step="5" value="80" data-var="--v2" aria-label="세 번째 꼭짓점 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">꼭짓점4</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="20" max="100" step="5" value="60" data-var="--v3" aria-label="네 번째 꼭짓점 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">꼭짓점5</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="20" max="100" step="5" value="85" data-var="--v4" aria-label="다섯 번째 꼭짓점 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">꼭짓점6</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="20" max="100" step="5" value="65" data-var="--v5" aria-label="여섯 번째 꼭짓점 반경">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">clip-path:</span> <span class="gen__v gen__v--c0"></span>% <span class="gen__v gen__v--c1"></span>% <span class="gen__v gen__v--c2"></span>% <span class="gen__v gen__v--c3"></span>% <span class="gen__v gen__v--c4"></span>% <span class="gen__v gen__v--c5"></span>%</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 다각형 꼭짓점 자르기 — 여섯 꼭짓점이 60도 간격 고정이고 각 꼭짓점의 반경만 속성 여섯 개로 조절된다.
// 미리보기의 clip-path 를 **CSS calc 로 직접** 계산한다(아래 계수표) — JS 의 clipText() 도 같은 계수표로
// 복사용 문자열을 만든다. 두 판이 같은 표를 공유하므로 화면 모양과 복사한 코드가 어긋나지 않는다.
// 세로 계수 0.6 은 미리보기 상자 비율(124×76)로 납작하게 누르는 값이다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
@property --v0 { syntax: "<integer>"; inherits: true; initial-value: 90; }
@property --v1 { syntax: "<integer>"; inherits: true; initial-value: 60; }
@property --v2 { syntax: "<integer>"; inherits: true; initial-value: 80; }
@property --v3 { syntax: "<integer>"; inherits: true; initial-value: 60; }
@property --v4 { syntax: "<integer>"; inherits: true; initial-value: 85; }
@property --v5 { syntax: "<integer>"; inherits: true; initial-value: 65; }
@include stage($stage-paper);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--v0: 90;
--v1: 60;
--v2: 80;
--v3: 60;
--v4: 85;
--v5: 65;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(23, 20, 26, .04);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 계수표: (dx, dy) = 위(0,-1) · 오위(0.87,-0.5) · 오아(0.87,0.5) · 아래(0,1) · 왼아(-0.87,0.5) · 왼위(-0.87,-0.5)
.gen__preview-frame { flex: 0 0 auto; padding: 4px; border: 1px dashed rgba(23, 20, 26, .3); border-radius: $r-xs; }
.gen__preview {
box-sizing: border-box; width: 116px; height: 72px;
background: $subject-blue;
clip-path: polygon(
calc(50% + 0 * var(--v0) * 1%) calc(50% + -1 * var(--v0) * 0.6 * 1%),
calc(50% + 0.87 * var(--v1) * 1%) calc(50% + -0.5 * var(--v1) * 0.6 * 1%),
calc(50% + 0.87 * var(--v2) * 1%) calc(50% + 0.5 * var(--v2) * 0.6 * 1%),
calc(50% + 0 * var(--v3) * 1%) calc(50% + 1 * var(--v3) * 0.6 * 1%),
calc(50% + -0.87 * var(--v4) * 1%) calc(50% + 0.5 * var(--v4) * 0.6 * 1%),
calc(50% + -0.87 * var(--v5) * 1%) calc(50% + -0.5 * var(--v5) * 0.6 * 1%)
);
}
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $sp-1 $sp-2; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 38px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc((var(--v0) - 20) / 80 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc((var(--v1) - 20) / 80 * 100); }
.gen__row:nth-child(3) .gen__thumb { --pct: calc((var(--v2) - 20) / 80 * 100); }
.gen__row:nth-child(4) .gen__thumb { --pct: calc((var(--v3) - 20) / 80 * 100); }
.gen__row:nth-child(5) .gen__thumb { --pct: calc((var(--v4) - 20) / 80 * 100); }
.gen__row:nth-child(6) .gen__thumb { --pct: calc((var(--v5) - 20) / 80 * 100); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--c0::after { counter-reset: v var(--v0); content: counter(v); }
.gen__v--c1::after { counter-reset: v var(--v1); content: counter(v); }
.gen__v--c2::after { counter-reset: v var(--v2); content: counter(v); }
.gen__v--c3::after { counter-reset: v var(--v3); content: counter(v); }
.gen__v--c4::after { counter-reset: v var(--v4); content: counter(v); }
.gen__v--c5::after { counter-reset: v var(--v5); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 여섯 반경이 엇갈려 오가면 육각형이 별처럼 숨쉰다
@keyframes pv0 { 0%, 100% { --v0: 90; } 50% { --v0: 45; } }
@keyframes pv1 { 0%, 100% { --v1: 60; } 40% { --v1: 95; } 80% { --v1: 35; } }
@keyframes pv2 { 0%, 100% { --v2: 80; } 50% { --v2: 40; } }
@keyframes pv3 { 0%, 100% { --v3: 60; } 45% { --v3: 95; } 85% { --v3: 40; } }
@keyframes pv4 { 0%, 100% { --v4: 85; } 50% { --v4: 40; } }
@keyframes pv5 { 0%, 100% { --v5: 65; } 40% { --v5: 30; } 80% { --v5: 90; } }
.gen.is-demo {
animation-name: pv0, pv1, pv2, pv3, pv4, pv5;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview { width: 96px; height: 60px; }
.gen__code { font-size: 9px; }
}@property --v0 {
syntax: "<integer>";
inherits: true;
initial-value: 90;
}
@property --v1 {
syntax: "<integer>";
inherits: true;
initial-value: 60;
}
@property --v2 {
syntax: "<integer>";
inherits: true;
initial-value: 80;
}
@property --v3 {
syntax: "<integer>";
inherits: true;
initial-value: 60;
}
@property --v4 {
syntax: "<integer>";
inherits: true;
initial-value: 85;
}
@property --v5 {
syntax: "<integer>";
inherits: true;
initial-value: 65;
}
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);
}
.gen {
min-width: 0;
--v0: 90;
--v1: 60;
--v2: 80;
--v3: 60;
--v4: 85;
--v5: 65;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview-frame {
flex: 0 0 auto;
padding: 4px;
border: 1px dashed rgba(23, 20, 26, 0.3);
border-radius: 4px;
}
.gen__preview {
box-sizing: border-box;
width: 116px;
height: 72px;
background: #2f6df6;
clip-path: polygon(calc(50% + 0 * var(--v0) * 1%) calc(50% + -1 * var(--v0) * 0.6 * 1%), calc(50% + 0.87 * var(--v1) * 1%) calc(50% + -0.5 * var(--v1) * 0.6 * 1%), calc(50% + 0.87 * var(--v2) * 1%) calc(50% + 0.5 * var(--v2) * 0.6 * 1%), calc(50% + 0 * var(--v3) * 1%) calc(50% + 1 * var(--v3) * 0.6 * 1%), calc(50% + -0.87 * var(--v4) * 1%) calc(50% + 0.5 * var(--v4) * 0.6 * 1%), calc(50% + -0.87 * var(--v5) * 1%) calc(50% + -0.5 * var(--v5) * 0.6 * 1%));
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 38px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc((var(--v0) - 20) / 80 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc((var(--v1) - 20) / 80 * 100);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: calc((var(--v2) - 20) / 80 * 100);
}
.gen__row:nth-child(4) .gen__thumb {
--pct: calc((var(--v3) - 20) / 80 * 100);
}
.gen__row:nth-child(5) .gen__thumb {
--pct: calc((var(--v4) - 20) / 80 * 100);
}
.gen__row:nth-child(6) .gen__thumb {
--pct: calc((var(--v5) - 20) / 80 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--c0::after {
counter-reset: v var(--v0);
content: counter(v);
}
.gen__v--c1::after {
counter-reset: v var(--v1);
content: counter(v);
}
.gen__v--c2::after {
counter-reset: v var(--v2);
content: counter(v);
}
.gen__v--c3::after {
counter-reset: v var(--v3);
content: counter(v);
}
.gen__v--c4::after {
counter-reset: v var(--v4);
content: counter(v);
}
.gen__v--c5::after {
counter-reset: v var(--v5);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes pv0 {
0%, 100% {
--v0: 90;
}
50% {
--v0: 45;
}
}
@keyframes pv1 {
0%, 100% {
--v1: 60;
}
40% {
--v1: 95;
}
80% {
--v1: 35;
}
}
@keyframes pv2 {
0%, 100% {
--v2: 80;
}
50% {
--v2: 40;
}
}
@keyframes pv3 {
0%, 100% {
--v3: 60;
}
45% {
--v3: 95;
}
85% {
--v3: 40;
}
}
@keyframes pv4 {
0%, 100% {
--v4: 85;
}
50% {
--v4: 40;
}
}
@keyframes pv5 {
0%, 100% {
--v5: 65;
}
40% {
--v5: 30;
}
80% {
--v5: 90;
}
}
.gen.is-demo {
animation-name: pv0, pv1, pv2, pv3, pv4, pv5;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 96px;
height: 60px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 여섯 꼭짓점은 60도 간격 고정. 각 꼭짓점의 (dx, dy) 계수표 — CSS 와 JS 가 같은 표를 공유한다
// (60도 간격 여섯 방향의 단위원 좌표, 소수 셋째에서 반올림). 중심 50% 에서 계수만큼 뻗는다.
var DIRS = [
[0, -1], [0.87, -0.5], [0.87, 0.5], [0, 1], [-0.87, 0.5], [-0.87, -0.5]
];
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
// 복사용 문자열 — 실제 polygon 전체 좌표. 미리보기는 CSS 가 같은 계수표로 그린다(주석 참조)
function clipText() {
var pts = DIRS.map(function (d, i) {
var r = readVar('--v' + i);
var x = Math.round(50 + d[0] * r);
var y = Math.round(50 + d[1] * r * 0.6);
return x + '% ' + y + '%';
});
return 'clip-path: polygon(' + pts.join(', ') + ');';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = clipText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './ClipPolygon.module.scss';
// 60도 간격 여섯 방향의 계수표 — vanilla scss 의 clip-path calc 와 같은 표를 공유한다
const DIRS: Array<[number, number]> = [
[0, -1], [0.87, -0.5], [0.87, 0.5], [0, 1], [-0.87, 0.5], [-0.87, -0.5]
];
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
export type ClipPolygonProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function ClipPolygon({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#17141a' }: ClipPolygonProps) {
const [values, setValues] = useState<number[]>([90, 60, 80, 60, 85, 65]);
const [live, setLive] = useState(true);
const clipStyle = useMemo(() => {
const pts = DIRS.map((d, i) => {
const x = 50 + d[0] * values[i];
const y = 50 + d[1] * values[i] * 0.6;
return `${x.toFixed(0)}% ${y.toFixed(0)}%`;
});
return `polygon(${pts.join(', ')})`;
}, [values]);
const codeText = useMemo(() => {
const pts = DIRS.map((d, i) => {
const x = Math.round(50 + d[0] * values[i]);
const y = Math.round(50 + d[1] * values[i] * 0.6);
return `${x}% ${y}%`;
});
return `clip-path: ${clipStyle};`;
}, [values, clipStyle]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.previewFrame} aria-hidden="true">
<div className={styles.preview} style={{ clipPath: clipStyle }}></div>
</div>
<div className={styles.grid}>
{DIRS.map((_, i) => (
<label className={styles.row} key={i}>
<span className={styles.lab}>{'꼭짓점' + (i + 1)}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={20}
max={100}
step={5}
value={values[i]}
aria-label={(i + 1) + ' 번째 꼭짓점 반경'}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => v.map((x, j) => (j === i ? Number(e.target.value) : x)));
}}
/>
<span className={styles.thumb} aria-hidden="true" style={{ left: `${((values[i] - 20) / 80) * 100}%` }} />
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>clip-path:</span> {values[0]}% {values[1]}% {values[2]}% {values[3]}% {values[4]}% {values[5]}%
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}04 · 다각형 꼭짓점 자르기 — 측정 누적면적 6.233% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
CSS 와 JS 가 같은 계수표를 공유한다
// 여섯 꼭짓점은 60도 간격 고정. 각 꼭짓점의 (dx, dy) 계수표 — CSS 와 JS 가 같은 표를 공유한다
// (60도 간격 여섯 방향의 단위원 좌표, 소수 셋째에서 반올림). 중심 50% 에서 계수만큼 뻗는다
var DIRS = [
[0, -1], [0.87, -0.5], [0.87, 0.5], [0, 1], [-0.87, 0.5], [-0.87, -0.5]
];
미리보기의 잘린 모양은 SCSS 의 calc 가 그리고, 복사 문자열은 JS 가 만드는데 둘이 같은 계수표를 쓰므로 화면과 복사한 코드가 어긋나지 않고, 꼭짓점 반경을 엇갈리게 움직이면 육각형이 별처럼 숨쉬어 측정은 누적면적 6.23%·강도 127.8·움직인 프레임 23/23 입니다.
세로 계수로 납작하게 누른다
.gen__preview {
box-sizing: border-box; width: 116px; height: 72px;
background: $subject-blue;
clip-path: polygon(
calc(50% + 0 * var(--v0) * 1%) calc(50% + -1 * var(--v0) * 0.6 * 1%),
calc(50% + 0.87 * var(--v1) * 1%) calc(50% + -0.5 * var(--v1) * 0.6 * 1%),
calc(50% + 0.87 * var(--v2) * 1%) calc(50% + 0.5 * var(--v2) * 0.6 * 1%)
);
}
세로 좌표에 0.6 을 곱해 미리보기 상자 비율에 맞추고(여섯 꼭짓점 전부 같은 계수), 점선 테두리는 잘리기 전 원래 크기를 보여 주어 어디가 잘렸는지 읽게 합니다.
05필터 체인 프리셋
밝기·대비·채도·블러를 한 줄에 이어 적고 프리셋 단추가 네 값을 한꺼번에 옮기는, 사진 갤러리 톤을 정하는 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true">
<span class="gen__sun"></span>
<span class="gen__hill gen__hill--a"></span>
<span class="gen__hill gen__hill--b"></span>
</div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">밝기</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="50" max="150" step="5" value="100" data-var="--f-bri" aria-label="밝기">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">대비</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="50" max="150" step="5" value="110" data-var="--f-con" aria-label="대비">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">채도</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="200" step="10" value="130" data-var="--f-sat" aria-label="채도">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">블러</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="8" step="1" value="0" data-var="--f-blur" aria-label="블러">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__presets">
<button class="gen__preset" type="button" data-preset="100,110,130,0">몽롱한 오후</button>
<button class="gen__preset" type="button" data-preset="100,115,0,0">잉크 사진</button>
<button class="gen__preset" type="button" data-preset="85,140,180,2">기억색</button>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">filter:</span> brightness(<span class="gen__v gen__v--bri"></span>%) contrast(<span class="gen__v gen__v--con"></span>%) saturate(<span class="gen__v gen__v--sat"></span>%) blur(<span class="gen__v gen__v--blur"></span>px)</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 필터 체인 프리셋 — 밝기·대비·채도·블러 네 값을 사용자 지정 속성에 모으고
// 미리보기 그림(해와 언덕)이 filter 한 줄로 전부 받는다. 프리셋 단추는 네 속성을 한꺼번에 옮긴다.
// 필터는 순서를 바꾸면 결과가 달라진다 — brightness 부터 blur 까지 이 순서가 정석이다(본문 참조).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-light;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$track: rgba(255, 255, 255, .2);
$rail: 4px;
$thumb: 12px;
@property --f-bri { syntax: "<integer>"; inherits: true; initial-value: 100; }
@property --f-con { syntax: "<integer>"; inherits: true; initial-value: 110; }
@property --f-sat { syntax: "<integer>"; inherits: true; initial-value: 130; }
@property --f-blur { syntax: "<integer>"; inherits: true; initial-value: 0; }
@include stage($stage-ink);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--f-bri: 100;
--f-con: 110;
--f-sat: 130;
--f-blur: 0;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 해와 언덕. 채도가 0 이 되면 회색으로 가라앉고 블러가 번진다
.gen__preview {
position: relative; flex: 0 0 auto; overflow: hidden;
box-sizing: border-box; width: 116px; height: 80px;
border-radius: $r-sm;
background: $stage-yellow;
filter:
brightness(calc(var(--f-bri) * 1%))
contrast(calc(var(--f-con) * 1%))
saturate(calc(var(--f-sat) * 1%))
blur(calc(var(--f-blur) * 1px));
}
.gen__sun {
position: absolute; left: 22px; top: 14px;
width: 28px; height: 28px; border-radius: 50%;
background: $stage-orange;
}
.gen__hill { position: absolute; bottom: 0; width: 90px; height: 44px; border-radius: $r-pill $r-pill 0 0; }
.gen__hill--a { left: -20px; background: $subject-blue; }
.gen__hill--b { right: -24px; background: $subject-blue-deep; }
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $sp-1 $sp-2; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 26px;
color: $subject-cream;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc((var(--f-bri) - 50) / 100 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc((var(--f-con) - 50) / 100 * 100); }
.gen__row:nth-child(3) .gen__thumb { --pct: calc(var(--f-sat) / 200 * 100); }
.gen__row:nth-child(4) .gen__thumb { --pct: calc(var(--f-blur) / 8 * 100); }
// ── 프리셋 단추 줄
.gen__presets { display: flex; gap: $sp-2; }
.gen__preset {
flex: 1 1 0;
padding: $sp-1 $sp-2; border: 1px solid $line; border-radius: $r-pill;
background: rgba(255, 255, 255, .06); color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 10px; line-height: 1.4;
cursor: pointer;
}
.gen__preset:hover { background: rgba(255, 255, 255, .12); }
.gen__preset:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--bri::after { counter-reset: v var(--f-bri); content: counter(v); }
.gen__v--con::after { counter-reset: v var(--f-con); content: counter(v); }
.gen__v--sat::after { counter-reset: v var(--f-sat); content: counter(v); }
.gen__v--blur::after { counter-reset: v var(--f-blur); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 채도가 꺼졌다 켜지고 블러가 번졌다 잡힌다 (프리셋 사이를 오가는 느낌)
@keyframes fBri { 0%, 100% { --f-bri: 100; } 50% { --f-bri: 85; } }
@keyframes fCon { 0%, 100% { --f-con: 110; } 50% { --f-con: 140; } }
@keyframes fSat { 0%, 100% { --f-sat: 130; } 30% { --f-sat: 0; } 70% { --f-sat: 180; } }
@keyframes fBlur { 0%, 100% { --f-blur: 0; } 45% { --f-blur: 2; } 85% { --f-blur: 0; } }
.gen.is-demo {
animation-name: fBri, fCon, fSat, fBlur;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview { width: 96px; height: 68px; }
.gen__preset { font-size: 9px; }
.gen__code { font-size: 9px; }
}@property --f-bri {
syntax: "<integer>";
inherits: true;
initial-value: 100;
}
@property --f-con {
syntax: "<integer>";
inherits: true;
initial-value: 110;
}
@property --f-sat {
syntax: "<integer>";
inherits: true;
initial-value: 130;
}
@property --f-blur {
syntax: "<integer>";
inherits: true;
initial-value: 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);
}
.gen {
min-width: 0;
--f-bri: 100;
--f-con: 110;
--f-sat: 130;
--f-blur: 0;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
position: relative;
flex: 0 0 auto;
overflow: hidden;
box-sizing: border-box;
width: 116px;
height: 80px;
border-radius: 8px;
background: #ffd23f;
filter: brightness(calc(var(--f-bri) * 1%)) contrast(calc(var(--f-con) * 1%)) saturate(calc(var(--f-sat) * 1%)) blur(calc(var(--f-blur) * 1px));
}
.gen__sun {
position: absolute;
left: 22px;
top: 14px;
width: 28px;
height: 28px;
border-radius: 50%;
background: #ff4d1f;
}
.gen__hill {
position: absolute;
bottom: 0;
width: 90px;
height: 44px;
border-radius: 999px 999px 0 0;
}
.gen__hill--a {
left: -20px;
background: #2f6df6;
}
.gen__hill--b {
right: -24px;
background: #0a4fe8;
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 26px;
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #5b8cf8;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc((var(--f-bri) - 50) / 100 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc((var(--f-con) - 50) / 100 * 100);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: calc(var(--f-sat) / 200 * 100);
}
.gen__row:nth-child(4) .gen__thumb {
--pct: calc(var(--f-blur) / 8 * 100);
}
.gen__presets {
display: flex;
gap: 8px;
}
.gen__preset {
flex: 1 1 0;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.gen__preset:hover {
background: rgba(255, 255, 255, 0.12);
}
.gen__preset:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--bri::after {
counter-reset: v var(--f-bri);
content: counter(v);
}
.gen__v--con::after {
counter-reset: v var(--f-con);
content: counter(v);
}
.gen__v--sat::after {
counter-reset: v var(--f-sat);
content: counter(v);
}
.gen__v--blur::after {
counter-reset: v var(--f-blur);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@keyframes fBri {
0%, 100% {
--f-bri: 100;
}
50% {
--f-bri: 85;
}
}
@keyframes fCon {
0%, 100% {
--f-con: 110;
}
50% {
--f-con: 140;
}
}
@keyframes fSat {
0%, 100% {
--f-sat: 130;
}
30% {
--f-sat: 0;
}
70% {
--f-sat: 180;
}
}
@keyframes fBlur {
0%, 100% {
--f-blur: 0;
}
45% {
--f-blur: 2;
}
85% {
--f-blur: 0;
}
}
.gen.is-demo {
animation-name: fBri, fCon, fSat, fBlur;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 96px;
height: 68px;
}
.gen__preset {
font-size: 9px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var VARS = ['--f-bri', '--f-con', '--f-sat', '--f-blur'];
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 네 값은 @property 등록 정수 — 미리보기·손잡이·코드 숫자가 전부 여기에 매달린다
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function filterText() {
return 'filter: brightness(' + readVar('--f-bri') + '%) contrast(' + readVar('--f-con') + '%) saturate(' + readVar('--f-sat') + '%) blur(' + readVar('--f-blur') + 'px);';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
// 프리셋 단추 — 네 값을 한꺼번에 속성에 넣는다. 단추가 값의 출처라는 걸 data-preset 으로 보여 준다
Array.prototype.forEach.call(gen.querySelectorAll('.gen__preset'), function (btn) {
btn.addEventListener('click', function () {
gen.classList.remove('is-demo');
btn.dataset.preset.split(',').forEach(function (val, i) {
gen.style.setProperty(VARS[i], val);
ranges[i].value = val;
});
});
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = filterText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './FilterPresets.module.scss';
type Axis = { key: string; label: string; min: number; max: number; step: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'bri', label: '밝기', min: 50, max: 150, step: 5 },
{ key: 'con', label: '대비', min: 50, max: 150, step: 5 },
{ key: 'sat', label: '채도', min: 0, max: 200, step: 10 },
{ key: 'blur', label: '블러', min: 0, max: 8, step: 1 }
];
const PRESETS: Array<{ name: string; values: Record<string, number> }> = [
{ name: '몽롱한 오후', values: { bri: 100, con: 110, sat: 130, blur: 0 } },
{ name: '잉크 사진', values: { bri: 100, con: 115, sat: 0, blur: 0 } },
{ name: '기억색', values: { bri: 85, con: 140, sat: 180, blur: 2 } }
];
export type FilterPresetsProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function FilterPresets({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#5b8cf8' }: FilterPresetsProps) {
const [values, setValues] = useState<Record<string, number>>({ bri: 100, con: 110, sat: 130, blur: 0 });
const [live, setLive] = useState(true);
const filterStyle = useMemo(() => {
return `brightness(${values.bri}%) contrast(${values.con}%) saturate(${values.sat}%) blur(${values.blur}px)`;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color
} as Record<string, string | number>;
function copy() {
setLive(false);
const text = `filter: ${filterStyle};`;
try {
navigator.clipboard.writeText(text);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true" style={{ filter: filterStyle }}>
<span className={styles.sun}></span>
<span className={`${styles.hill} ${styles.hillA}`}></span>
<span className={`${styles.hill} ${styles.hillB}`}></span>
</div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={axis.step}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.presets}>
{PRESETS.map((p) => (
<button key={p.name} className={styles.preset} type="button" onClick={() => { setLive(false); setValues(p.values); }}>
{p.name}
</button>
))}
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>filter:</span> brightness({values.bri}%) contrast({values.con}%) saturate({values.sat}%) blur({values.blur}px)
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}05 · 필터 체인 프리셋 — 측정 누적면적 9.446% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
순서가 바뀌면 결과가 달라진다
filter:
brightness(calc(var(--f-bri) * 1%))
contrast(calc(var(--f-con) * 1%))
saturate(calc(var(--f-sat) * 1%))
blur(calc(var(--f-blur) * 1px));
필터는 왼쪽부터 순서대로 적용되므로 blur 를 앞에 두면 흐려진 위에 대비가 걸려 결과가 달라지는데, brightness 부터 blur 까지가 흔히 쓰이는 정석이고, 채도가 0 이 되는 순간을 잡은 측정은 누적면적 9.45%·강도 96.3·움직인 프레임 23/23 입니다.
프리셋은 네 값을 한 번에 넣는다
// 프리셋 단추 — 네 값을 한꺼번에 속성에 넣는다. 단추가 값의 출처라는 걸 data-preset 으로 보여 준다
Array.prototype.forEach.call(gen.querySelectorAll('.gen__preset'), function (btn) {
btn.addEventListener('click', function () {
gen.classList.remove('is-demo');
btn.dataset.preset.split(',').forEach(function (val, i) {
gen.style.setProperty(VARS[i], val);
ranges[i].value = val;
});
});
});
data-preset 속성에 네 값이 쉼표로 들어 있어 단추 하나가 곧 저장된 값 묶음이고, 직접 조작 실측에서 단추를 눌렀을 때 슬라이더 넷과 코드 상자가 한꺼번에 바뀌는 것을 확인했습니다.
06격자 열과 간격
최소 폭과 간격을 슬라이더로 잡으면 repeat() 문법이 그대로 적히고 auto-fit 이 칸 수를 화면에 맞춰 정하는 반응형 격자 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true">
<span class="gen__cell gen__cell--a"></span><span class="gen__cell gen__cell--b"></span>
<span class="gen__cell gen__cell--b"></span><span class="gen__cell gen__cell--a"></span>
<span class="gen__cell gen__cell--a"></span><span class="gen__cell gen__cell--b"></span>
<span class="gen__cell gen__cell--b"></span><span class="gen__cell gen__cell--a"></span>
<span class="gen__cell gen__cell--a"></span><span class="gen__cell gen__cell--b"></span>
<span class="gen__cell gen__cell--b"></span><span class="gen__cell gen__cell--a"></span>
</div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">최소폭</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="80" max="260" step="20" value="120" data-var="--g-min" aria-label="칸 최소 폭">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">간격</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="24" step="4" value="8" data-var="--g-gap" aria-label="칸 간격">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">grid-template-columns:</span> repeat(auto-fit, minmax(<span class="gen__v gen__v--min"></span>px, 1fr)); <span class="gen__prop">gap:</span> <span class="gen__v gen__v--gap"></span>px</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 격자 열과 간격 — auto-fit 반응형 격자의 최소 폭과 간격을 사용자 지정 속성 둘로 잡는다.
// 열 개수 슬라이더가 없는 게 핵심이다: minmax 의 최소 폭이 열 개수를 화면 폭에 맞춰 스스로 정한다.
// 미리보기에서 칸들이 최소 폭이 커지면 한 줄로 펴졌다 작아지면 여러 줄로 접힌다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue-light;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$track: rgba(255, 255, 255, .2);
$rail: 4px;
$thumb: 12px;
@property --g-min { syntax: "<integer>"; inherits: true; initial-value: 120; }
@property --g-gap { syntax: "<integer>"; inherits: true; initial-value: 8; }
@include stage($stage-ink);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--g-min: 120;
--g-gap: 8;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 카드 12장이 auto-fit 으로 접히고 펴진다
.gen__preview {
flex: 0 0 auto; box-sizing: border-box;
width: 148px; height: 92px; padding: 4px;
border-radius: $r-sm;
background: rgba(0, 0, 0, .28);
display: grid; align-content: start;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--g-min) * 0.32px), 1fr));
gap: 0;
overflow: hidden;
}
// 미리보기 상자가 실제 사용 폭보다 작으므로 계수 0.32 로 눈금만 줄인다 — 칸 수 변화는 그대로 보인다
// 칸 간격은 gap 이 아니라 투명 border 로 준다(297 선례) — 칸 자신의 일부라
// 값이 소수 px 이 되어도 격자 눈금 규칙을 어기지 않는다. 좌우 상하 반씩 나눠 붙인다
.gen__cell {
height: 20px; border-radius: $r-xs; box-sizing: border-box;
border: calc(var(--g-gap) * 0.32px / 2) solid transparent;
background-clip: padding-box;
}
.gen__cell--a { background: $subject-blue; }
.gen__cell--b { background: $subject-pink; }
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: $sp-1; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 42px;
color: $subject-cream;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc((var(--g-min) - 80) / 180 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc(var(--g-gap) / 24 * 100); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--min::after { counter-reset: v var(--g-min); content: counter(v); }
.gen__v--gap::after { counter-reset: v var(--g-gap); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 최소 폭이 오가면 칸이 여러 줄로 접혔다 한 줄로 펴진다
@keyframes gMin { 0%, 100% { --g-min: 120; } 40% { --g-min: 240; } 75% { --g-min: 80; } }
@keyframes gGap { 0%, 100% { --g-gap: 8; } 50% { --g-gap: 20; } 85% { --g-gap: 0; } }
.gen.is-demo {
animation-name: gMin, gGap;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview { width: 116px; height: 84px; }
.gen__code { font-size: 9px; }
}@property --g-min {
syntax: "<integer>";
inherits: true;
initial-value: 120;
}
@property --g-gap {
syntax: "<integer>";
inherits: true;
initial-value: 8;
}
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);
}
.gen {
min-width: 0;
--g-min: 120;
--g-gap: 8;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
flex: 0 0 auto;
box-sizing: border-box;
width: 148px;
height: 92px;
padding: 4px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.28);
display: grid;
align-content: start;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--g-min) * 0.32px), 1fr));
gap: 0;
overflow: hidden;
}
.gen__cell {
height: 20px;
border-radius: 4px;
box-sizing: border-box;
border: calc(var(--g-gap) * 0.32px / 2) solid transparent;
background-clip: padding-box;
}
.gen__cell--a {
background: #2f6df6;
}
.gen__cell--b {
background: #ff9ebb;
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 42px;
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #5b8cf8;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc((var(--g-min) - 80) / 180 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc(var(--g-gap) / 24 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--min::after {
counter-reset: v var(--g-min);
content: counter(v);
}
.gen__v--gap::after {
counter-reset: v var(--g-gap);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@keyframes gMin {
0%, 100% {
--g-min: 120;
}
40% {
--g-min: 240;
}
75% {
--g-min: 80;
}
}
@keyframes gGap {
0%, 100% {
--g-gap: 8;
}
50% {
--g-gap: 20;
}
85% {
--g-gap: 0;
}
}
.gen.is-demo {
animation-name: gMin, gGap;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 116px;
height: 84px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// auto-fit + minmax 는 칸 수를 직접 세지 않고 최소 폭이 정하도록 두는 반응형 격자다.
// 그래서 슬라이더는 열 개수가 아니라 최소 폭과 간격만 잡는다 — 칸 수는 화면이 결정한다
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function gridText() {
return 'grid-template-columns: repeat(auto-fit, minmax(' + readVar('--g-min') + 'px, 1fr));\ngap: ' + readVar('--g-gap') + 'px;';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = gridText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './GridColumns.module.scss';
type Axis = { key: string; label: string; min: number; max: number; step: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'min', label: '최소폭', min: 80, max: 260, step: 20 },
{ key: 'gap', label: '간격', min: 0, max: 24, step: 4 }
];
export type GridColumnsProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function GridColumns({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#5b8cf8' }: GridColumnsProps) {
const [values, setValues] = useState<Record<string, number>>({ min: 120, gap: 8 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
return `grid-template-columns: repeat(auto-fit, minmax(${values.min}px, 1fr));\ngap: ${values.gap}px;`;
}, [values]);
const gridStyle = useMemo(() => {
// 미리보기 상자가 실제 사용 폭보다 작으므로 계수 0.32 로 눈금만 줄인다 — 칸 수 변화는 그대로 보인다
return {
gridTemplateColumns: `repeat(auto-fit, minmax(${values.min * 0.32}px, 1fr))`,
gap: `${values.gap * 0.32}px`
} as Record<string, string>;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true" style={gridStyle}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => (
<span key={i} className={i % 2 === 0 ? styles.cellA : styles.cellB}></span>
))}
</div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={axis.step}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>grid-template-columns:</span> repeat(auto-fit, minmax({values.min}px, 1fr)); <span className={styles.prop}>gap:</span> {values.gap}px
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}06 · 격자 열과 간격 — 측정 누적면적 10.212% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
열 개수 슬라이더가 없는 이유
.gen__preview {
display: grid; align-content: start;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--g-min) * 0.32px), 1fr));
gap: 0;
overflow: hidden;
}
auto-fit 과 minmax 는 칸 수를 직접 정하지 않고 최소 폭에 맡기는 반응형 격자여서 슬라이더는 최소 폭과 간격만 잡고 칸 수는 화면 폭이 결정하는데, 최소 폭이 커지면 칸이 여러 줄로 접히고 작아지면 한 줄로 펴져 측정은 누적면적 10.21%·강도 169.7·움직인 프레임 22/23 입니다.
칸 간격은 칸 자신의 테두리로
// 칸 간격은 gap 이 아니라 투명 border 로 준다(297 선례) — 칸 자신의 일부라
// 값이 소수 px 이 되어도 격자 눈금 규칙을 어기지 않는다. 좌우 상하 반씩 나눠 붙인다
.gen__cell {
height: 20px; border-radius: $r-xs; box-sizing: border-box;
border: calc(var(--g-gap) * 0.32px / 2) solid transparent;
background-clip: padding-box;
}
간격값을 칸마다 투명 테두리로 나눠 붙이면 간격이 소수 픽셀이 되어도 화면이 고르게 유지되고, 미리보기 계수 0.32 는 상자가 실제 사용 폭보다 작기 때문이며 칸 수 변화는 그대로 보입니다.
07글자 테두리 두께
테두리 두께와 채움 불투명도를 슬라이더로 잡고 속을 비우면 스티커 같은 글자가 되는, 사진 위 큰 제목을 위한 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true"><span class="gen__word">GO</span></div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">테두리</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="8" step="1" value="3" data-var="--t-sw" aria-label="테두리 두께">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">채움</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="100" step="10" value="100" data-var="--t-fill" aria-label="채움 불투명도">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">-webkit-text-stroke:</span> <span class="gen__v gen__v--sw"></span>px #17141a; <span class="gen__prop">color:</span> rgba(255,247,230,<span class="gen__v gen__v--fill"></span>%)</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 글자 테두리 두께 — -webkit-text-stroke 두께와 채움 불투명도를 속성 둘로 잡는다.
// 채움을 0 으로 내리면 속이 빈 스티커 글자가 된다. paint-order 로 스트로크를 글자 안쪽에
// 그리면 얇은 글자가 뭉개지지 않는다(SVG 관용을 CSS 가 물려받은 속성).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
@property --t-sw { syntax: "<integer>"; inherits: true; initial-value: 3; }
@property --t-fill { syntax: "<integer>"; inherits: true; initial-value: 100; }
@include stage($stage-yellow);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--t-sw: 3;
--t-fill: 100;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 큰 글자 두 개
.gen__preview {
flex: 0 0 auto;
display: grid; place-items: center;
box-sizing: border-box; width: 132px; height: 84px;
// 크림 채움(4.5:1 미달)을 깔개로 살린다 — 크림 글자 5.98:1, 잉크 테두리도 남색 위에서 보인다 (BD25)
background: $subject-blue-deep; border-radius: $r-sm;
}
.gen__word {
color: rgba(255, 247, 230, calc(var(--t-fill) * 1%));
-webkit-text-stroke: calc(var(--t-sw) * 1px) $subject-ink;
paint-order: stroke fill;
font-family: $mono; font-weight: 900; font-size: 44px; line-height: 1;
letter-spacing: 0.04em;
}
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: $sp-1; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 40px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc(var(--t-sw) / 8 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: var(--t-fill); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--sw::after { counter-reset: v var(--t-sw); content: counter(v); }
.gen__v--fill::after { counter-reset: v var(--t-fill); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 테두리가 굵어졌다 얇아지고 채움이 비었다 차오른다
@keyframes tSw { 0%, 100% { --t-sw: 3; } 40% { --t-sw: 7; } 80% { --t-sw: 1; } }
@keyframes tFill { 0%, 100% { --t-fill: 100; } 50% { --t-fill: 0; } }
.gen.is-demo {
animation-name: tSw, tFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__word { font-size: 36px; }
.gen__code { font-size: 9px; }
}@property --t-sw {
syntax: "<integer>";
inherits: true;
initial-value: 3;
}
@property --t-fill {
syntax: "<integer>";
inherits: true;
initial-value: 100;
}
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);
}
.gen {
min-width: 0;
--t-sw: 3;
--t-fill: 100;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
flex: 0 0 auto;
display: grid;
place-items: center;
box-sizing: border-box;
width: 132px;
height: 84px;
background: #0a4fe8;
border-radius: 8px;
}
.gen__word {
color: rgba(255, 247, 230, calc(var(--t-fill) * 1%));
-webkit-text-stroke: calc(var(--t-sw) * 1px) #17141a;
paint-order: stroke fill;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 900;
font-size: 44px;
line-height: 1;
letter-spacing: 0.04em;
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 40px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc(var(--t-sw) / 8 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: var(--t-fill);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--sw::after {
counter-reset: v var(--t-sw);
content: counter(v);
}
.gen__v--fill::after {
counter-reset: v var(--t-fill);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes tSw {
0%, 100% {
--t-sw: 3;
}
40% {
--t-sw: 7;
}
80% {
--t-sw: 1;
}
}
@keyframes tFill {
0%, 100% {
--t-fill: 100;
}
50% {
--t-fill: 0;
}
}
.gen.is-demo {
animation-name: tSw, tFill;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__word {
font-size: 36px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 테두리 두께와 채움 불투명도 — 속성 둘로 글자 하나를 통째로 다룬다.
// -webkit-text-stroke 는 접두사 그대로 쓰는 게 실무 표준이고, 구형 브라우저 대체는
// 여러 겹의 text-shadow 로 같은 굵기의 테두리를 흉내 낸다(아래 fallbackText)
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function strokeText() {
return '-webkit-text-stroke: ' + readVar('--t-sw') + 'px #17141a;\ncolor: rgba(255,247,230,' + readVar('--t-fill') + '%);';
}
function fallbackText() {
var sw = readVar('--t-sw');
// 네 방향(대각선 포함 여덟 방향) 같은 오프셋의 그림자로 테두리를 흉내 낸다
var shades = [];
for (var dx = -1; dx <= 1; dx++) {
for (var dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue;
shades.push((dx * sw) + 'px ' + (dy * sw) + 'px 0 #17141a');
}
}
return 'text-shadow: ' + shades.join(', ') + ';';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = strokeText() + '\n' + fallbackText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './TextOutline.module.scss';
type Axis = { key: string; label: string; min: number; max: number; step: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'sw', label: '테두리', min: 0, max: 8, step: 1 },
{ key: 'fill', label: '채움', min: 0, max: 100, step: 10 }
];
export type TextOutlineProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function TextOutline({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#17141a' }: TextOutlineProps) {
const [values, setValues] = useState<Record<string, number>>({ sw: 3, fill: 100 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
return `-webkit-text-stroke: ${values.sw}px #17141a;\ncolor: rgba(255,247,230,${values.fill}%);`;
}, [values]);
const wordStyle = useMemo(() => {
return {
color: `rgba(255,247,230,${values.fill}%)`,
WebkitTextStroke: `${values.sw}px #17141a`
} as Record<string, string>;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true"><span className={styles.word} style={wordStyle}>GO</span></div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={axis.step}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>-webkit-text-stroke:</span> {values.sw}px #17141a; <span className={styles.prop}>color:</span> rgba(255,247,230,{values.fill}%)
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}07 · 글자 테두리 두께 — 측정 누적면적 2.958% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
스트로크를 안쪽에 그리는 paint-order
.gen__word {
color: rgba(255, 247, 230, calc(var(--t-fill) * 1%));
-webkit-text-stroke: calc(var(--t-sw) * 1px) $subject-ink;
paint-order: stroke fill;
font-family: $mono; font-weight: 900; font-size: 44px; line-height: 1;
}
paint-order: stroke fill 은 SVG 관례를 CSS 가 물려받은 속성으로 스트로크를 글자 안쪽에 그려 얇은 글자가 뭉개지지 않게 하고, 채움을 0 으로 내리면 속이 빈 글자가 되어 측정은 누적면적 2.96%·강도 123.8·움직인 프레임 22/23 입니다.
오래된 브라우저용 그림자 대체
// 네 방향(대각선 포함 여덟 방향) 같은 오프셋의 그림자로 테두리를 흉내 낸다
var shades = [];
for (var dx = -1; dx <= 1; dx++) {
for (var dy = -1; dy <= 1; dy++) {
if (dx === 0 && dy === 0) continue;
shades.push((dx * sw) + 'px ' + (dy * sw) + 'px 0 #17141a');
}
}
return 'text-shadow: ' + shades.join(', ') + ';';
-webkit-text-stroke 가 없는 환경에서는 여덟 방향 같은 오프셋의 text-shadow 로 같은 굵기를 흉내 내는데, 복사 단추를 누르면 두 코드가 함께 들어가므로 붙여 넣은 곳에서 골라 쓰면 됩니다.
08타이포 배율 사다리
기준 크기와 배율 하나로 여섯 단계를 계산해 사다리로 보여 주고 그 값이 사용자 지정 속성 목록으로 떨어지는 글자 크기 결정 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true">
<span class="gen__step gen__step--1">Step 1</span>
<span class="gen__step gen__step--2">Step 2</span>
<span class="gen__step gen__step--3">Step 3</span>
<span class="gen__step gen__step--4">Step 4</span>
<span class="gen__step gen__step--5">Step 5</span>
<span class="gen__step gen__step--6">Step 6</span>
</div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">기준</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="12" max="16" step="1" value="14" data-var="--s-base" aria-label="기준 글자 크기">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">배율</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="110" max="140" step="5" value="120" data-var="--s-ratio" aria-label="단계 배율">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">--step-1:</span> <span class="gen__v gen__v--s1"></span>rem; <span class="gen__prop">--step-2:</span> <span class="gen__v gen__v--s2"></span>rem; <span class="gen__prop">--step-3:</span> <span class="gen__v gen__v--s3"></span>rem</code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 타이포 배율 사다리 — 기준 크기와 배율 하나로 여섯 단계가 calc 곱으로 저절로 계산된다.
// 배율은 백분율 정수(125 = 1.25 배)로 등록하고 calc 안에서 0.01 을 곱해 쓴다.
// 몇 곱이 되는지만 단계마다 다르지 식은 하나다 — JS 의 scaleText() 도 같은 식으로 rem 값을 만든다.
// 미리보기 계수 0.6 은 사다리 여섯 단계를 무대(480×300) 안에 들어가게 하는 표시 배율이다 —
// 단계 사이 비율은 그대로다(06 미리보기의 0.32 계수와 같은 관례). 복사 코드는 실제 px 값.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
@property --s-base { syntax: "<integer>"; inherits: true; initial-value: 14; }
@property --s-ratio { syntax: "<integer>"; inherits: true; initial-value: 120; }
@include stage($stage-paper);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--s-base: 14;
--s-ratio: 120;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(23, 20, 26, .04);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 여섯 단계 사다리. calc 곱의 개수만 단계마다 다르다
.gen__preview {
flex: 0 0 auto;
display: flex; flex-direction: column; align-items: flex-start; gap: $sp-1;
box-sizing: border-box; width: 148px; padding: $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
overflow: hidden;
}
.gen__step {
display: block; white-space: nowrap;
color: $subject-ink;
font-family: $mono; font-weight: 700; line-height: 1.1;
}
.gen__step--1 { font-size: calc(var(--s-base) * 0.6px); }
.gen__step--2 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * 0.01); }
.gen__step--3 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * 0.0001); }
.gen__step--4 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * 0.000001); }
.gen__step--5 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * 0.00000001); }
.gen__step--6 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * 0.0000000001); }
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: $sp-1; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 30px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc((var(--s-base) - 12) / 4 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc((var(--s-ratio) - 110) / 30 * 100); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
// 코드 숫자는 rem 세 자리 소수라 counter 정수로 못 쓴다 — 첫 단계 px 값만 표시하고
// 나머지는 미리보기 사다리가 그대로 보여 준다(전체 목록은 복사로 얻는다)
.gen__v { font-weight: 700; }
.gen__v--s1::after { counter-reset: v var(--s-base); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 기준과 배율이 오가면 사다리 전체가 숨쉰다
@keyframes sBase { 0%, 100% { --s-base: 14; } 40% { --s-base: 16; } 75% { --s-base: 12; } }
@keyframes sRatio { 0%, 100% { --s-ratio: 120; } 50% { --s-ratio: 140; } 85% { --s-ratio: 112; } }
.gen.is-demo {
animation-name: sBase, sRatio;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
// 폰 높이(200px) 안에 들어가게 사다리 아래쪽을 자른다 — 비율은 그대로다
.gen__preview { width: 116px; height: 92px; }
.gen__code { font-size: 9px; }
}@property --s-base {
syntax: "<integer>";
inherits: true;
initial-value: 14;
}
@property --s-ratio {
syntax: "<integer>";
inherits: true;
initial-value: 120;
}
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);
}
.gen {
min-width: 0;
--s-base: 14;
--s-ratio: 120;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
box-sizing: border-box;
width: 148px;
padding: 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
overflow: hidden;
}
.gen__step {
display: block;
white-space: nowrap;
color: #17141a;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
line-height: 1.1;
}
.gen__step--1 {
font-size: calc(var(--s-base) * 0.6px);
}
.gen__step--2 {
font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * 0.01);
}
.gen__step--3 {
font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * 0.0001);
}
.gen__step--4 {
font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * 0.000001);
}
.gen__step--5 {
font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * 0.00000001);
}
.gen__step--6 {
font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * 0.0000000001);
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 30px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc((var(--s-base) - 12) / 4 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc((var(--s-ratio) - 110) / 30 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--s1::after {
counter-reset: v var(--s-base);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes sBase {
0%, 100% {
--s-base: 14;
}
40% {
--s-base: 16;
}
75% {
--s-base: 12;
}
}
@keyframes sRatio {
0%, 100% {
--s-ratio: 120;
}
50% {
--s-ratio: 140;
}
85% {
--s-ratio: 112;
}
}
.gen.is-demo {
animation-name: sBase, sRatio;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 116px;
height: 92px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 기준 크기와 배율 — 다섯 단계 사다리가 calc 곱으로 저절로 따라온다(scss 의 calc 랑 같은 식).
// 속성은 --s-base(정수 px) 와 --s-ratio(백분율 정수) 둘뿐이다
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function scaleText() {
var base = readVar('--s-base');
var ratio = readVar('--s-ratio') / 100;
var lines = [];
for (var i = 1; i <= 6; i++) {
lines.push('--step-' + i + ': ' + (Math.round(base * Math.pow(ratio, i - 1) * 100) / 100 / 16).toFixed(3) + 'rem;');
}
return ':root {\n ' + lines.join('\n ') + '\n}';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = scaleText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './TypeScale.module.scss';
type Axis = { key: string; label: string; min: number; max: number; step: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'base', label: '기준', min: 12, max: 16, step: 1 },
{ key: 'ratio', label: '배율', min: 110, max: 140, step: 5 }
];
export type TypeScaleProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function TypeScale({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#17141a' }: TypeScaleProps) {
const [values, setValues] = useState<Record<string, number>>({ base: 14, ratio: 120 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
const ratio = values.ratio / 100;
const lines: string[] = [];
for (let i = 1; i <= 6; i++) {
lines.push(`--step-${i}: ${(values.base * Math.pow(ratio, i - 1) / 16).toFixed(3)}rem;`);
}
return `:root {\n ${lines.join('\n ')}\n}`;
}, [values]);
const stepSizes = useMemo(() => {
// 미리보기 계수 0.6 은 사다리를 무대 안에 들어가게 하는 표시 배율이다 — 단계 비율은 그대로다
const sizes: number[] = [];
for (let i = 0; i < 6; i++) {
sizes.push(values.base * Math.pow(values.ratio / 100, i) * 0.6);
}
return sizes;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true">
{stepSizes.map((size, i) => (
<span key={i} style={{ fontSize: `${size.toFixed(2)}px` }}>{'Step ' + (i + 1)}</span>
))}
</div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={axis.step}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>--step-1:</span> {(values.base / 16).toFixed(3)}rem; <span className={styles.prop}>--step-2:</span> {(values.base * values.ratio / 100 / 16).toFixed(3)}rem; <span className={styles.prop}>--step-3:</span> {(values.base * Math.pow(values.ratio / 100, 2) / 16).toFixed(3)}rem
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}08 · 타이포 배율 사다리 — 측정 누적면적 42.674% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
곱의 개수만 단계마다 다르다
.gen__step--1 { font-size: calc(var(--s-base) * 0.6px); }
.gen__step--2 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * 0.01); }
.gen__step--3 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * 0.0001); }
.gen__step--4 { font-size: calc(var(--s-base) * 0.6px * var(--s-ratio) * var(--s-ratio) * var(--s-ratio) * 0.000001); }
CSS 의 calc 는 거듭제곱이 없어서 배율을 곱할 때마다 한 번 더 쓰는데, 식은 하나이고 곱의 개수만 단계마다 달라서 값 하나가 사다리 전체를 숨쉬게 하고 여섯 줄이 전부 크기를 바꾸니 측정은 누적면적 42.67%·강도 153.5·움직인 프레임 21/23 입니다.
복사되는 쪽은 rem 목록이다
// 기준 크기와 배율 — 다섯 단계 사다리가 calc 곱으로 저절로 따라온다(scss 의 calc 랑 같은 식).
// 속성은 --s-base(정수 px) 와 --s-ratio(백분율 정수) 둘뿐이다
var base = readVar('--s-base');
var ratio = readVar('--s-ratio') / 100;
var lines = [];
for (var i = 1; i <= 6; i++) {
lines.push('--step-' + i + ': ' + (Math.round(base * Math.pow(ratio, i - 1) * 100) / 100 / 16).toFixed(3) + 'rem;');
}
복사 단추를 누르면 --step-1 부터 --step-6 까지 rem 값이 :root 묶음으로 나오고, 미리보기의 0.6 계수는 여섯 단계가 무대 안에 들어가게 하는 표시 배율일 뿐 단계 사이 비율은 그대로입니다.
09타이밍 값 미리보기
길이·지연·반복을 슬라이더로 잡으면 옆의 공이 그 값으로 바로 다시 뛰는 애니메이션 속도 결정 도구입니다.

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview" aria-hidden="true">
<span class="gen__track-line"></span>
<span class="gen__ball"></span>
</div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">길이</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="200" max="1200" step="100" value="600" data-var="--a-dur" aria-label="애니메이션 길이">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">지연</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="400" step="50" value="0" data-var="--a-delay" aria-label="시작 지연">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">반복</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="1" max="5" step="1" value="2" data-var="--a-iter" aria-label="반복 횟수">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">animation-duration:</span> <span class="gen__v gen__v--dur"></span>ms; <span class="gen__prop">animation-delay:</span> <span class="gen__v gen__v--delay"></span>ms; <span class="gen__prop">animation-iteration-count:</span> <span class="gen__v gen__v--iter"></span></code>
<button class="gen__copy" type="button" id="copy">복사</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 타이밍 값 미리보기 — 길이·지연·반복 세 값이 공의 animation 낱개 속성을 그대로 움직인다.
// animation 단축이 아니라 낱개 속성을 쓰는 게 이 데모의 주제이기도 하다: 단축은 자리에 없는
// 하위 속성을 초기값(0s) 으로 리셋하므로, delay 를 따로 두는 순간 단축이 그걸 지운다.
// 미리보기(.is-demo)는 세 속성을 그대로 오가게 둔다 — 공의 duration 이 매 순간 바뀌는 것도
// 제너레이터가 보여 주려는 값의 효과 그 자체다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-ink;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
@property --a-dur { syntax: "<integer>"; inherits: true; initial-value: 600; }
@property --a-delay { syntax: "<integer>"; inherits: true; initial-value: 0; }
@property --a-iter { syntax: "<integer>"; inherits: true; initial-value: 2; }
@include stage($stage-yellow);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--a-dur: 600;
--a-delay: 0;
--a-iter: 2;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 공이 트랙을 왕복한다. 세 속성이 그대로 반영된다
.gen__preview {
position: relative; flex: 0 0 auto;
box-sizing: border-box; width: 148px; height: 76px;
border-radius: $r-sm;
background: rgba(23, 20, 26, .06);
}
.gen__track-line {
position: absolute; left: 16px; right: 16px; top: 50%;
height: $rail; margin-top: -($rail * .5);
border-radius: $r-pill; background: rgba(23, 20, 26, .18);
}
.gen__ball {
position: absolute; left: 16px; top: 50%;
width: 20px; height: 20px; margin-top: -10px;
border-radius: 50%; background: $subject-blue;
// 낱개 속성 — 단축이 delay 를 리셋하는 함정을 피한다
animation-name: ballRun;
animation-duration: calc(var(--a-dur) * 1ms);
animation-delay: calc(var(--a-delay) * 1ms);
animation-iteration-count: var(--a-iter);
animation-timing-function: $easing;
animation-direction: alternate;
animation-fill-mode: both;
}
@keyframes ballRun { from { transform: translateX(0); } to { transform: translateX(96px); } }
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: $sp-1; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 30px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc((var(--a-dur) - 200) / 1000 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc(var(--a-delay) / 400 * 100); }
.gen__row:nth-child(3) .gen__thumb { --pct: calc((var(--a-iter) - 1) / 4 * 100); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--dur::after { counter-reset: v var(--a-dur); content: counter(v); }
.gen__v--delay::after { counter-reset: v var(--a-delay); content: counter(v); }
.gen__v--iter::after { counter-reset: v var(--a-iter); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 세 속성이 오가는 동안 공은 계속 값대로 왕복한다
@keyframes aDur { 0%, 100% { --a-dur: 600; } 40% { --a-dur: 1100; } 75% { --a-dur: 300; } }
@keyframes aDelay { 0%, 100% { --a-delay: 0; } 50% { --a-delay: 200; } }
@keyframes aIter { 0%, 100% { --a-iter: 2; } 45% { --a-iter: 4; } 80% { --a-iter: 1; } }
.gen.is-demo {
animation-name: aDur, aDelay, aIter;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
// reduce 에서는 속성 순환과 공 모두 멈춘다 — 공은 시작 위치에 남긴다
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
.gen__ball { animation-name: none; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview { width: 116px; }
.gen__code { font-size: 9px; }
}@property --a-dur {
syntax: "<integer>";
inherits: true;
initial-value: 600;
}
@property --a-delay {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --a-iter {
syntax: "<integer>";
inherits: true;
initial-value: 2;
}
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);
}
.gen {
min-width: 0;
--a-dur: 600;
--a-delay: 0;
--a-iter: 2;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: 148px;
height: 76px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
}
.gen__track-line {
position: absolute;
left: 16px;
right: 16px;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__ball {
position: absolute;
left: 16px;
top: 50%;
width: 20px;
height: 20px;
margin-top: -10px;
border-radius: 50%;
background: #2f6df6;
animation-name: ballRun;
animation-duration: calc(var(--a-dur) * 1ms);
animation-delay: calc(var(--a-delay) * 1ms);
animation-iteration-count: var(--a-iter);
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-direction: alternate;
animation-fill-mode: both;
}
@keyframes ballRun {
from {
transform: translateX(0);
}
to {
transform: translateX(96px);
}
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 30px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc((var(--a-dur) - 200) / 1000 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc(var(--a-delay) / 400 * 100);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: calc((var(--a-iter) - 1) / 4 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--dur::after {
counter-reset: v var(--a-dur);
content: counter(v);
}
.gen__v--delay::after {
counter-reset: v var(--a-delay);
content: counter(v);
}
.gen__v--iter::after {
counter-reset: v var(--a-iter);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes aDur {
0%, 100% {
--a-dur: 600;
}
40% {
--a-dur: 1100;
}
75% {
--a-dur: 300;
}
}
@keyframes aDelay {
0%, 100% {
--a-delay: 0;
}
50% {
--a-delay: 200;
}
}
@keyframes aIter {
0%, 100% {
--a-iter: 2;
}
45% {
--a-iter: 4;
}
80% {
--a-iter: 1;
}
}
.gen.is-demo {
animation-name: aDur, aDelay, aIter;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
.gen__ball {
animation-name: none;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview {
width: 116px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var ball = gen.querySelector('.gen__ball');
var COPY_LABEL = '복사';
var DONE_LABEL = '복사됨';
// 길이·지연·반복 세 값 — 미리보기 공의 animation 낱개 속성이 전부 여기서 나온다.
// 단축(animation:)은 쓰지 않는다: 단축은 delay 를 0 으로 리셋하는 함정이 있다(scss 주석 참조)
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function timingText() {
return 'animation-duration: ' + readVar('--a-dur') + 'ms;\nanimation-delay: ' + readVar('--a-delay') + 'ms;\nanimation-iteration-count: ' + readVar('--a-iter') + ';';
}
// 값이 바뀌면 공의 애니메이션을 새로 띄운다 — getAnimations 으로 잡아 시계를 0 에 되돌린다
function restartBall() {
ball.getAnimations().forEach(function (a) {
a.cancel();
});
// cancel 뒤 같은 이름의 애니메이션이 다시 만들어진다 — CSS 시트가 그대로 있으므로
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
restartBall();
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = timingText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './TimingPreview.module.scss';
type Axis = { key: string; label: string; min: number; max: number; step: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'dur', label: '길이', min: 200, max: 1200, step: 100 },
{ key: 'delay', label: '지연', min: 0, max: 400, step: 50 },
{ key: 'iter', label: '반복', min: 1, max: 5, step: 1 }
];
export type TimingPreviewProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function TimingPreview({ duration = '2s', easing = 'cubic-bezier(.2, 1.4, .4, 1)', color = '#17141a' }: TimingPreviewProps) {
const [values, setValues] = useState<Record<string, number>>({ dur: 600, delay: 0, iter: 2 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
return `animation-duration: ${values.dur}ms;\nanimation-delay: ${values.delay}ms;\nanimation-iteration-count: ${values.iter};`;
}, [values]);
// 값이 바뀔 때마다 key 를 바꿔 애니메이션을 새로 띄운다 — CSS cancel 에 해당하는 React 관용
const animKey = useMemo(() => `${values.dur}-${values.delay}-${values.iter}`, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color,
'--a-dur': values.dur,
'--a-delay': values.delay,
'--a-iter': values.iter
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.preview} aria-hidden="true">
<span className={styles.trackLine}></span>
<span key={animKey} className={styles.ball}></span>
</div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={axis.step}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${((values[axis.key] - axis.min) / (axis.max - axis.min)) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>animation-duration:</span> {values.dur}ms; <span className={styles.prop}>animation-delay:</span> {values.delay}ms; <span className={styles.prop}>animation-iteration-count:</span> {values.iter}
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}09 · 타이밍 값 미리보기 — 측정 누적면적 3.245% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
단축이 아니라 낱개 속성을 쓰는 이유
// 낱개 속성 — 단축이 delay 를 리셋하는 함정을 피한다
animation-name: ballRun;
animation-duration: calc(var(--a-dur) * 1ms);
animation-delay: calc(var(--a-delay) * 1ms);
animation-iteration-count: var(--a-iter);
animation-timing-function: $easing;
animation-direction: alternate;
animation-fill-mode: both;
animation: 단축은 자리에 없는 하위 속성을 초기값으로 되돌려 지연을 따로 두는 순간 그걸 지우므로, 이 도구는 처음부터 낱개 속성으로 쓰고, 값이 오가는 동안 공은 계속 그 값대로 왕복해 측정은 누적면적 3.25%·강도 146.0·움직인 프레임 23/23 입니다.
값을 바꾸면 공을 다시 띄운다
// 값이 바뀌면 공의 애니메이션을 새로 띄운다 — getAnimations 으로 잡아 시계를 0 에 되돌린다
function restartBall() {
ball.getAnimations().forEach(function (a) {
a.cancel();
});
// cancel 뒤 같은 이름의 애니메이션이 다시 만들어진다 — CSS 시트가 그대로 있으므로
}
슬라이더를 끌면 getAnimations() 로 잡은 애니메이션을 취소하는데, CSS 시트가 그대로 있으므로 같은 이름의 애니메이션이 다시 만들어져 공이 새 길이값으로 0 부터 뜁니다.
css 박스 그림자는 슬라이더 몇 개로 만드나?
css 박스 그림자를 결정하는 값은 가로·세로·번짐·퍼짐·색 다섯 개이지만 색은 디자인 시스템이 정해 두는 경우가 많아 이 도구는 네 슬라이더로 시작합니다. 그림자를 잡을 때 눈으로 먼저 보고 값을 읽는 순서가 중요합니다 — 감으로 16px 를 찍는 것보다 슬라이더로 2px 간격을 오가며 카드가 뜨는 지점을 찾는 편이 빠릅니다. 두 겹 쌓기의 아래 겹은 절반값이므로 실제 조절 축은 넷으로 충분합니다.
아홉 개의 조절값과 내놓는 코드
| 항목 | 조절값 | 내놓는 코드 |
|---|---|---|
| 01 그림자 | 가로·세로·번짐·퍼짐 | box-shadow 두 겹 |
| 02 그라디언트 | 각도·갈림 A·갈림 B | linear-gradient |
| 03 모서리 | 네 모서리 반경·세로 반경 | border-radius 슬래시 |
| 04 다각형 | 여섯 꼭짓점 반경 | clip-path: polygon |
| 05 필터 | 밝기·대비·채도·블러 | filter 체인 |
| 06 격자 | 최소 폭·간격 | grid-template-columns + gap |
| 07 글자 테두리 | 두께·채움 | -webkit-text-stroke + color |
| 08 타이포 | 기준·배율 | --step-1 부터 --step-6 |
| 09 타이밍 | 길이·지연·반복 | animation- 낱개 속성 |
값은 전부 사용자 지정 속성으로 흐르므로, 한 항목의 미리보기·손잡이·코드 상자가 어긋날 수 없습니다. 직접 조작 실측에서도 아홉 항목 모두 슬라이더 이동에 값·미리보기·코드가 같이 움직였습니다(run/073/_조작실측.json).
어디서 깨지는가 — 함정
가장 먼저 깨진 곳은 320px 폭이었습니다. 데스크톱 폭(480px) 에서는 멀쩡하던 아홉 개 중 일곱 개가 폰 폭에서 본문이 331~444px 까지 밀려 나갔습니다. 원인은 슬라이더의 원래 폭이었습니다 — input[type=range] 는 자기 최소 크기를 가지고, 그 최소 크기가 격자 트랙과 카드를 그대로 밀어낸 것입니다. 고친 방법은 세 가지입니다: 트랙을 repeat(2, minmax(0, 1fr)) 로 최솟값을 0 으로 내리고, 슬라이더 자리와 입력에 min-width: 0 을 주고, 카드 자체도 무대 격자의 항목이라 그 자동 최소폭을 끊었습니다. 고친 뒤 아홉 개 전부 본문 320×200 에 들어갑니다.
두 번째는 코드 상자의 숫자였습니다. 값이 CSS 카운터로 그려지는 자리는 textContent 로 읽히지 않고, 계산값을 물어보면 counter(v) 라는 문자열이 그대로 돌아옵니다. 조작 실측 프로브는 결국 코드 상자를 찍은 픽셀 해시로 숫자가 바뀌는 것을 증명했습니다 — 눈에 보이는 것과 기계가 읽는 것이 다른 전형입니다. 세 번째는 클립보드였습니다. iframe 안에서 navigator.clipboard.writeText 는 거부된 프로미스를 돌려주는데 try/catch 만으로는 잡히지 않아 .catch 로 잡아, 화면 밖 textarea 를 선택해 복사하는 방식으로 떨어뜨렸습니다. 아홉 파일 전부 같은 패턴입니다. 이 파일 아홉 개의 압축 비밀번호는 ycejvu79 이고, 풀면 vanilla 판과 React 판이 함께 들어 있습니다.
접근성
prefers-reduced-motion 이 켜진 환경에서는 격자 미리보기의 값 순환이 멈추고 09 의 공도 시작 위치에 멈춥니다 — 요소가 사라지지는 않습니다. 슬라이더는 네이티브 input[type=range] 라 화살표 키로 값이 오가고(조작 실측도 키보드로 했습니다), 라벨은 aria-label 로 붙어 있으며 초점 링은 focus-visible 로 뚜렷합니다. 복사 단추를 누른 뒤에는 글자가 짧게 바뀌어 결과를 눈으로 확인할 수 있습니다.
FAQ
css 그림자 효과를 복사하면 두 줄이 같이 들어오는데 한 줄만 써도 되나요?
됩니다. 첫 줄이 주 그림자이고 둘째 줄이 절반값의 받침 그림자입니다. 카드 하나에는 첫 줄만으로 충분하고, 떠 있는 느낌을 강조하고 싶을 때 둘을 함께 쓰세요.
값이 이상한 단위로 보이는데 고쳐야 하나요?
아니요. 코드 상자의 숫자는 화면과 같은 사용자 지정 속성을 읽는 카운터라 내놓는 문자열과 같은 값입니다. 복사 단추를 누르면 JS 가 만든 온전한 코드가 들어가므로 그대로 붙여 쓰면 됩니다.
css 리니어 그라디언트 말고 conic 이나 radial 로 바꾸고 싶으면요?
02 의 코드에서 linear-gradient(각도, 색 정지점) 을 radial-gradient(원점, 색 정지점) 이나 conic-gradient(시작각, 색 정지점) 으로 이름만 바꾸면 됩니다. 정지점 문법은 셋이 같아 갈림값을 그대로 옮겨 쓸 수 있습니다.









