css corner-shape 9종 — border-radius 없이 깎는 모서리 모양
아홉 가지 모서리 모양 중 하나는 슈퍼타원 지수 값이 원과 사각형 사이를 흐르며 카드 능선이 바뀌고, 하나는 스쿠어클 비율 값에 따라 모서리가 보간되고, 하나는 베벨 각도 값이 커질수록 빗면 넓이 값이 함께 커집니다.
아홉 데모는 각자 다른 조작으로 값을 받습니다 — 01·02는 click 하나, 04·05·07·08은 click과 keydown(Enter·Space·방향키), 03·09는 pointerdown·pointerup·pointerleave(03은 pointercancel까지)·keydown·keyup으로 누르고 떼는 동작을 직접 재고, 06만 pointerenter·pointerleave·focus·blur·click으로 상태를 바꿉니다. 아홉 항목 모두 자동 시연이 .is-demo 클래스가 붙어 있고 prefers-reduced-motion이 거짓일 때만 requestAnimationFrame 루프 안에서 돌다가, 사람이 처음 조작하는 순간 해제됩니다. 아홉 항목 모두 그 환경에서 CSS.supports('corner-shape', 'superellipse(1)') 호출로 지원 여부를 직접 재서, 참이면 corner-shape 속성을, 거짓이면 border-radius 근사 폴백을 씁니다.
01 corner-shape-squircle-card
02 corner-shape-bevel-chip
03 corner-shape-scoop-button
04 corner-shape-notch-tab
05 corner-shape-wave-panel
06 corner-shape-morph-hover
07 corner-shape-radius-ladder
08 corner-shape-fallback-polyfill
09 corner-shape-frame-inset
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스쿼클 카드 — 슈퍼타원 지수 값이 원과 사각형 사이를 흐르며 카드 모서리 능선이 바뀌는 방식
- 02 베벨 칩 — 베벨 각도 값이 커질수록 칩 모서리 빗면의 넓이 값이 함께 커지는 방식
- 03 스쿠프 버튼 — 모서리가 안쪽으로 파이는 scoop 곡선 값이 움직이며 버튼 윤곽이 바뀌는 방식
- 04 노치 탭 — 선택된 탭 모서리에 노치 깊이 값만큼 홈이 파이는 방식
- 05 물결 패널 — 네 모서리의 곡선 값이 위상차를 두고 순서대로 흔들리는 방식
- 06 호버 모핑 — 대기 상태와 호버 상태의 모서리 곡선 값이 보간되며 넘어가는 방식
- 07 반경 사다리 — 한 카드의 반경 값을 고정된 다섯 단 사다리에서 오르내리며 같은 곡선의 결을 보는 방식
- 08 폴백 나란히 보기 — 같은 반경 값을 받은 지원 칸과 폴백 칸의 윤곽을 나란히 비교하는 방식
- 09 안쪽 테두리 — 겉곡선과 속곡선 값이 따로 놓여 이중 테두리가 생기는 방식
아홉 개의 해부 — 어떤 값이 모서리 곡선을 바꾸는가
다음 표에는 css 요소가 화면을 얼마나 채웠는지·움직임이 얼마나 강했는지·몇 프레임이나 값이 갱신됐는지 세 값이 항목별로 들어 있습니다.
| 항목 | 누적면적% | 강도_평균 | 움직인프레임 |
|---|---|---|---|
| 01 | 1.36% | 109.8 | 23/23 |
| 02 | 0.33% | 73.2 | 23/23 |
| 03 | 1.99% | 90.0 | 21/23 |
| 04 | 3.21% | 94.7 | 8/23 |
| 05 | 1.01% | 109.7 | 23/23 |
| 06 | 3.25% | 107.9 | 13/23 |
| 07 | 0.80% | 107.2 | 15/23 |
| 08 | 1.42% | 82.0 | 23/23 |
| 09 | 0.43% | 82.8 | 23/23 |
아홉 항목 중 누적면적이 가장 넓은 항목은 06 하나(round 48px 원호와 bevel 6px 사이를 지수감쇠 lerp로 오가며 네 모서리 픽셀이 한꺼번에 바뀝니다)이고, 가장 좁은 항목은 02 하나(칩 한쪽 모서리의 빗면만 좁게 움직입니다)이며, 강도가 가장 센 항목도 01 하나(카드 능선 전체가 스무더스텝으로 편 흐름을 따라 끊김 없이 바뀝니다)뿐입니다. 강도가 가장 낮은 항목은 02(73.2 — 좁은 빗면 안에서 천천히 값이 바뀝니다)이고, 움직인프레임이 가장 많은 다섯 항목은 01·02·05·08·09(23/23 — 자동 시연이 쉬는 구간 없이 매 프레임 값을 다시 계산합니다)이며 가장 적은 항목은 04(8/23 — 탭이 한 칸 넘어간 뒤 스프링이 멈춰 있는 시간이 훨씬 깁니다)입니다.
이 값을 미는 계산 방식으로 아홉 항목을 다시 묶으면 네 무리가 나옵니다.
01·08 — 경과 시간 하나를 주기 함수에 바로 넣어 값을 구하는 것
01은 SWEEP_MS로 나눈 경과 시간의 왕복 삼각파(triangle)를 5차 다항식 스무더스텝(smootherstep)으로 편 값으로 슈퍼타원 지수를 MIN_N(1)에서 SUPER_N(4.0)까지 흘려보냅니다. 08은 (1-cos(t×2π))/2 코사인 왕복 값으로 반지름을 R_MIN(6)과 R_MAX(34) 사이에서 움직이고, 왼쪽 지원 칸에만 corner-shape: superellipse(SUPER_N)을 함께 얹어 오른쪽 폴백 칸과 나란히 겨룹니다. 둘 다 스프링처럼 목표를 뒤쫓지 않고, 그 순간의 경과 시간 하나가 값을 바로 정합니다.
03·07 — 3차 이징 곡선으로 구간 사이 값을 굽히는 것
03은 누르고 있는 시간 비율에 3차 ease-out(1-(1-t)³)을 걸어 스쿠프 반경을 SCOOP_MIN(4)에서 SCOOP_MAX(28)까지 굽혀 키우고, 뗄 때는 3차 ease-in(t³)으로 되돌립니다. 07은 다섯 단 고정 반경 목록(RUNGS)을 한 칸씩 옮길 때마다 3차 ease-in-out으로 STEP_MS(380ms) 동안 보간해, 값이 단 사이에서 뚝 끊기지 않습니다.
04·06·09 — 목표를 향해 감쇠로 붙거나 넘쳤다 되돌아오는 값
04는 감쇠 진동 공식 2^(-10t)·sin((10t-0.75)×2π/3)+1을 노치 깊이에 곱해, 탭이 바뀔 때 NOTCH_MAX(14)를 살짝 넘겼다 가라앉는 오버슛을 만듭니다. 06은 매 프레임 follow = 1-exp(-LERP_K×dt)를 구해 보간 비율을 목표에 붙이고, 비율이 절반을 넘으면 곡선 종류를 round에서 bevel로 바꿉니다. 09는 속곡선 반경을 겉 반경(OUTER_R 40)에서 인셋을 뺀 값으로 구하고, 누르고 있을 때와 뗄 때 목표가 바뀌는 복귀를 같은 지수감쇠 lerp(INSET_SPRING_K)로 되돌립니다.
02·05 — 각도·위상을 삼각비로 바로 계산하는 것
02는 베벨 각도의 탄젠트로 가로 깊이(rx = BEVEL_RY×tan(각도))를 구하고, 두 반경의 hypot 합산으로 빗면 넓이를 잽니다. 05는 네 모서리 각각에 π/2씩 어긋난 위상을 주고 r = BASE_R + AMP×sin(t×2π + phase)로 반경을 구해, 물결이 모서리를 한 바퀴 도는 순서를 만듭니다.
패턴별 코드
01스쿼클 카드 — 슈퍼타원 지수 값이 원과 사각형 사이를 흐르며 카드 모서리 능선이 바뀌는 방식
슈퍼타원 지수 값이 커질수록 모서리가 사각형에 가까워지고 1에 가까워질수록 직선 절단에 가까워져, 한 카드에서 곡률의 성질이 흐릅니다. 흐름은 SWEEP_MS(2400ms) 동안 삼각파를 스무더스텝으로 편 값을 따라 MIN_N(1)에서 SUPER_N(4.0)까지 오갑니다.
// 계산 겹: 스무더스텝(5차 다항식, Perlin) 으로 왕복 삼각파를 매끄러운 흐름으로 편다 — 선형 트윈이 아니다
function smootherstep(u) { return u * u * u * (u * (u * 6 - 15) + 10); }
function triangle(x) { var f = x - Math.floor(x); return f < 0.5 ? f * 2 : (1 - f) * 2; }
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function setN(n) {
if (SUPPORTS) {
card.style.cornerShape = 'superellipse(' + n.toFixed(3) + ')';
card.style.borderRadius = '40px';
} else {
// 근사 폴백: 슈퍼타원 지수가 클수록 반경을 좁혀 각진 인상을 흉내(정확한 곡선은 아니다)
card.style.borderRadius = (40 / n).toFixed(2) + 'px';
}
readout.textContent = '지수 값 ' + n.toFixed(2);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="card" id="card" type="button" aria-pressed="false" aria-label="곡률 프리셋 전환"></button>
<p class="readout" id="readout" aria-live="polite">지수 값 1.00</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 smootherstep(5차 다항식) 삼각파 보간에 있다 — 여기는 무대·판만 맡는다.
// corner-shape 는 실험적 속성이라 @supports 로 감지한다. 미지원 환경은 JS 가 border-radius 근사로 대신한다.
@include stage($stage-ink);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.card {
appearance: none;
border: none;
width: 180px;
height: 112px;
background: $subject-blue;
border-radius: $r-card;
cursor: pointer;
transition: box-shadow $dur-quick $ease-out, transform $dur-instant $ease-out;
box-shadow: $shadow-raised;
&:hover { box-shadow: $shadow-press; }
&:active { transform: scale(.97); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
@supports not (corner-shape: superellipse(1)) {
.card { border: 1px solid rgba($subject-cream, .3); }
}
.readout {
margin: 0;
color: $subject-cream;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card { width: 140px; height: 92px; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.card {
appearance: none;
border: none;
width: 180px;
height: 112px;
background: #2f6df6;
border-radius: 16px;
cursor: pointer;
transition: box-shadow 220ms ease-out, transform 140ms ease-out;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.card:active {
transform: scale(0.97);
}
.card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
@supports not (corner-shape: superellipse(1)) {
.card {
border: 1px solid rgba(255, 247, 230, 0.3);
}
}
.readout {
margin: 0;
color: #fff7e6;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card {
width: 140px;
height: 92px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}(function () {
var SUPER_N = 4.0; // 슈퍼타원 지수 상한 — 흐름이 도달하는 사각형 쪽 끝값 (변형 01 목표)
var SWEEP_MS = 2400; // 원 → 사각형 → 원 한 바퀴 주기 (변형 03 목표)
var MIN_N = 1; // 슈퍼타원 지수 하한 — 원에 가장 가까운 끝값
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'superellipse(1)');
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var readout = document.getElementById('readout');
var isDemo = true;
var PRESETS = [MIN_N, (MIN_N + SUPER_N) / 2, SUPER_N];
var presetIdx = 0;
// 계산 겹: 스무더스텝(5차 다항식, Perlin) 으로 왕복 삼각파를 매끄러운 흐름으로 편다 — 선형 트윈이 아니다
function smootherstep(u) { return u * u * u * (u * (u * 6 - 15) + 10); }
function triangle(x) { var f = x - Math.floor(x); return f < 0.5 ? f * 2 : (1 - f) * 2; }
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function setN(n) {
if (SUPPORTS) {
card.style.cornerShape = 'superellipse(' + n.toFixed(3) + ')';
card.style.borderRadius = '40px';
} else {
// 근사 폴백: 슈퍼타원 지수가 클수록 반경을 좁혀 각진 인상을 흉내(정확한 곡선은 아니다)
card.style.borderRadius = (40 / n).toFixed(2) + 'px';
}
readout.textContent = '지수 값 ' + n.toFixed(2);
}
card.addEventListener('click', function () {
human();
presetIdx = (presetIdx + 1) % PRESETS.length;
card.setAttribute('aria-pressed', String(presetIdx > 0));
setN(PRESETS[presetIdx]);
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
setN(SUPER_N); // 계산은 유지, 시간 축소 대신 목표 상태로 즉시 고정
} else {
var u = triangle((ts - start) / SWEEP_MS);
setN(MIN_N + (SUPER_N - MIN_N) * smootherstep(u));
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// SquircleCard — vanilla 판(run/372/demo/01-corner-shape-squircle-card/index.html) 의 React 포트.
// 슈퍼타원 지수를 삼각파 → smootherstep(5차 다항식) 으로 편 흐름으로 순환시키고, corner-shape 미지원
// 환경에서는 JS 가 border-radius 근사로 대신한다.
import { useEffect, useRef } from "react";
import styles from "./SquircleCard.module.scss";
type Box<T> = { current: T };
const SUPER_N = 4.0; // 슈퍼타원 지수 상한 — 흐름이 도달하는 사각형 쪽 끝값 (변형 01 목표)
const SWEEP_MS = 2400; // 원 → 사각형 → 원 한 바퀴 주기 (변형 03 목표)
const MIN_N = 1; // 슈퍼타원 지수 하한 — 원에 가장 가까운 끝값
export default function SquircleCard() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "superellipse(1)");
const root = rootRef.current;
const card = cardRef.current;
const readout = readoutRef.current;
if (!root || !card || !readout) return;
let isDemo = true;
const PRESETS = [MIN_N, (MIN_N + SUPER_N) / 2, SUPER_N];
let presetIdx = 0;
// 계산 겹: 스무더스텝(5차 다항식) 으로 왕복 삼각파를 매끄러운 흐름으로 편다 — 선형 트윈이 아니다
function smootherstep(u: number) { return u * u * u * (u * (u * 6 - 15) + 10); }
function triangle(x: number) { const f = x - Math.floor(x); return f < 0.5 ? f * 2 : (1 - f) * 2; }
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function setN(n: number) {
if (SUPPORTS) {
(card!.style as any).cornerShape = "superellipse(" + n.toFixed(3) + ")";
card!.style.borderRadius = "40px";
} else {
card!.style.borderRadius = (40 / n).toFixed(2) + "px";
}
readout!.textContent = "지수 값 " + n.toFixed(2);
}
function onClick() {
human();
presetIdx = (presetIdx + 1) % PRESETS.length;
card!.setAttribute("aria-pressed", String(presetIdx > 0));
setN(PRESETS[presetIdx]);
}
card.addEventListener("click", onClick);
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
setN(SUPER_N);
} else {
const u = triangle((ts - start) / SWEEP_MS);
setN(MIN_N + (SUPER_N - MIN_N) * smootherstep(u));
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
card.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.card}
ref={cardRef}
type="button"
aria-pressed="false"
aria-label="곡률 프리셋 전환"
/>
<p className={styles.readout} ref={readoutRef} aria-live="polite">지수 값 1.00</p>
</div>
</main>
);
}01 · 스쿼클 카드 — 슈퍼타원 지수 값이 원과 사각형 사이를 흐르며 카드 모서리 능선이 바뀌는 방식 — 측정 누적면적 1.36% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02베벨 칩 — 베벨 각도 값이 커질수록 칩 모서리 빗면의 넓이 값이 함께 커지는 방식
베벨 각도 값이 8도에서 18도까지 오르는 동안 빗면 넓이 값이 각도의 탄젠트를 따라 넓어져, 절단면이 얇은 선에서 면으로 자라납니다. 가로 깊이는 BEVEL_RY(14)×tan(각도)로 구하고, 빗면 넓이는 두 반경의 hypot 합산으로 잽니다.
// 계산 겹: 베벨 각도 값에서 가로축 반경을 삼각비로 역산하고, 두 반경의 빗변 길이를 빗면 넓이로 삼는다
function applyAngle(deg) {
var rad = deg * Math.PI / 180;
var rx = BEVEL_RY * Math.tan(rad);
var facetWidth = Math.hypot(rx, BEVEL_RY);
if (SUPPORTS) {
chip.style.borderTopRightRadius = rx.toFixed(2) + 'px ' + BEVEL_RY + 'px';
} else {
// 근사 폴백: 대각 빗면 대신 같은 반경 크기의 둥근 모서리로 흉내
chip.style.borderTopRightRadius = facetWidth.toFixed(2) + 'px';
}
readout.textContent = '빗면 넓이 ' + facetWidth.toFixed(1);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="chip" id="chip" type="button" aria-pressed="false" aria-label="베벨 각도 전환">라벨</button>
<p class="readout" id="readout" aria-live="polite">빗면 넓이 14.0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 베벨 각도 값에서 가로축 반경을 tan() 으로 역산하고 빗변 길이를 빗면 넓이로 쓴다.
@include stage($stage-yellow);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.chip {
appearance: none;
border: none;
padding: $sp-3 $sp-8;
font-size: 14px;
font-weight: 700;
color: $subject-cream;
background: $subject-blue-deep;
corner-shape: bevel;
border-radius: $r-sm;
cursor: pointer;
transition: box-shadow $dur-quick $ease-out;
box-shadow: $shadow-raised;
&:hover { box-shadow: $shadow-press; }
&:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
}
@supports not (corner-shape: bevel) {
.chip { border: 1px solid rgba($subject-cream, .3); }
}
.readout {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.chip { padding: $sp-2 $sp-6; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.chip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.chip {
appearance: none;
border: none;
padding: 12px 32px;
font-size: 14px;
font-weight: 700;
color: #fff7e6;
background: #0a4fe8;
corner-shape: bevel;
border-radius: 8px;
cursor: pointer;
transition: box-shadow 220ms ease-out;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.chip:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.chip:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@supports not (corner-shape: bevel) {
.chip {
border: 1px solid rgba(255, 247, 230, 0.3);
}
}
.readout {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.chip {
padding: 8px 24px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}(function () {
var BEVEL_DEG = 18; // 빗면이 도달하는 각도 상한 값 (변형 02 목표)
var MIN_DEG = 8; // 빗면 각도 하한 값
var BEVEL_RY = 14; // 계산 겹 상수: 세로축 반경 고정값(px) — 각도가 커질수록 가로축만 늘어나 빗면이 넓어진다
var BEVEL_MS = 2000; // 각도가 하한에서 상한까지 한 번 오가는 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'bevel');
var wrap = document.getElementById('wrap');
var chip = document.getElementById('chip');
var readout = document.getElementById('readout');
var isDemo = true;
var manualDeg = null;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 베벨 각도 값에서 가로축 반경을 삼각비로 역산하고, 두 반경의 빗변 길이를 빗면 넓이로 삼는다
function applyAngle(deg) {
var rad = deg * Math.PI / 180;
var rx = BEVEL_RY * Math.tan(rad);
var facetWidth = Math.hypot(rx, BEVEL_RY);
if (SUPPORTS) {
chip.style.borderTopRightRadius = rx.toFixed(2) + 'px ' + BEVEL_RY + 'px';
} else {
// 근사 폴백: 대각 빗면 대신 같은 반경 크기의 둥근 모서리로 흉내
chip.style.borderTopRightRadius = facetWidth.toFixed(2) + 'px';
}
readout.textContent = '빗면 넓이 ' + facetWidth.toFixed(1);
}
chip.addEventListener('click', function () {
human();
manualDeg = manualDeg === BEVEL_DEG ? MIN_DEG : BEVEL_DEG;
chip.setAttribute('aria-pressed', String(manualDeg === BEVEL_DEG));
applyAngle(manualDeg);
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
applyAngle(BEVEL_DEG);
} else {
var t = ((ts - start) % BEVEL_MS) / BEVEL_MS;
var tri = t < 0.5 ? t * 2 : (1 - t) * 2;
applyAngle(MIN_DEG + (BEVEL_DEG - MIN_DEG) * tri);
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// BevelChip — vanilla 판(run/372/demo/02-corner-shape-bevel-chip/index.html) 의 React 포트.
// 베벨 각도 값에서 가로축 반경을 tan() 으로 역산하고 빗변 길이를 빗면 넓이로 쓴다.
import { useEffect, useRef } from "react";
import styles from "./BevelChip.module.scss";
type Box<T> = { current: T };
const BEVEL_DEG = 18; // 빗면이 도달하는 각도 상한 값 (변형 02 목표)
const MIN_DEG = 8; // 빗면 각도 하한 값
const BEVEL_RY = 14; // 계산 겹 상수: 세로축 반경 고정값(px) — 각도가 커질수록 가로축만 늘어나 빗면이 넓어진다
const BEVEL_MS = 2000; // 각도가 하한에서 상한까지 한 번 오가는 주기
export default function BevelChip() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const chipRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "bevel");
const root = rootRef.current;
const chip = chipRef.current;
const readout = readoutRef.current;
if (!root || !chip || !readout) return;
let isDemo = true;
let manualDeg: number | null = null;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
// 계산 겹: 베벨 각도 값에서 가로축 반경을 삼각비로 역산하고, 두 반경의 빗변 길이를 빗면 넓이로 삼는다
function applyAngle(deg: number) {
const rad = deg * Math.PI / 180;
const rx = BEVEL_RY * Math.tan(rad);
const facetWidth = Math.hypot(rx, BEVEL_RY);
if (SUPPORTS) {
chip!.style.borderTopRightRadius = rx.toFixed(2) + "px " + BEVEL_RY + "px";
} else {
chip!.style.borderTopRightRadius = facetWidth.toFixed(2) + "px";
}
readout!.textContent = "빗면 넓이 " + facetWidth.toFixed(1);
}
function onClick() {
human();
manualDeg = manualDeg === BEVEL_DEG ? MIN_DEG : BEVEL_DEG;
chip!.setAttribute("aria-pressed", String(manualDeg === BEVEL_DEG));
applyAngle(manualDeg);
}
chip.addEventListener("click", onClick);
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
applyAngle(BEVEL_DEG);
} else {
const t = ((ts - start) % BEVEL_MS) / BEVEL_MS;
const tri = t < 0.5 ? t * 2 : (1 - t) * 2;
applyAngle(MIN_DEG + (BEVEL_DEG - MIN_DEG) * tri);
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
chip.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.chip}
ref={chipRef}
type="button"
aria-pressed="false"
aria-label="베벨 각도 전환"
>라벨</button>
<p className={styles.readout} ref={readoutRef} aria-live="polite">빗면 넓이 14.0</p>
</div>
</main>
);
}02 · 베벨 칩 — 베벨 각도 값이 커질수록 칩 모서리 빗면의 넓이 값이 함께 커지는 방식 — 측정 누적면적 0.326% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03스쿠프 버튼 — 모서리가 안쪽으로 파이는 scoop 곡선 값이 움직이며 버튼 윤곽이 바뀌는 방식
안쪽으로 파인 곡선 값이 깊어질수록 버튼 가장자리가 오목해져, 볼록한 둥근 버튼과 정반대 인상을 만듭니다. 누르고 있는 시간 비율에 3차 ease-out을 걸어 SCOOP_MIN(4)에서 SCOOP_MAX(28)까지 굽혀 키우고, HOLD_MS(700ms) 동안 유지한 뒤 ease-in으로 되돌립니다.
// 계산 겹: 누르는 시간 비율을 3차 ease-out 으로 굽혀 스쿠프 반경에 매핑한다 — 선형 증가가 아니다
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function easeInCubic(t) { return t * t * t; }
function applyScoop(r) {
if (SUPPORTS) {
btn.style.cornerShape = 'scoop';
btn.style.borderRadius = r.toFixed(2) + 'px';
} else {
// 근사 폴백: 오목한 스쿠프 대신 같은 반경의 둥근 모서리로 흉내(오목/볼록만 다르다)
btn.style.borderRadius = r.toFixed(2) + 'px';
}
readout.textContent = '스쿠프 반경 ' + r.toFixed(1);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="btn" id="btn" type="button" aria-pressed="false" aria-label="눌러서 스쿠프 깊이 키우기">누르기</button>
<p class="readout" id="readout" aria-live="polite">스쿠프 반경 4.0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 누르는 시간 비율을 3차 ease 로 굽혀 스쿠프 반경에 매핑한다.
@include stage($stage-orange);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.btn {
appearance: none;
border: none;
padding: $sp-3 $sp-8;
font-size: 15px;
font-weight: 700;
color: $subject-ink;
background: $subject-mint;
corner-shape: scoop;
border-radius: $r-xs;
cursor: pointer;
transition: box-shadow $dur-quick $ease-out;
box-shadow: $shadow-raised;
&:hover { box-shadow: $shadow-press; }
&:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
}
@supports not (corner-shape: scoop) {
.btn { border: 1px solid rgba($subject-ink, .25); }
}
.readout {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.btn { padding: $sp-2 $sp-6; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.btn {
appearance: none;
border: none;
padding: 12px 32px;
font-size: 15px;
font-weight: 700;
color: #17141a;
background: #4cd4a6;
corner-shape: scoop;
border-radius: 4px;
cursor: pointer;
transition: box-shadow 220ms ease-out;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.btn:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@supports not (corner-shape: scoop) {
.btn {
border: 1px solid rgba(23, 20, 26, 0.25);
}
}
.readout {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.btn {
padding: 8px 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
}(function () {
var SCOOP_MAX = 28; // 다 눌렀을 때 도달하는 스쿠프 반경 상한(px)
var SCOOP_MIN = 4; // 손 뗀 상태의 스쿠프 반경 하한(px)
var HOLD_MS = 700; // 하한에서 상한까지 차오르는 데 걸리는 시간
var AUTO_MS = 1600; // 자동 루프에서 누름·뗌 한 번을 왕복하는 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'scoop');
var wrap = document.getElementById('wrap');
var btn = document.getElementById('btn');
var readout = document.getElementById('readout');
var isDemo = true;
var pressed = false;
var phaseStart = 0; // 현재 누름/뗌 상태로 바뀐 시각
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 누르는 시간 비율을 3차 ease-out 으로 굽혀 스쿠프 반경에 매핑한다 — 선형 증가가 아니다
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function easeInCubic(t) { return t * t * t; }
function applyScoop(r) {
if (SUPPORTS) {
btn.style.cornerShape = 'scoop';
btn.style.borderRadius = r.toFixed(2) + 'px';
} else {
// 근사 폴백: 오목한 스쿠프 대신 같은 반경의 둥근 모서리로 흉내(오목/볼록만 다르다)
btn.style.borderRadius = r.toFixed(2) + 'px';
}
readout.textContent = '스쿠프 반경 ' + r.toFixed(1);
}
function setPressed(next, ts) {
if (pressed === next) return;
pressed = next;
phaseStart = ts || performance.now();
btn.setAttribute('aria-pressed', String(pressed));
}
function press() { human(); setPressed(true); }
function release() { human(); setPressed(false); }
btn.addEventListener('pointerdown', press);
btn.addEventListener('pointerup', release);
btn.addEventListener('pointercancel', release);
btn.addEventListener('pointerleave', function () { if (pressed) release(); });
btn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); }
});
btn.addEventListener('keyup', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); release(); }
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
applyScoop(SCOOP_MAX);
} else {
var t = ((ts - start) % AUTO_MS) / AUTO_MS;
if (t < 0.5) { setPressed(true, ts); applyScoop(SCOOP_MIN + (SCOOP_MAX - SCOOP_MIN) * easeOutCubic(t * 2)); }
else { setPressed(false, ts); applyScoop(SCOOP_MAX - (SCOOP_MAX - SCOOP_MIN) * easeInCubic((t - 0.5) * 2)); }
}
} else if (!reduce) {
var elapsed = Math.min(1, (ts - phaseStart) / HOLD_MS);
var r = pressed
? SCOOP_MIN + (SCOOP_MAX - SCOOP_MIN) * easeOutCubic(elapsed)
: SCOOP_MAX - (SCOOP_MAX - SCOOP_MIN) * easeInCubic(elapsed);
applyScoop(r);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// ScoopButton — vanilla 판(run/372/demo/03-corner-shape-scoop-button/index.html) 의 React 포트.
// 누르는 시간 비율을 3차 ease 로 굽혀 스쿠프 반경에 매핑한다(선형 증가가 아니다).
import { useEffect, useRef } from "react";
import styles from "./ScoopButton.module.scss";
type Box<T> = { current: T };
const SCOOP_MAX = 28; // 다 눌렀을 때 도달하는 스쿠프 반경 상한(px)
const SCOOP_MIN = 4; // 손 뗀 상태의 스쿠프 반경 하한(px)
const HOLD_MS = 700; // 하한에서 상한까지 차오르는 데 걸리는 시간
const AUTO_MS = 1600; // 자동 루프에서 누름·뗌 한 번을 왕복하는 주기
export default function ScoopButton() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const btnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "scoop");
const root = rootRef.current;
const btn = btnRef.current;
const readout = readoutRef.current;
if (!root || !btn || !readout) return;
let isDemo = true;
let pressed = false;
let phaseStart = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
// 계산 겹: 누르는 시간 비율을 3차 ease-out 으로 굽혀 스쿠프 반경에 매핑한다 — 선형 증가가 아니다
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function easeInCubic(t: number) { return t * t * t; }
function applyScoop(r: number) {
if (SUPPORTS) {
(btn!.style as any).cornerShape = "scoop";
}
btn!.style.borderRadius = r.toFixed(2) + "px";
readout!.textContent = "스쿠프 반경 " + r.toFixed(1);
}
function setPressed(next: boolean, ts?: number) {
if (pressed === next) return;
pressed = next;
phaseStart = ts || performance.now();
btn!.setAttribute("aria-pressed", String(pressed));
}
function press() { human(); setPressed(true); }
function release() { human(); setPressed(false); }
function onPointerLeave() { if (pressed) release(); }
function onKeyDown(e: KeyboardEvent) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); press(); } }
function onKeyUp(e: KeyboardEvent) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); release(); } }
btn.addEventListener("pointerdown", press);
btn.addEventListener("pointerup", release);
btn.addEventListener("pointercancel", release);
btn.addEventListener("pointerleave", onPointerLeave);
btn.addEventListener("keydown", onKeyDown);
btn.addEventListener("keyup", onKeyUp);
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
applyScoop(SCOOP_MAX);
} else {
const t = ((ts - start) % AUTO_MS) / AUTO_MS;
if (t < 0.5) { setPressed(true, ts); applyScoop(SCOOP_MIN + (SCOOP_MAX - SCOOP_MIN) * easeOutCubic(t * 2)); }
else { setPressed(false, ts); applyScoop(SCOOP_MAX - (SCOOP_MAX - SCOOP_MIN) * easeInCubic((t - 0.5) * 2)); }
}
} else if (!reduce) {
const elapsed = Math.min(1, (ts - phaseStart) / HOLD_MS);
const r = pressed
? SCOOP_MIN + (SCOOP_MAX - SCOOP_MIN) * easeOutCubic(elapsed)
: SCOOP_MAX - (SCOOP_MAX - SCOOP_MIN) * easeInCubic(elapsed);
applyScoop(r);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
btn.removeEventListener("pointerdown", press);
btn.removeEventListener("pointerup", release);
btn.removeEventListener("pointercancel", release);
btn.removeEventListener("pointerleave", onPointerLeave);
btn.removeEventListener("keydown", onKeyDown);
btn.removeEventListener("keyup", onKeyUp);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.btn}
ref={btnRef}
type="button"
aria-pressed="false"
aria-label="눌러서 스쿠프 깊이 키우기"
>누르기</button>
<p className={styles.readout} ref={readoutRef} aria-live="polite">스쿠프 반경 4.0</p>
</div>
</main>
);
}03 · 스쿠프 버튼 — 모서리가 안쪽으로 파이는 scoop 곡선 값이 움직이며 버튼 윤곽이 바뀌는 방식 — 측정 누적면적 1.99% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04노치 탭 — 선택된 탭 모서리에 노치 깊이 값만큼 홈이 파이는 방식
활성 탭의 모서리에 노치 깊이 값이 지정한 만큼 홈이 새겨져, 어떤 탭이 열려 있는지 윤곽만으로 읽힙니다. 탭이 바뀐 순간부터 경과 비율을 감쇠 진동 공식에 넣어 NOTCH_MAX(14)를 살짝 넘겼다 가라앉는 스프링 오버슛(SPRING_MS 560ms)을 만듭니다.
// 계산 겹: 탭이 바뀔 때 노치 깊이가 목표값을 살짝 넘겼다가 가라앉는 감쇠 진동(스프링 오버슛)으로 움직인다
function springOvershoot(t) {
if (t <= 0) return 0;
if (t >= 1) return 1;
var c4 = (2 * Math.PI) / 3;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
}
function applyDepth(tab, depth) {
if (SUPPORTS) {
tab.style.cornerShape = 'notch';
tab.style.borderRadius = Math.max(0, depth).toFixed(2) + 'px';
} else {
// 근사 폴백: 파인 노치 대신 같은 크기의 둥근 모서리로 흉내(파임/둥긂만 다르다)
tab.style.borderRadius = Math.max(0, depth).toFixed(2) + 'px';
}
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="tabs" id="tabs" role="tablist" aria-label="노치 탭 전환">
<button class="tab" id="tab0" role="tab" aria-selected="true" tabindex="0" type="button">하나</button>
<button class="tab" id="tab1" role="tab" aria-selected="false" tabindex="-1" type="button">둘</button>
<button class="tab" id="tab2" role="tab" aria-selected="false" tabindex="-1" type="button">셋</button>
</div>
<p class="readout" id="readout" aria-live="polite">노치 깊이 0.0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 탭이 바뀔 때 노치 깊이가 감쇠 진동(스프링 오버슛)으로 안정된다.
@include stage($stage-paper);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.tabs {
display: flex;
gap: $sp-2;
background: $subject-cream;
padding: $sp-2;
border-radius: $r-control;
}
.tab {
appearance: none;
border: none;
padding: $sp-2 $sp-6;
font-size: 14px;
font-weight: 700;
color: $subject-ink;
background: $subject-lilac;
corner-shape: notch;
border-radius: $r-xs;
cursor: pointer;
&[aria-selected="true"] { background: $subject-sky; }
&:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
}
@supports not (corner-shape: notch) {
.tab { border: 1px solid rgba($subject-ink, .2); }
}
.readout {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.tab { padding: $sp-1 $sp-4; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.tab { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.tabs {
display: flex;
gap: 8px;
background: #fff7e6;
padding: 8px;
border-radius: 12px;
}
.tab {
appearance: none;
border: none;
padding: 8px 24px;
font-size: 14px;
font-weight: 700;
color: #17141a;
background: #b9a0ff;
corner-shape: notch;
border-radius: 4px;
cursor: pointer;
}
.tab[aria-selected=true] {
background: #6ec8ff;
}
.tab:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@supports not (corner-shape: notch) {
.tab {
border: 1px solid rgba(23, 20, 26, 0.2);
}
}
.readout {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.tab {
padding: 4px 16px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.tab {
transition: none;
}
}(function () {
var NOTCH_MAX = 14; // 활성 탭이 도달하는 노치 깊이 상한(px)
var SPRING_MS = 560; // 탭이 바뀐 뒤 스프링이 안정되기까지 걸리는 시간
var AUTO_MS = 1700; // 자동 루프에서 탭이 한 칸 넘어가는 간격
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'notch');
var wrap = document.getElementById('wrap');
var tabs = [document.getElementById('tab0'), document.getElementById('tab1'), document.getElementById('tab2')];
var readout = document.getElementById('readout');
var isDemo = true;
var active = 0;
var phaseStart = 0;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 탭이 바뀔 때 노치 깊이가 목표값을 살짝 넘겼다가 가라앉는 감쇠 진동(스프링 오버슛)으로 움직인다
function springOvershoot(t) {
if (t <= 0) return 0;
if (t >= 1) return 1;
var c4 = (2 * Math.PI) / 3;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
}
function applyDepth(tab, depth) {
if (SUPPORTS) {
tab.style.cornerShape = 'notch';
tab.style.borderRadius = Math.max(0, depth).toFixed(2) + 'px';
} else {
// 근사 폴백: 파인 노치 대신 같은 크기의 둥근 모서리로 흉내(파임/둥긂만 다르다)
tab.style.borderRadius = Math.max(0, depth).toFixed(2) + 'px';
}
}
function setActive(i, ts) {
if (active === i) return;
tabs[active].setAttribute('aria-selected', 'false');
tabs[active].tabIndex = -1;
applyDepth(tabs[active], 0);
active = i;
tabs[active].setAttribute('aria-selected', 'true');
tabs[active].tabIndex = 0;
phaseStart = ts || performance.now();
}
tabs.forEach(function (tab, i) {
tab.addEventListener('click', function () { human(); setActive(i, performance.now()); tab.focus({ preventScroll: true }); });
});
document.getElementById('tabs').addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
human();
var next = e.key === 'ArrowRight' ? (active + 1) % tabs.length : (active + tabs.length - 1) % tabs.length;
setActive(next, performance.now());
tabs[next].focus({ preventScroll: true });
});
var start = 0;
function loop(ts) {
if (!start) { start = ts; phaseStart = ts; }
if (isDemo && !reduce) {
var cyclePos = Math.floor((ts - start) / AUTO_MS) % tabs.length;
if (cyclePos !== active) setActive(cyclePos, ts);
}
var elapsed = reduce ? 1 : Math.min(1, (ts - phaseStart) / SPRING_MS);
var depth = NOTCH_MAX * (reduce ? 1 : springOvershoot(elapsed));
applyDepth(tabs[active], depth);
readout.textContent = '노치 깊이 ' + Math.max(0, depth).toFixed(1);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// NotchTab — vanilla 판(run/372/demo/04-corner-shape-notch-tab/index.html) 의 React 포트.
// 탭이 바뀔 때 노치 깊이가 목표값을 살짝 넘겼다가 가라앉는 감쇠 진동(스프링 오버슛)으로 움직인다.
import { useEffect, useRef } from "react";
import styles from "./NotchTab.module.scss";
type Box<T> = { current: T };
const NOTCH_MAX = 14; // 활성 탭이 도달하는 노치 깊이 상한(px)
const SPRING_MS = 560; // 탭이 바뀐 뒤 스프링이 안정되기까지 걸리는 시간
const AUTO_MS = 1700; // 자동 루프에서 탭이 한 칸 넘어가는 간격
const LABELS = ["하나", "둘", "셋"];
export default function NotchTab() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabsRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabRefs = useRef([]) as unknown as Box<HTMLButtonElement[]>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "notch");
const root = rootRef.current;
const tabsEl = tabsRef.current;
const tabs = tabRefs.current;
const readout = readoutRef.current;
if (!root || !tabsEl || tabs.length === 0 || !readout) return;
let isDemo = true;
let active = 0;
let phaseStart = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
// 계산 겹: 탭이 바뀔 때 노치 깊이가 목표값을 살짝 넘겼다가 가라앉는 감쇠 진동(스프링 오버슛)으로 움직인다
function springOvershoot(t: number) {
if (t <= 0) return 0;
if (t >= 1) return 1;
const c4 = (2 * Math.PI) / 3;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
}
function applyDepth(tab: HTMLButtonElement, depth: number) {
if (SUPPORTS) (tab.style as any).cornerShape = "notch";
tab.style.borderRadius = Math.max(0, depth).toFixed(2) + "px";
}
function setActive(i: number, ts: number) {
if (active === i) return;
tabs[active].setAttribute("aria-selected", "false");
tabs[active].tabIndex = -1;
applyDepth(tabs[active], 0);
active = i;
tabs[active].setAttribute("aria-selected", "true");
tabs[active].tabIndex = 0;
phaseStart = ts;
}
const onTabClick = (i: number) => () => { human(); setActive(i, performance.now()); tabs[i].focus({ preventScroll: true }); };
const clickHandlers = tabs.map((_, i) => onTabClick(i));
tabs.forEach((tab, i) => tab.addEventListener("click", clickHandlers[i]));
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
human();
const next = e.key === "ArrowRight" ? (active + 1) % tabs.length : (active + tabs.length - 1) % tabs.length;
setActive(next, performance.now());
tabs[next].focus({ preventScroll: true });
}
tabsEl.addEventListener("keydown", onKeyDown);
let start = 0;
function loop(ts: number) {
if (!start) { start = ts; phaseStart = ts; }
if (isDemo && !reduce) {
const cyclePos = Math.floor((ts - start) / AUTO_MS) % tabs.length;
if (cyclePos !== active) setActive(cyclePos, ts);
}
const elapsed = reduce ? 1 : Math.min(1, (ts - phaseStart) / SPRING_MS);
const depth = NOTCH_MAX * (reduce ? 1 : springOvershoot(elapsed));
applyDepth(tabs[active], depth);
readout!.textContent = "노치 깊이 " + Math.max(0, depth).toFixed(1);
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
tabs.forEach((tab, i) => tab.removeEventListener("click", clickHandlers[i]));
tabsEl.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<div className={styles.tabs} ref={tabsRef} role="tablist" aria-label="노치 탭 전환">
{LABELS.map((label, i) => (
<button
key={label}
className={styles.tab}
ref={(el: HTMLButtonElement | null) => { if (el) tabRefs.current[i] = el; }}
role="tab"
aria-selected={i === 0}
tabIndex={i === 0 ? 0 : -1}
type="button"
>{label}</button>
))}
</div>
<p className={styles.readout} ref={readoutRef} aria-live="polite">노치 깊이 0.0</p>
</div>
</main>
);
}04 · 노치 탭 — 선택된 탭 모서리에 노치 깊이 값만큼 홈이 파이는 방식 — 측정 누적면적 3.212% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05물결 패널 — 네 모서리의 곡선 값이 위상차를 두고 순서대로 흔들리는 방식
네 모서리가 각각 다른 위상 값으로 곡선을 바꿔 물결이 한 바퀴 도는 것처럼 패널 윤곽이 살아 움직입니다. 모서리마다 π/2씩 어긋난 위상으로 r = BASE_R(20) + AMP(16)×sin(t×WAVE_MS 주기 − 위상)을 구합니다.
// 계산 겹: 모서리마다 위상을 PI/2 씩 어긋낸 사인파 4개를 동시에 계산해 물결처럼 번갈아 부풀린다
function corners(t) {
var out = [];
for (var i = 0; i < 4; i++) {
var phase = i * (Math.PI / 2);
var r = BASE_R + AMP * Math.sin(t * Math.PI * 2 + phase);
out.push(Math.max(2, r));
}
return out;
}
function applyCorners(rs) {
for (var i = 0; i < 4; i++) {
if (SUPPORTS) panel.style.cornerShape = 'superellipse(2.2)';
panel.style['border' + CORNERS[i] + 'Radius'] = rs[i].toFixed(2) + 'px';
}
readout.textContent = '위상 어긋남 ' + (rs[0] - rs[2]).toFixed(1);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="panel" id="panel" type="button" aria-pressed="false" aria-label="네 모서리 물결 정지·재생"></button>
<p class="readout" id="readout" aria-live="polite">위상 어긋남 0.0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 모서리 네 곳의 위상을 어긋낸 사인파 네 개를 동시에 계산한다.
@include stage($stage-ink);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.panel {
appearance: none;
border: none;
width: 200px;
height: 120px;
background: linear-gradient(135deg, $subject-sky, $subject-lilac);
cursor: pointer;
box-shadow: $shadow-raised;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
@supports not (corner-shape: superellipse(1)) {
.panel { border: 1px solid rgba($subject-cream, .3); }
}
.readout {
margin: 0;
color: $subject-cream;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.panel { width: 150px; height: 96px; }
}
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.panel {
appearance: none;
border: none;
width: 200px;
height: 120px;
background: linear-gradient(135deg, #6ec8ff, #b9a0ff);
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.panel:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
@supports not (corner-shape: superellipse(1)) {
.panel {
border: 1px solid rgba(255, 247, 230, 0.3);
}
}
.readout {
margin: 0;
color: #fff7e6;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.panel {
width: 150px;
height: 96px;
}
}
@media (prefers-reduced-motion: reduce) {
.panel {
transition: none;
}
}(function () {
var BASE_R = 20; // 네 모서리가 공유하는 기준 반경(px)
var AMP = 16; // 반경이 기준에서 출렁이는 폭(px)
var WAVE_MS = 2200; // 물결이 한 바퀴 도는 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'superellipse(1)');
var wrap = document.getElementById('wrap');
var panel = document.getElementById('panel');
var readout = document.getElementById('readout');
var isDemo = true;
var paused = false;
var frozenAt = 0;
var CORNERS = ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'];
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 모서리마다 위상을 PI/2 씩 어긋낸 사인파 4개를 동시에 계산해 물결처럼 번갈아 부풀린다
function corners(t) {
var out = [];
for (var i = 0; i < 4; i++) {
var phase = i * (Math.PI / 2);
var r = BASE_R + AMP * Math.sin(t * Math.PI * 2 + phase);
out.push(Math.max(2, r));
}
return out;
}
function applyCorners(rs) {
for (var i = 0; i < 4; i++) {
if (SUPPORTS) panel.style.cornerShape = 'superellipse(2.2)';
panel.style['border' + CORNERS[i] + 'Radius'] = rs[i].toFixed(2) + 'px';
}
readout.textContent = '위상 어긋남 ' + (rs[0] - rs[2]).toFixed(1);
}
panel.addEventListener('click', function () {
human();
paused = !paused;
panel.setAttribute('aria-pressed', String(paused));
if (paused) frozenAt = performance.now();
});
panel.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); panel.click(); }
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
var t;
if (reduce) {
t = 0.25;
} else if (paused) {
t = ((frozenAt - start) % WAVE_MS) / WAVE_MS;
} else {
t = ((ts - start) % WAVE_MS) / WAVE_MS;
}
applyCorners(corners(t));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// WavePanel — vanilla 판(run/372/demo/05-corner-shape-wave-panel/index.html) 의 React 포트.
// 모서리 네 곳의 위상을 어긋낸 사인파 네 개를 동시에 계산해 물결처럼 번갈아 부풀린다.
import { useEffect, useRef } from "react";
import styles from "./WavePanel.module.scss";
type Box<T> = { current: T };
const BASE_R = 20; // 네 모서리가 공유하는 기준 반경(px)
const AMP = 16; // 반경이 기준에서 출렁이는 폭(px)
const WAVE_MS = 2200; // 물결이 한 바퀴 도는 주기
const CORNERS = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"] as const;
export default function WavePanel() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const panelRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "superellipse(1)");
const root = rootRef.current;
const panel = panelRef.current;
const readout = readoutRef.current;
if (!root || !panel || !readout) return;
let isDemo = true;
let paused = false;
let frozenAt = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
// 계산 겹: 모서리마다 위상을 PI/2 씩 어긋낸 사인파 4개를 동시에 계산해 물결처럼 번갈아 부풀린다
function corners(t: number) {
const out: number[] = [];
for (let i = 0; i < 4; i++) {
const phase = i * (Math.PI / 2);
const r = BASE_R + AMP * Math.sin(t * Math.PI * 2 + phase);
out.push(Math.max(2, r));
}
return out;
}
function applyCorners(rs: number[]) {
for (let i = 0; i < 4; i++) {
if (SUPPORTS) (panel!.style as any).cornerShape = "superellipse(2.2)";
(panel!.style as any)["border" + CORNERS[i] + "Radius"] = rs[i].toFixed(2) + "px";
}
readout!.textContent = "위상 어긋남 " + (rs[0] - rs[2]).toFixed(1);
}
function onClick() {
human();
paused = !paused;
panel!.setAttribute("aria-pressed", String(paused));
if (paused) frozenAt = performance.now();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); onClick(); }
}
panel.addEventListener("click", onClick);
panel.addEventListener("keydown", onKeyDown);
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
let t: number;
if (reduce) {
t = 0.25;
} else if (paused) {
t = ((frozenAt - start) % WAVE_MS) / WAVE_MS;
} else {
t = ((ts - start) % WAVE_MS) / WAVE_MS;
}
applyCorners(corners(t));
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
panel.removeEventListener("click", onClick);
panel.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.panel}
ref={panelRef}
type="button"
aria-pressed="false"
aria-label="네 모서리 물결 정지·재생"
/>
<p className={styles.readout} ref={readoutRef} aria-live="polite">위상 어긋남 0.0</p>
</div>
</main>
);
}05 · 물결 패널 — 네 모서리의 곡선 값이 위상차를 두고 순서대로 흔들리는 방식 — 측정 누적면적 1.01% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06호버 모핑 — 대기 상태와 호버 상태의 모서리 곡선 값이 보간되며 넘어가는 방식
호버가 바뀔 때마다 두 곡선 값 사이를 보간 값이 매끈히 이어, 둥근 모양에서 베벨 모양으로 끊김 없이 넘어갑니다. 매 프레임 follow = 1-exp(-LERP_K(9)×dt)를 구해 목표에 붙이고, 비율이 절반을 넘으면 곡선 종류를 round(ROUND_R 48)에서 bevel(BEVEL_R 6)로 바꿉니다.
// 계산 겹: 비율 값을 목표로 순간 이동시키지 않고 지수 감쇠(dt 기반 관성 lerp)로 붙인다 — 두 모양 사이 보간 비율이다
var last = 0;
function stepRatio(ts) {
var dt = last ? (ts - last) / 1000 : 0;
last = ts;
var follow = 1 - Math.exp(-LERP_K * dt);
ratio += (target - ratio) * follow;
if (Math.abs(ratio - target) < 0.001) ratio = target;
}
function applyMorph() {
var r = ROUND_R + (BEVEL_R - ROUND_R) * ratio;
var shape = ratio < 0.5 ? (SUPPORTS_ROUND ? 'round' : null) : (SUPPORTS_BEVEL ? 'bevel' : null);
if (shape) card.style.cornerShape = shape;
card.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '보간 비율 ' + Math.round(ratio * 100) + '%';
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="card" id="card" type="button" aria-pressed="false" aria-label="올려서 카드 모양 바꾸기"></button>
<p class="readout" id="readout" aria-live="polite">보간 비율 0%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 목표 비율로 순간 이동하지 않고 지수 감쇠 lerp 로 따라붙는다.
@include stage($stage-yellow);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.card {
appearance: none;
border: none;
width: 168px;
height: 108px;
background: $subject-blue-deep;
border-radius: $r-card;
cursor: pointer;
box-shadow: $shadow-raised;
&:hover { box-shadow: $shadow-press; }
&:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
}
@supports not (corner-shape: bevel) {
.card { border: 1px solid rgba($subject-ink, .2); }
}
.readout {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card { width: 130px; height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.card {
appearance: none;
border: none;
width: 168px;
height: 108px;
background: #0a4fe8;
border-radius: 16px;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card:hover {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.card:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@supports not (corner-shape: bevel) {
.card {
border: 1px solid rgba(23, 20, 26, 0.2);
}
}
.readout {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card {
width: 130px;
height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}(function () {
var ROUND_R = 48; // 대기 상태(둥근 원형)의 반경 상한(px)
var BEVEL_R = 6; // 호버 상태(각진 베벨)의 반경 하한(px)
var LERP_K = 9; // 목표 비율로 붙는 속도 상수 — 클수록 빨리 따라붙는다
var AUTO_MS = 2000; // 자동 루프에서 대기·호버를 오가는 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS_ROUND = CSS.supports('corner-shape', 'round');
var SUPPORTS_BEVEL = CSS.supports('corner-shape', 'bevel');
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var readout = document.getElementById('readout');
var isDemo = true;
var target = 0;
var ratio = 0;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function setTarget(v) { target = v; }
card.addEventListener('pointerenter', function () { human(); setTarget(1); });
card.addEventListener('pointerleave', function () { human(); setTarget(0); });
card.addEventListener('focus', function () { human(); setTarget(1); });
card.addEventListener('blur', function () { human(); setTarget(0); });
card.addEventListener('click', function () { human(); card.setAttribute('aria-pressed', String(target === 1)); });
// 계산 겹: 비율 값을 목표로 순간 이동시키지 않고 지수 감쇠(dt 기반 관성 lerp)로 붙인다 — 두 모양 사이 보간 비율이다
var last = 0;
function stepRatio(ts) {
var dt = last ? (ts - last) / 1000 : 0;
last = ts;
var follow = 1 - Math.exp(-LERP_K * dt);
ratio += (target - ratio) * follow;
if (Math.abs(ratio - target) < 0.001) ratio = target;
}
function applyMorph() {
var r = ROUND_R + (BEVEL_R - ROUND_R) * ratio;
var shape = ratio < 0.5 ? (SUPPORTS_ROUND ? 'round' : null) : (SUPPORTS_BEVEL ? 'bevel' : null);
if (shape) card.style.cornerShape = shape;
card.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '보간 비율 ' + Math.round(ratio * 100) + '%';
}
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
target = 1; // 계산은 유지, 시간 축소 대신 호버 목표 상태로 즉시 고정
} else {
var t = ((ts - start) % AUTO_MS) / AUTO_MS;
target = t < 0.5 ? 0 : 1;
}
}
if (reduce) {
ratio = target;
} else {
stepRatio(ts);
}
applyMorph();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// MorphHover — vanilla 판(run/372/demo/06-corner-shape-morph-hover/index.html) 의 React 포트.
// 비율 값을 목표로 순간 이동시키지 않고 지수 감쇠(dt 기반 관성 lerp)로 붙인다.
import { useEffect, useRef } from "react";
import styles from "./MorphHover.module.scss";
type Box<T> = { current: T };
const ROUND_R = 48; // 대기 상태(둥근 원형)의 반경 상한(px)
const BEVEL_R = 6; // 호버 상태(각진 베벨)의 반경 하한(px)
const LERP_K = 9; // 목표 비율로 붙는 속도 상수 — 클수록 빨리 따라붙는다
const AUTO_MS = 2000; // 자동 루프에서 대기·호버를 오가는 주기
export default function MorphHover() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS_ROUND = CSS.supports("corner-shape", "round");
const SUPPORTS_BEVEL = CSS.supports("corner-shape", "bevel");
const root = rootRef.current;
const card = cardRef.current;
const readout = readoutRef.current;
if (!root || !card || !readout) return;
let isDemo = true;
let target = 0;
let ratio = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function setTarget(v: number) { target = v; }
function onEnter() { human(); setTarget(1); }
function onLeave() { human(); setTarget(0); }
function onFocus() { human(); setTarget(1); }
function onBlur() { human(); setTarget(0); }
function onClick() { human(); card!.setAttribute("aria-pressed", String(target === 1)); }
card.addEventListener("pointerenter", onEnter);
card.addEventListener("pointerleave", onLeave);
card.addEventListener("focus", onFocus);
card.addEventListener("blur", onBlur);
card.addEventListener("click", onClick);
// 계산 겹: 비율 값을 목표로 순간 이동시키지 않고 지수 감쇠(dt 기반 관성 lerp)로 붙인다 — 두 모양 사이 보간 비율이다
let last = 0;
function stepRatio(ts: number) {
const dt = last ? (ts - last) / 1000 : 0;
last = ts;
const follow = 1 - Math.exp(-LERP_K * dt);
ratio += (target - ratio) * follow;
if (Math.abs(ratio - target) < 0.001) ratio = target;
}
function applyMorph() {
const r = ROUND_R + (BEVEL_R - ROUND_R) * ratio;
const shape = ratio < 0.5 ? (SUPPORTS_ROUND ? "round" : null) : (SUPPORTS_BEVEL ? "bevel" : null);
if (shape) (card!.style as any).cornerShape = shape;
card!.style.borderRadius = r.toFixed(2) + "px";
readout!.textContent = "보간 비율 " + Math.round(ratio * 100) + "%";
}
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
target = 1;
} else {
const t = ((ts - start) % AUTO_MS) / AUTO_MS;
target = t < 0.5 ? 0 : 1;
}
}
if (reduce) {
ratio = target;
} else {
stepRatio(ts);
}
applyMorph();
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
card.removeEventListener("pointerenter", onEnter);
card.removeEventListener("pointerleave", onLeave);
card.removeEventListener("focus", onFocus);
card.removeEventListener("blur", onBlur);
card.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.card}
ref={cardRef}
type="button"
aria-pressed="false"
aria-label="올려서 카드 모양 바꾸기"
/>
<p className={styles.readout} ref={readoutRef} aria-live="polite">보간 비율 0%</p>
</div>
</main>
);
}06 · 호버 모핑 — 대기 상태와 호버 상태의 모서리 곡선 값이 보간되며 넘어가는 방식 — 측정 누적면적 3.251% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07반경 사다리 — 한 카드의 반경 값을 고정된 다섯 단 사다리에서 오르내리며 같은 곡선의 결을 보는 방식
다섯 단의 반경 사다리를 한 카드가 오르내리며, 단이 바뀔 때 값이 매끈히 보간되어 같은 곡선의 결이 크기마다 흐릅니다. 고정 반경 목록(RUNGS 4·12·20·28·36)을 STEP_MS(380ms) 동안 3차 ease-in-out으로 보간합니다.
// 계산 겹: 사다리 칸 사이를 3차 ease-in-out 으로 보간한다 — 칸이 바뀌어도 값이 뚝 끊기지 않는다
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function goTo(nextIdx, ts) {
nextIdx = Math.max(0, Math.min(RUNGS.length - 1, nextIdx));
if (nextIdx === idx) return;
fromR = currentR(ts);
idx = nextIdx;
toR = RUNGS[idx];
stepStart = ts;
rungEl.textContent = String(idx + 1);
}
function currentR(ts) {
var t = STEP_MS > 0 ? Math.min(1, (ts - stepStart) / STEP_MS) : 1;
return fromR + (toR - fromR) * easeInOutCubic(t);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="card" id="card" type="button" aria-label="반경 사다리 한 칸 오르기 (방향키로 오르내리기)">
<span id="rung">1</span><span class="of">/5</span>
</button>
<p class="readout" id="readout" aria-live="polite">반경 4.0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 사다리 칸 사이를 3차 ease-in-out 으로 보간해 값이 뚝 끊기지 않는다.
@include stage($stage-ink);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.card {
appearance: none;
border: none;
width: 168px;
height: 108px;
background: $subject-pink;
corner-shape: squircle;
border-radius: $r-xs;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
color: $subject-ink;
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
box-shadow: $shadow-raised;
.of { font-size: 13px; font-weight: 600; opacity: .7; }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
@supports not (corner-shape: squircle) {
.card { border: 1px solid rgba($subject-cream, .3); }
}
.readout {
margin: 0;
color: $subject-cream;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card { width: 130px; height: 88px; font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.card {
appearance: none;
border: none;
width: 168px;
height: 108px;
background: #ff9ebb;
corner-shape: squircle;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: #17141a;
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card .of {
font-size: 13px;
font-weight: 600;
opacity: 0.7;
}
.card:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
@supports not (corner-shape: squircle) {
.card {
border: 1px solid rgba(255, 247, 230, 0.3);
}
}
.readout {
margin: 0;
color: #fff7e6;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.card {
width: 130px;
height: 88px;
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}(function () {
var RUNGS = [4, 12, 20, 28, 36]; // 사다리 칸마다의 반경 값(px) — 오름차순 고정 목록
var STEP_MS = 380; // 한 칸 사이를 보간하는 데 걸리는 시간
var AUTO_MS = 620; // 자동 루프에서 한 칸에 머무는 시간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'squircle');
var wrap = document.getElementById('wrap');
var card = document.getElementById('card');
var rungEl = document.getElementById('rung');
var readout = document.getElementById('readout');
var isDemo = true;
var idx = 0;
var fromR = RUNGS[0];
var toR = RUNGS[0];
var stepStart = 0;
var dir = 1;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 사다리 칸 사이를 3차 ease-in-out 으로 보간한다 — 칸이 바뀌어도 값이 뚝 끊기지 않는다
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function goTo(nextIdx, ts) {
nextIdx = Math.max(0, Math.min(RUNGS.length - 1, nextIdx));
if (nextIdx === idx) return;
fromR = currentR(ts);
idx = nextIdx;
toR = RUNGS[idx];
stepStart = ts;
rungEl.textContent = String(idx + 1);
}
function currentR(ts) {
var t = STEP_MS > 0 ? Math.min(1, (ts - stepStart) / STEP_MS) : 1;
return fromR + (toR - fromR) * easeInOutCubic(t);
}
function applyRadius(r) {
if (SUPPORTS) card.style.cornerShape = 'squircle';
card.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '반경 ' + r.toFixed(1);
}
card.addEventListener('click', function (e) {
human();
goTo(idx + 1 >= RUNGS.length ? 0 : idx + 1, performance.now());
});
wrap.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
human();
goTo(idx + (e.key === 'ArrowUp' ? 1 : -1), performance.now());
});
var start = 0;
function loop(ts) {
if (!start) { start = ts; stepStart = ts; }
if (isDemo) {
if (reduce) {
idx = RUNGS.length - 1; toR = RUNGS[idx]; fromR = toR;
rungEl.textContent = String(idx + 1);
} else if ((ts - stepStart) >= AUTO_MS) {
var next = idx + dir;
if (next >= RUNGS.length) { dir = -1; next = idx + dir; }
if (next < 0) { dir = 1; next = idx + dir; }
goTo(next, ts);
}
}
applyRadius(currentR(ts));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// RadiusLadder — vanilla 판(run/372/demo/07-corner-shape-radius-ladder/index.html) 의 React 포트.
// 사다리 칸 사이를 3차 ease-in-out 으로 보간한다 — 칸이 바뀌어도 값이 뚝 끊기지 않는다.
import { useEffect, useRef } from "react";
import styles from "./RadiusLadder.module.scss";
type Box<T> = { current: T };
const RUNGS = [4, 12, 20, 28, 36]; // 사다리 칸마다의 반경 값(px) — 오름차순 고정 목록
const STEP_MS = 380; // 한 칸 사이를 보간하는 데 걸리는 시간
const AUTO_MS = 620; // 자동 루프에서 한 칸에 머무는 시간
export default function RadiusLadder() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rungRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "squircle");
const root = rootRef.current;
const card = cardRef.current;
const rungEl = rungRef.current;
const readout = readoutRef.current;
if (!root || !card || !rungEl || !readout) return;
let isDemo = true;
let idx = 0;
let fromR = RUNGS[0];
let toR = RUNGS[0];
let stepStart = 0;
let dir = 1;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
// 계산 겹: 사다리 칸 사이를 3차 ease-in-out 으로 보간한다 — 칸이 바뀌어도 값이 뚝 끊기지 않는다
function easeInOutCubic(t: number) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function currentR(ts: number) {
const t = STEP_MS > 0 ? Math.min(1, (ts - stepStart) / STEP_MS) : 1;
return fromR + (toR - fromR) * easeInOutCubic(t);
}
function goTo(nextIdx: number, ts: number) {
nextIdx = Math.max(0, Math.min(RUNGS.length - 1, nextIdx));
if (nextIdx === idx) return;
fromR = currentR(ts);
idx = nextIdx;
toR = RUNGS[idx];
stepStart = ts;
rungEl!.textContent = String(idx + 1);
}
function applyRadius(r: number) {
if (SUPPORTS) (card!.style as any).cornerShape = "squircle";
card!.style.borderRadius = r.toFixed(2) + "px";
readout!.textContent = "반경 " + r.toFixed(1);
}
function onClick() { human(); goTo(idx + 1 >= RUNGS.length ? 0 : idx + 1, performance.now()); }
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
e.preventDefault();
human();
goTo(idx + (e.key === "ArrowUp" ? 1 : -1), performance.now());
}
card.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
let start = 0;
function loop(ts: number) {
if (!start) { start = ts; stepStart = ts; }
if (isDemo) {
if (reduce) {
idx = RUNGS.length - 1; toR = RUNGS[idx]; fromR = toR;
rungEl!.textContent = String(idx + 1);
} else if ((ts - stepStart) >= AUTO_MS) {
let next = idx + dir;
if (next >= RUNGS.length) { dir = -1; next = idx + dir; }
if (next < 0) { dir = 1; next = idx + dir; }
goTo(next, ts);
}
}
applyRadius(currentR(ts));
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
card.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.card}
ref={cardRef}
type="button"
aria-label="반경 사다리 한 칸 오르기 (방향키로 오르내리기)"
>
<span ref={rungRef}>1</span><span className={styles.of}>/5</span>
</button>
<p className={styles.readout} ref={readoutRef} aria-live="polite">반경 4.0</p>
</div>
</main>
);
}07 · 반경 사다리 — 한 카드의 반경 값을 고정된 다섯 단 사다리에서 오르내리며 같은 곡선의 결을 보는 방식 — 측정 누적면적 0.797% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08폴백 나란히 보기 — 같은 반경 값을 받은 지원 칸과 폴백 칸의 윤곽을 나란히 비교하는 방식
같은 반경 값을 받은 지원 칸과 폴백 칸을 나란히 두고, 어느 모서리까지 똑같고 어디서부터 갈라지는지 보여줍니다. 반경은 (1-cos(t×2π))/2 코사인 왕복 값으로 R_MIN(6)과 R_MAX(34) 사이를 오가고, 왼쪽 칸만 corner-shape: superellipse(SUPER_N 2.6)을 함께 씁니다.
// 계산 겹: 반경을 코사인으로 왕복시킨다 — 사인이 아니라 위상을 맞춘 코사인으로 0초에 하한에서 시작한다
function radiusAt(t) {
var c = (1 - Math.cos(t * Math.PI * 2)) / 2;
return R_MIN + (R_MAX - R_MIN) * c;
}
function applyBoth(r) {
if (SUPPORTS) {
nativeBox.style.cornerShape = 'superellipse(' + SUPER_N.toFixed(1) + ')';
nativeBox.style.borderRadius = r.toFixed(2) + 'px';
} else {
nativeBox.style.borderRadius = r.toFixed(2) + 'px';
}
// 오른쪽 칸은 언제나 border-radius 근사만 쓴다 — 두 렌더링의 차이를 나란히 겨룬다
shimBox.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '지금 반경 ' + r.toFixed(1);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="row">
<figure class="cell">
<div class="box native" id="nativeBox"></div>
<figcaption class="cap" id="capNative">지원 환경</figcaption>
</figure>
<figure class="cell">
<div class="box shim" id="shimBox"></div>
<figcaption class="cap">미지원 폴백</figcaption>
</figure>
</div>
<p class="readout" id="readout" aria-live="polite">지금 반경 20.0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 코사인 왕복으로 두 박스의 반경을 같은 값으로 움직여 차이를 겨룬다.
@include stage($stage-paper);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.row {
display: flex;
gap: $sp-6;
}
.cell {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.box {
width: 120px;
height: 92px;
background: $subject-lilac;
}
.native {
background: $subject-blue-deep;
}
.cap {
font-size: 12px;
font-weight: 700;
color: $subject-ink;
}
.readout {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.box { width: 96px; height: 76px; }
.row { gap: $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.box { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.row {
display: flex;
gap: 24px;
}
.cell {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.box {
width: 120px;
height: 92px;
background: #b9a0ff;
}
.native {
background: #0a4fe8;
}
.cap {
font-size: 12px;
font-weight: 700;
color: #17141a;
}
.readout {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.box {
width: 96px;
height: 76px;
}
.row {
gap: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.box {
transition: none;
}
}(function () {
var R_MIN = 6; // 반경 하한(px)
var R_MAX = 34; // 반경 상한(px)
var SWEEP_MS = 2000; // 반경이 하한↔상한을 왕복하는 주기
var SUPER_N = 2.6; // 슈퍼타원 지수 고정값 — 원과 사각형 사이
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'superellipse(1)');
var wrap = document.getElementById('wrap');
var nativeBox = document.getElementById('nativeBox');
var shimBox = document.getElementById('shimBox');
var capNative = document.getElementById('capNative');
var readout = document.getElementById('readout');
var isDemo = true;
var paused = false;
var frozenT = 0;
// 캡션을 실제 지원 여부로 갱신 — 미지원 환경에서 열면 두 칸 다 폴백으로 표시된다
if (!SUPPORTS) capNative.textContent = '미지원 폴백';
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 반경을 코사인으로 왕복시킨다 — 사인이 아니라 위상을 맞춘 코사인으로 0초에 하한에서 시작한다
function radiusAt(t) {
var c = (1 - Math.cos(t * Math.PI * 2)) / 2;
return R_MIN + (R_MAX - R_MIN) * c;
}
function applyBoth(r) {
if (SUPPORTS) {
nativeBox.style.cornerShape = 'superellipse(' + SUPER_N.toFixed(1) + ')';
nativeBox.style.borderRadius = r.toFixed(2) + 'px';
} else {
nativeBox.style.borderRadius = r.toFixed(2) + 'px';
}
// 오른쪽 칸은 언제나 border-radius 근사만 쓴다 — 두 렌더링의 차이를 나란히 겨룬다
shimBox.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '지금 반경 ' + r.toFixed(1);
}
wrap.addEventListener('click', function () {
human();
paused = !paused;
if (paused) frozenT = performance.now();
});
wrap.addEventListener('keydown', function (e) {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); wrap.click(); }
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
var t;
if (reduce) {
t = 0.5; // 상태(상한)에 고정 — 코사인 계산은 그대로 쓴다
} else if (paused) {
t = ((frozenT - start) % SWEEP_MS) / SWEEP_MS;
} else {
t = ((ts - start) % SWEEP_MS) / SWEEP_MS;
}
applyBoth(radiusAt(t));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// FallbackPolyfill — vanilla 판(run/372/demo/08-corner-shape-fallback-polyfill/index.html) 의 React 포트.
// 반경을 코사인으로 왕복시킨다 — 사인이 아니라 위상을 맞춘 코사인으로 0초에 하한에서 시작한다.
import { useEffect, useRef } from "react";
import styles from "./FallbackPolyfill.module.scss";
type Box<T> = { current: T };
const R_MIN = 6; // 반경 하한(px)
const R_MAX = 34; // 반경 상한(px)
const SWEEP_MS = 2000; // 반경이 하한↔상한을 왕복하는 주기
const SUPER_N = 2.6; // 슈퍼타원 지수 고정값 — 원과 사각형 사이
export default function FallbackPolyfill() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const nativeRef = useRef(null) as unknown as Box<HTMLDivElement>;
const shimRef = useRef(null) as unknown as Box<HTMLDivElement>;
const capRef = useRef(null) as unknown as Box<HTMLElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "superellipse(1)");
const root = rootRef.current;
const nativeBox = nativeRef.current;
const shimBox = shimRef.current;
const capNative = capRef.current;
const readout = readoutRef.current;
if (!root || !nativeBox || !shimBox || !capNative || !readout) return;
let isDemo = true;
let paused = false;
let frozenT = 0;
// 캡션을 실제 지원 여부로 갱신 — 미지원 환경에서 열면 두 칸 다 폴백으로 표시된다
if (!SUPPORTS) capNative.textContent = "미지원 폴백";
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
// 계산 겹: 반경을 코사인으로 왕복시킨다 — 사인이 아니라 위상을 맞춘 코사인으로 0초에 하한에서 시작한다
function radiusAt(t: number) {
const c = (1 - Math.cos(t * Math.PI * 2)) / 2;
return R_MIN + (R_MAX - R_MIN) * c;
}
function applyBoth(r: number) {
if (SUPPORTS) {
(nativeBox!.style as any).cornerShape = "superellipse(" + SUPER_N.toFixed(1) + ")";
}
nativeBox!.style.borderRadius = r.toFixed(2) + "px";
shimBox!.style.borderRadius = r.toFixed(2) + "px";
readout!.textContent = "지금 반경 " + r.toFixed(1);
}
function onClick() {
human();
paused = !paused;
if (paused) frozenT = performance.now();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); onClick(); }
}
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeyDown);
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
let t: number;
if (reduce) {
t = 0.5;
} else if (paused) {
t = ((frozenT - start) % SWEEP_MS) / SWEEP_MS;
} else {
t = ((ts - start) % SWEEP_MS) / SWEEP_MS;
}
applyBoth(radiusAt(t));
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef} tabIndex={0}>
<div className={styles.row}>
<figure className={styles.cell}>
<div className={styles.box + " " + styles.native} ref={nativeRef} />
<figcaption className={styles.cap} ref={capRef}>지원 환경</figcaption>
</figure>
<figure className={styles.cell}>
<div className={styles.box + " " + styles.shim} ref={shimRef} />
<figcaption className={styles.cap}>미지원 폴백</figcaption>
</figure>
</div>
<p className={styles.readout} ref={readoutRef} aria-live="polite">지금 반경 20.0</p>
</div>
</main>
);
}08 · 폴백 나란히 보기 — 같은 반경 값을 받은 지원 칸과 폴백 칸의 윤곽을 나란히 비교하는 방식 — 측정 누적면적 1.415% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09안쪽 테두리 — 겉곡선과 속곡선 값이 따로 놓여 이중 테두리가 생기는 방식
겉요소와 속요소가 서로 다른 곡선 값을 쓰면 두 윤곽 사이 간격이 곡선을 따라 달라져, 흐르는 이중 테두리가 만들어집니다. 속곡선 반경은 OUTER_R(40)에서 인셋을 뺀 산술로 구하고, 복귀는 INSET_SPRING_K(8) 지수감쇠로 INSET_MAX(22)까지 오갑니다.
// 계산 겹: 인셋 산술 — 안쪽 반경 = 바깥 반경 − 인셋. 인셋이 커질수록 안쪽 곡선이 더 깎인다
function innerRadius(inset) { return Math.max(2, OUTER_R - inset); }
function applyInset(inset) {
var r = innerRadius(inset);
if (SUPPORTS) {
frame.style.cornerShape = 'superellipse(3)';
inner.style.cornerShape = 'superellipse(3)';
}
frame.style.borderRadius = OUTER_R + 'px';
inner.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '인셋 ' + inset.toFixed(1);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="frame" id="frame" type="button" aria-pressed="false" aria-label="안쪽 곡선 밀기/당기기">
<span class="inner" id="inner">코어</span>
</button>
<p class="readout" id="readout" aria-live="polite">인셋 10.0</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — 인셋 산술(안쪽 반경 = 바깥 반경 − 인셋)과 관성 복귀.
@include stage($stage-yellow);
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.frame {
appearance: none;
border: none;
width: 200px;
height: 132px;
padding: $sp-4;
background: $subject-blue-deep;
corner-shape: superellipse(3);
border-radius: $r-card;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-raised;
&:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
}
.inner {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: $subject-mint;
corner-shape: superellipse(3);
border-radius: $r-pill;
color: $subject-ink;
font-size: 15px;
font-weight: 800;
pointer-events: none;
}
@supports not (corner-shape: superellipse(1)) {
.frame { border: 1px solid rgba($subject-ink, .2); }
.inner { border: 1px solid rgba($subject-ink, .2); }
}
.readout {
margin: 0;
color: $subject-ink;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.frame { width: 156px; height: 106px; }
.inner { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.frame { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.frame {
appearance: none;
border: none;
width: 200px;
height: 132px;
padding: 16px;
background: #0a4fe8;
corner-shape: superellipse(3);
border-radius: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.frame:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.inner {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #4cd4a6;
corner-shape: superellipse(3);
border-radius: 999px;
color: #17141a;
font-size: 15px;
font-weight: 800;
pointer-events: none;
}
@supports not (corner-shape: superellipse(1)) {
.frame {
border: 1px solid rgba(23, 20, 26, 0.2);
}
.inner {
border: 1px solid rgba(23, 20, 26, 0.2);
}
}
.readout {
margin: 0;
color: #17141a;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.frame {
width: 156px;
height: 106px;
}
.inner {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.frame {
transition: none;
}
}(function () {
var OUTER_R = 40; // 바깥 프레임의 고정 반경(px)
var INSET_MIN = 6; // 안쪽 곡선이 프레임에서 떨어지는 최소 거리(px)
var INSET_MAX = 22; // 최대 거리(px) — 커질수록 안쪽 곡선이 더 안으로 밀려든다
var SWEEP_MS = 2400; // 인셋이 한 번 왕복하는 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SUPPORTS = CSS.supports('corner-shape', 'superellipse(1)');
var wrap = document.getElementById('wrap');
var frame = document.getElementById('frame');
var inner = document.getElementById('inner');
var readout = document.getElementById('readout');
var isDemo = true;
var held = false;
var heldStart = 0;
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 인셋 산술 — 안쪽 반경 = 바깥 반경 − 인셋. 인셋이 커질수록 안쪽 곡선이 더 깎인다
function innerRadius(inset) { return Math.max(2, OUTER_R - inset); }
function applyInset(inset) {
var r = innerRadius(inset);
if (SUPPORTS) {
frame.style.cornerShape = 'superellipse(3)';
inner.style.cornerShape = 'superellipse(3)';
}
frame.style.borderRadius = OUTER_R + 'px';
inner.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '인셋 ' + inset.toFixed(1);
}
frame.addEventListener('pointerdown', function () { human(); held = true; heldStart = performance.now(); frame.setAttribute('aria-pressed', 'true'); });
frame.addEventListener('pointerup', function () { human(); held = false; heldStart = performance.now(); frame.setAttribute('aria-pressed', 'false'); });
frame.addEventListener('pointerleave', function () { if (held) { held = false; heldStart = performance.now(); frame.setAttribute('aria-pressed', 'false'); } });
frame.addEventListener('keydown', function (e) { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); frame.dispatchEvent(new PointerEvent('pointerdown')); } });
frame.addEventListener('keyup', function (e) { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); frame.dispatchEvent(new PointerEvent('pointerup')); } });
// 계산 겹: 뗄 때 인셋이 목표로 관성(지수 감쇠)으로 되돌아간다
var INSET_SPRING_K = 8;
var insetNow = INSET_MIN;
function stepInset(ts) {
var target = held ? INSET_MAX : INSET_MIN;
var dt = (ts - (stepInset.last || ts)) / 1000;
stepInset.last = ts;
var follow = 1 - Math.exp(-INSET_SPRING_K * dt);
insetNow += (target - insetNow) * follow;
if (Math.abs(insetNow - target) < 0.01) insetNow = target;
}
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
insetNow = INSET_MAX;
} else {
var t = ((ts - start) % SWEEP_MS) / SWEEP_MS;
var tri = t < 0.5 ? t * 2 : (1 - t) * 2;
insetNow = INSET_MIN + (INSET_MAX - INSET_MIN) * tri;
}
} else if (!reduce) {
stepInset(ts);
}
applyInset(insetNow);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// FrameInset — vanilla 판(run/372/demo/09-corner-shape-frame-inset/index.html) 의 React 포트.
// 인셋 산술(안쪽 반경 = 바깥 반경 − 인셋)과, 뗄 때 목표로 지수 감쇠 관성 복귀.
import { useEffect, useRef } from "react";
import styles from "./FrameInset.module.scss";
type Box<T> = { current: T };
const OUTER_R = 40; // 바깥 프레임의 고정 반경(px)
const INSET_MIN = 6; // 안쪽 곡선이 프레임에서 떨어지는 최소 거리(px)
const INSET_MAX = 22; // 최대 거리(px) — 커질수록 안쪽 곡선이 더 안으로 밀려든다
const SWEEP_MS = 2400; // 인셋이 한 번 왕복하는 주기
const INSET_SPRING_K = 8; // 뗄 때 목표로 되돌아가는 관성 속도 상수
export default function FrameInset() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const frameRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const innerRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SUPPORTS = CSS.supports("corner-shape", "superellipse(1)");
const root = rootRef.current;
const frame = frameRef.current;
const inner = innerRef.current;
const readout = readoutRef.current;
if (!root || !frame || !inner || !readout) return;
let isDemo = true;
let held = false;
let insetNow = INSET_MIN;
let lastStepTs = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
// 계산 겹: 인셋 산술 — 안쪽 반경 = 바깥 반경 − 인셋. 인셋이 커질수록 안쪽 곡선이 더 깎인다
function innerRadius(inset: number) { return Math.max(2, OUTER_R - inset); }
function applyInset(inset: number) {
const r = innerRadius(inset);
if (SUPPORTS) {
(frame!.style as any).cornerShape = "superellipse(3)";
(inner!.style as any).cornerShape = "superellipse(3)";
}
frame!.style.borderRadius = OUTER_R + "px";
inner!.style.borderRadius = r.toFixed(2) + "px";
readout!.textContent = "인셋 " + inset.toFixed(1);
}
function onPointerDown() { human(); held = true; frame!.setAttribute("aria-pressed", "true"); }
function onPointerUp() { human(); held = false; frame!.setAttribute("aria-pressed", "false"); }
function onPointerLeave() { if (held) { held = false; frame!.setAttribute("aria-pressed", "false"); } }
function onKeyDown(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") { e.preventDefault(); onPointerDown(); } }
function onKeyUp(e: KeyboardEvent) { if (e.key === " " || e.key === "Enter") { e.preventDefault(); onPointerUp(); } }
frame.addEventListener("pointerdown", onPointerDown);
frame.addEventListener("pointerup", onPointerUp);
frame.addEventListener("pointerleave", onPointerLeave);
frame.addEventListener("keydown", onKeyDown);
frame.addEventListener("keyup", onKeyUp);
// 계산 겹: 뗄 때 인셋이 목표로 관성(지수 감쇠)으로 되돌아간다
function stepInset(ts: number) {
const target = held ? INSET_MAX : INSET_MIN;
const dt = (ts - (lastStepTs || ts)) / 1000;
lastStepTs = ts;
const follow = 1 - Math.exp(-INSET_SPRING_K * dt);
insetNow += (target - insetNow) * follow;
if (Math.abs(insetNow - target) < 0.01) insetNow = target;
}
let start = 0;
function loop(ts: number) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
insetNow = INSET_MAX;
} else {
const t = ((ts - start) % SWEEP_MS) / SWEEP_MS;
const tri = t < 0.5 ? t * 2 : (1 - t) * 2;
insetNow = INSET_MIN + (INSET_MAX - INSET_MIN) * tri;
}
} else if (!reduce) {
stepInset(ts);
}
applyInset(insetNow);
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
frame.removeEventListener("pointerdown", onPointerDown);
frame.removeEventListener("pointerup", onPointerUp);
frame.removeEventListener("pointerleave", onPointerLeave);
frame.removeEventListener("keydown", onKeyDown);
frame.removeEventListener("keyup", onKeyUp);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<button
className={styles.frame}
ref={frameRef}
type="button"
aria-pressed="false"
aria-label="안쪽 곡선 밀기/당기기"
>
<span className={styles.inner} ref={innerRef}>코어</span>
</button>
<p className={styles.readout} ref={readoutRef} aria-live="polite">인셋 10.0</p>
</div>
</main>
);
}09 · 안쪽 테두리 — 겉곡선과 속곡선 값이 따로 놓여 이중 테두리가 생기는 방식 — 측정 누적면적 0.428% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 능선과 빗면을 바꾸는 곳 — 지수 값·각도 값·비율 값 기준
지수·각도·비율 어느 값이 모서리 곡선을 잡아당기는지 기준으로 아홉 항목을 다시 겹쳐 보면 세 무리가 나옵니다. 지수 값이 기준인 쪽은 01 하나로, corner-shape 명세의 superellipse 지수를 MIN_N에서 SUPER_N까지 흘려보내는 값이 능선의 결을 직접 정합니다. 각도 값이 기준인 쪽도 02 하나로, 베벨 각도의 탄젠트가 빗면 넓이를 정합니다. 비율 값이 기준인 쪽은 여섯이나 됩니다 — 03(스쿠프 반경 비율)·04(노치 깊이 오버슛 비율)·06(모핑 보간 비율)·07(사다리 단 사이 보간 비율)·08(코사인 왕복 비율)·09(인셋 스프링 비율)처럼, 0에서 1 사이 진행값 하나가 그 프레임의 반경이나 깊이를 정합니다.
05는 이 세 갈래에 들지 않고 위상 값 하나를 씁니다 — 네 모서리 각각에 걸린 위상차가 사인 함수 안에서 반경을 정해, 지수·각도·비율 어느 쪽으로도 묶이지 않습니다.
이 편의 아홉 항목은 corner-shape 하나로 모서리 곡선의 지수·각도·비율 값을 계산하는 것이 축이라, 호버 시 그림자와 이동 값을 계산하는 CSS 카드 애니메이션 호버 효과 9종 — 한 줄 복붙으로와는 다루는 값 자체가 다릅니다. 뱃지 디자인 UI 9종 — 신상·세일·인증 배지 코드는 색과 형태로 상태를 구분하는 값을 다루는 반면, 이 편은 모서리 곡선 하나의 계산만 다룹니다. 사진 없이 윤곽만으로 사용자를 표시하는 값 계산은 아바타 UI 9종 — 사진 파일 없이 만드는 사용자 표시에서 다루고, 이 편의 09(안쪽 테두리)는 그 원형 윤곽과 달리 두 겹의 corner-shape 곡선 값 차이로 이중 테두리를 만듭니다.
어디서 깨지는가 — 미지원 브라우저, 폴백, reduced-motion
아홉 항목 모두 각 환경에서 CSS.supports('corner-shape', 'superellipse(1)') 호출로 지원 여부를 직접 재서, 참이면 corner-shape 속성(superellipse·bevel·scoop·notch·squircle·round)을 쓰고 거짓이면 border-radius 근사 폴백으로 같은 자리를 채웁니다. 08은 이 감지를 그대로 시연해 지원 칸과 폴백 칸을 나란히 두고, 어느 모서리까지 똑같고 어디서부터 갈라지는지 보여줍니다. 근사 폴백은 정확한 곡선이 아니라 반경 하나로 흉내 낸 것이라, 슈퍼타원처럼 지수가 큰 모양일수록 지원 환경과 폴백 환경의 모서리가 눈에 띄게 달라집니다.
reduced-motion이 켜지면 아홉 항목이 저마다 다른 값 하나로 멈춥니다 — 01은 지수를 SUPER_N으로 즉시 고정하고, 02는 각도를 BEVEL_DEG로 고정하며, 03은 스쿠프 반경이 즉시 결정되고, 04는 노치 깊이가 스냅되며, 05는 위상을 고정값으로 멈추고, 06은 target=1로 비율이 즉시 정해지며, 07은 사다리 최상단 칸에 고정되고, 08은 t=0.5 지점에 고정되며, 09는 INSET_MAX로 고정됩니다. 전 항목이 같은 방식으로 멈춘다고 단정할 수 없는 이유가 여기 있습니다 — 계산 한 겹마다 멈추는 값의 자리가 다릅니다.
값을 직접 눌러보며 상수를 하나씩 바꿔보고 싶다면, x4zdvxz7 압축 비밀번호로 아홉 항목의 html·scss·js 파일 전체를 받아 코드를 그대로 열어볼 수 있습니다.
저사양 기기에서 아홉 데모를 한 화면에 전부 재생하면 requestAnimationFrame 루프 아홉 개가 동시에 돌아 프레임이 밀릴 수 있으므로, 실제로 붙일 때는 한 화면에 필요한 항목 하나만 남기는 편이 낫습니다. css corner shape generator 같은 도구 없이도, 아홉 상수 값을 zip 안에서 직접 바꿔보면 같은 모서리 곡선을 손으로 만들 수 있습니다. 이 중 css corner shape bevel 방식에 해당하는 것은 02 베벨 칩 하나로, 각도 값의 탄젠트가 빗면 넓이를 직접 정합니다.
변형 세 가지와 접근성
| 변형 | 값 | 느낌 |
|---|---|---|
| 스쿼클 지수 올리기 | 01 SUPER_N 4.0 → 8.0 | 슈퍼타원 지수 상한을 4.0에서 8.0으로 올리면 흐름이 닿는 능선이 한층 사각형 쪽으로 깊어진다. 소스는 지수를 MIN_N(1)에서 SUPER_N까지 매끄러운 보간으로 흘려보내므로, 상한 하나만 바꿔도 전체 흐름의 결이 달라진다. |
| 베벨 각도 키우기 | 02 BEVEL_DEG 18 → 36 | 베벨 각도 상한을 18도에서 36도로 올리면 빗면 넓이가 탄젠트를 따라 선형보다 크게 벌어져 절단면이 도드라진다. 소스는 가로 깊이를 BEVEL_RY × tan(각도)로 계산하고 빗면 넓이를 hypot로 재므로, 각도를 두 배로 하면 빗면은 두 배보다 넓어진다. |
| 지수 순환 빠르게 하기 | 01 SWEEP_MS 2400 → 1200 | 지수 순환 주기를 2400ms에서 1200ms로 줄이면 원과 사각형 사이를 오가는 흐름이 정확히 두 배 빨라져 변화가 또렷해진다. 소스는 경과 시간을 SWEEP_MS로 나눈 삼각파로 지수를 흘려보내므로, 주기를 절반으로 줄이는 것이 곧 두 배 속도다. |
아홉 항목 모두 상태 문구를 aria-live="polite" readout에 담아 스크린 리더가 지금 값을 읽게 하고, 01·02·03·05·06·09는 버튼에 aria-pressed를 상태에 맞춰 true·false로 바꿉니다. 04는 role="tab"·aria-selected로 탭 세 개의 열림 상태를 알리고, 07은 aria-label에 방향키 조작법을 함께 적어 둡니다. 키보드는 04·05·07·08이 Enter·Space·방향키로, 03·09는 스페이스를 누르고 있는 동안 값이 바뀌도록 keydown·keyup으로 직접 조작되며, reduced-motion에서는 앞서 본 것처럼 항목마다 다른 값 하나로 즉시 고정되어 애니메이션 없이도 최종 모양은 그대로 보입니다.
FAQ
css squircle 카드에 border-radius만 써도 되나요?
border-radius만 쓰면 네 모서리가 전부 같은 곡률로 둥글게 깎여, 슈퍼타원 지수가 흘러가며 능선의 결이 바뀌는 01의 움직임은 재현할 수 없습니다. corner-shape가 지원되는 환경에서는 superellipse(지수) 값을 직접 넣어야 사각형에 가까운 결과까지 갈 수 있고, 지원되지 않는 환경에서는 반경만으로 흉내 낸 폴백이 됩니다.
이 아홉 패턴을 실제 서비스 버튼·카드에 그대로 붙여도 되나요?
네, 아홉 항목 모두 외부 라이브러리 없이 css·js 값 계산만으로 동작해 zip 안 코드를 그대로 붙이면 바로 움직입니다. 다만 corner-shape 미지원 환경에서는 border-radius 근사 폴백만 보이므로, 지수가 큰 슈퍼타원처럼 폴백과 차이가 큰 모양은 두 환경을 나란히 한 번 확인해 보는 편이 안전합니다.
아홉 항목 중 계산이 가장 무거운 건 어떤 항목인가요?
06은 매 프레임 지수감쇠 lerp를 반경과 곡선 종류 모두에 적용하고 누적면적도 3.25%로 가장 넓어, 화면에서 픽셀이 바뀌는 범위가 아홉 중 가장 큽니다. 02는 반대로 빗면 하나만 좁게 움직여 누적면적이 0.33%로 가장 좁고 강도평균도 73.2로 가장 낮아, 계산량은 비슷해도 화면에 미치는 영향은 훨씬 작습니다.









