css corner-shape 9選 — border-radiusで切らない角の処理
9つのコーナー形状のうち、1つはスーパー楕円指数の値が円と四角の間を流れ、1つはsquircle比率の値でカードの角が補間され、1つはベベル角度の値が増すほど面の幅が広がります。この9つは、css corner-shape の対応を起動時に確かめながら角の曲線そのものを動かす、コピーしてすぐ試せる実演です。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スクワークルカード — スーパー楕円指数の値が円と四角の間を流れ、カードの角の稜線が変わる方式
- 02 ベベルチップ — ベベル角度の値が増すほどチップの角の面の幅の値が共に大きくなる方式
- 03 スクープボタン — 角が内側へえぐれるscoop曲線の値が動き、ボタンの輪郭が変わる方式
- 04 ノッチタブ — 選択されたタブの角にノッチ深さの値の分だけ溝が掘られる方式
- 05 ウェーブパネル — 4つの角の曲線の値が位相差を持って順に揺れる方式
- 06 ホバーモーフィング — 待機状態とホバー状態の角の曲線の値が補間されて移る方式
- 07 半径のはしご — 1枚のカードの半径値を固定の5段はしごで上り下りし、同じ曲線の質感を見る方式
- 08 フォールバック並び比較 — 同じ半径値を与えた対応ペインとフォールバックペインの輪郭を並べて比べる方式
- 09 内側フレーム — 外側曲線と内側曲線の値が別々に置かれ、二重の縁が生まれる方式
css corner-shape は、border-radius と組み合わせて角の曲線そのものを指定する実験的なプロパティで、下の9項目はすべて起動時に CSS.supports で対応を確かめてから値を書き込み、対応していない環境では border-radius の近似に切り替えます。グリッドを見ていると、カードごとに1つの数値 — 指数・角度・比率・インセット — が動き、それに合わせて角の曲線が変わるのがわかります。ランダムな明滅はどこにもありません。
9つのパターンの解剖 — どの値が角の曲線を変えるか
次の表には、項目ごとに3つの実測値が入っています。動く角の処理がフレームのうちどれだけの面積を累積で塗り替えたか、1ピクセルあたりの平均強度がどれだけだったか、そして23フレーム中いくつのフレームで実際にピクセルが動いたか、この3つです。
| 項目 | 累積面積 | 強度平均 | 動いたフレーム |
|---|---|---|---|
| 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だけで、最も狭いのは02だけです。強度が最も高いのは01の109.8で、05の109.7とは0.1しか離れておらず、順位は実測値の数字だけが区別します。強度が最も低いのも02、動いたフレームが最も少ないのは04の23フレーム中8で、ノッチはタブの切り替えが実際に進んでいるあいだだけ動き、切り替えと切り替えのあいだは完全に静止しているためです。表の数値はどれもこの9項目をこのまま録画した実測値であり、再生環境が変われば値も変わります。
値を動かす計算方式で9項目を分けると、ちょうど4つのグループに分かれます。それぞれの計算層は独立したファイルにあります。
01・02 — 経過時間の比率を関数に通してから形状にするもの
01は SWEEP_MS で割った経過時間を三角波にし、それを5次のスムーザーステップで曲げてから MIN_N から SUPER_N までの指数に映射します。02は角度の値を Math.tan に通して、固定の BEVEL_RY から横軸半径を逆算し、Math.hypot で斜面幅を測ります。どちらも、CSSトランジションに中間値を任せるのではなく、素直な経過時間の比率を明示的な関数で角の形状に変えているグループです。
03・04・09 — 目標を通り過ぎたり、イージングで目標に近づく値
03は押している時間の比率を3次ease-out(上昇時)と3次ease-in(下降時)で曲げてスクープ半径に映射します。04のノッチ深さは springOvershoot 関数に従い、目標を一瞬通り過ぎてから戻って落ち着きます。09は離したあと、固定時間のトゥイーンではなく 1 - Math.exp(-INSET_SPRING_K * dt) の指数減衰でゆっくり目標へ戻ります。
05・07・08 — 毎フレーム計算される波とサイクル
05は角ごとに位相を Math.PI / 2 ずつずらしたサイン値4つを同時に計算するため、4つの半径が同じタイミングで膨らむことはありません。07は固定の5段リスト RUNGS を、段のあいだを3次ease-in-outで補間しながら上り下りします。08は半径を (1 - Math.cos(t * Math.PI * 2)) / 2 で往復させます — 位相を合わせたコサインなので、値は中間値ではなく下限から始まります。
06 — 慣性lerpで追いかける補間比率
06は比率を目標へ瞬間移動させません。毎フレーム、残りの距離のうち 1 - Math.exp(-LERP_K * dt) の割合だけを詰めていくため、同じホバー操作が60fpsでも144fpsでも同じ速さで読める性質です。
パターン別コード
01スクワークルカード — スーパー楕円指数の値が円と四角の間を流れ、カードの角の稜線が変わる方式
指数の値は5次スムーザーステップで曲げた三角波に乗って MIN_N(1)から SUPER_N(4.0)までを SWEEP_MS ごとに往復し、それぞれの値が固定40px半径の cornerShape = superellipse(n) として書き込まれるため、同じ1枚のカードが円に近い形から真のスクワークルを経て四角へと流れます。アプリアイコンのプレビューカードやギャラリーのサムネイルカードに合う方式です。クリックすると指数が3つのプリセット値のあいだを自分で止まって回ります。
数値が語ること
項目01は23フレーム中23枚で動きながら累積面積1.36%を占め、強度平均は9項目中で最も高い109.8に達しますが、これはカードの縁全体がステップごとに再び丸まるためで、僅差の項目05(109.7)より0.1ポイントだけ高い値です。
5次スムーザーステップが三角波の折り返しを丸めるため、指数がSUPER_N(4.0)に達する瞬間も動きが不連続に感じられません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: スムーザーステップ(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);
}
02ベベルチップ — ベベル角度の値が増すほどチップの角の面の幅の値が共に大きくなる方式
角度の値が MIN_DEG(8)から BEVEL_DEG(18)へ上がるあいだ、横軸半径は BEVEL_RY × Math.tan(rad) で計算され、斜面幅は Math.hypot(rx, BEVEL_RY) で測られるため、切断面は直線ではなくタンジェントに沿って細い線から本物の面へ育ちます。値札・ラベルチップやステータスバッジに合う方式です。
数値が語ること
項目02は23フレーム中23枚で動きますが累積面積は0.326%と9項目中で最小で、強度平均も73.2と最も低く、ベベル面の変化が視覚的に小さいことを示しています。
斜面幅はBEVEL_RYとMath.tan(rad)から導かれるため、角度が上がるほど面の幅も加速的に広がります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: ベベル角度の値から横軸半径を三角比で逆算し、2つの半径の斜辺の長さを斜面幅とする
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);
}
03スクープボタン — 角が内側へえぐれるscoop曲線の値が動き、ボタンの輪郭が変わる方式
スクープ半径は押しているあいだ SCOOP_MIN(4)から SCOOP_MAX(28)へ3次ease-outで伸び、離すと3次ease-inで戻るため、凹んだ切り取りは最初に速く深くなってからゆっくり緩み、ずっと一定の速さで動くわけではありません。次のステージへ誘うボタンやタグ削除ボタンに合う方式です。キーボードでも押し続けが再現できます。
数値が語ること
項目03は23フレーム中21枚で動き、累積面積1.99%、強度平均90.0で、押している間だけ伸びるscoop半径の性質上、動くフレーム数が9項目の中間に位置します。
3次ease-outとease-inを押す/離すで使い分けているため、伸びと戻りの速さの印象がわずかに異なります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 押している時間の比率を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);
}
04ノッチタブ — 選択されたタブの角にノッチ深さの値の分だけ溝が掘られる方式
タブが切り替わるたび、ノッチ深さは springOvershoot に従うため、溝は一瞬 NOTCH_MAX(14)を通り過ぎてから沈んで落ち着き、開いているタブは輪郭だけで読み取れます。ドキュメントエディタのタブバーやブラウザタブUIに合う方式です。
数値が語ること
項目04は23フレーム中わずか8枚だけが動き、これは9項目の中で最少の値ですが、累積面積は3.212%とむしろ大きく、タブが切り替わる瞬間にノッチ深さが大きく動いてすぐ落ち着くスプリングオーバーシュートの性質を表しています。
springOvershootの定数c4が振動の周期を決めるため、ノッチはNOTCH_MAX(14)を一瞬超えてから収まります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: タブが変わるときノッチ深さが目標値をわずかに通り過ぎてから沈む減衰振動(スプリングオーバーシュート)で動く
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';
}
}
05ウェーブパネル — 4つの角の曲線の値が位相差を持って順に揺れる方式
各角は自分自身の半径を BASE_R + AMP × Math.sin(t × 2π + i × π/2) で計算するため、4つの値はそれぞれ四半周期ずつずれており、膨らみが WAVE_MS ごとにパネルを一周する波のように見えます。ヒーローセクションの背景パネルやイントロカードに合う方式です。クリックで波を一時停止し、もう一度クリックで再開できます。
数値が語ること
項目05は23フレーム中23枚で動き、累積面積1.01%、強度平均109.7と項目01に次ぐ高さで、4つの角が位相をずらして順に膨らむため強度の高さが持続します。
4つの角の位相差はちょうど90度(π/2)なので、膨らみは対角ではなく隣り合う角から順に移ります。

<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 · ウェーブパネル — 4つの角の曲線の値が位相差を持って順に揺れる方式 — 計測 累積面積 1.01% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 角ごとに位相を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);
}
06ホバーモーフィング — 待機状態とホバー状態の角の曲線の値が補間されて移る方式
ホバーが変わるたび、補間比率は0か1へ指数減衰でゆっくり追いつき、半径はその比率に乗って ROUND_R(48)と BEVEL_R(6)のあいだを移り、シェイプキーワード自体は中間点で round から bevel へ渡ります。ナビゲーション項目やフィルターボタンに合う方式です。キーボードのフォーカスでも同じ遷移が走ります。
数値が語ること
項目06は累積面積3.251%と9項目中で最大ですが、23フレーム中動くのは13枚にとどまり、指数減衰で比率がゆっくり目標へ追いつく慣性lerpの性質上、動く範囲は広いものの動くフレーム数は中間にとどまります。
LERP_Kの値が大きいほど比率の追いつきが速まり、round/bevelというシェイプキーワード自体も比率0.5で切り替わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 比率の値を目標へ瞬間移動させず、指数減衰(dtベースの慣性lerp)で追いつかせる — 2つの形の間の補間比率である
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) + '%';
}
07半径のはしご — 1枚のカードの半径値を固定の5段はしごで上り下りし、同じ曲線の質感を見る方式
カードは RUNGS リスト[4, 12, 20, 28, 36]を上り下りし、goTo は現在の補間中の半径を新しい出発点として記録するため、段が変わっても値が途中で飛びません。デザインシステムの角トークン規格ページに合う方式です。矢印キーでも段を送れます。
数値が語ること
項目07は23フレーム中15枚で動き、累積面積0.797%、強度平均107.2で、5段のはしごを3次ease-in-outで補間するため段の切り替わり付近でだけ値が大きく動きます。
goToが呼ばれるたびに現在値をfromRとして記録するため、はしごの途中で方向が変わっても値は飛びません。

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="card" id="card" type="button" aria-label="半径ラダーを1段上る(矢印キーで上下)">
<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 · 半径のはしご — 1枚のカードの半径値を固定の5段はしごで上り下りし、同じ曲線の質感を見る方式 — 計測 累積面積 0.797% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: はしごの段の間を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);
}
08フォールバック並び比較 — 同じ半径値を与えた対応ペインとフォールバックペインの輪郭を並べて比べる方式
2つのペインはどちらも R_MIN(6)と R_MAX(34)のあいだをコサインで往復する同じ半径を受け取りますが、左のペインは対応環境でのみ cornerShape = superellipse(2.6) を加え、右のペインは常に border-radius の近似のままなので、2つのレンダリングがどこで分かれるかを目で確かめられます。ブラウザ対応の案内画面や互換性表に合う方式です。左のキャプションは、開いた環境が対応していないとき自分で書き換わります。
数値が語ること
項目08は23フレーム中23枚で動き、累積面積1.415%、強度平均82.0で、対応ペインと近似ペインの両方が同じコサイン往復の半径を受け取るため、2つの輪郭が同時に動きます。
SUPER_N(2.6)は対応ペインにだけ適用され、右のペインはborder-radiusの近似だけを受け取り続けます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 半径をコサインで往復させる — サインではなく位相を合わせたコサインで、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の近似だけを使う — 2つのレンダリングの違いを並べて比べる
shimBox.style.borderRadius = r.toFixed(2) + 'px';
readout.textContent = '現在の半径 ' + r.toFixed(1);
}
09内側フレーム — 外側曲線と内側曲線の値が別々に置かれ、二重の縁が生まれる方式
外側のフレームは固定の OUTER_R(40)半径を保ち、内側の曲線は INSET_MIN(6)と INSET_MAX(22)のあいだを呼吸するインセット値から Math.max(2, OUTER_R − inset) で計算されるため、2つの輪郭の間隔は曲線に沿って変わり、流れる二重の縁ができます。プロフィールカードの縁や認証マークのフレームに合う方式です。押しっぱなしにすると内側の曲線が沈み込みます。
数値が語ること
項目09は23フレーム中23枚で動き、累積面積0.428%、強度平均82.8で、外側フレームは固定のまま内側の曲線だけがインセット値に応じて動くため、変化の面積は9項目の中でも小さめです。
OUTER_R(40)は固定のまま、INSET_MIN(6)からINSET_MAX(22)のあいだでinsetだけが呼吸するように動きます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: インセット算術 — 内側半径 = 外側半径 − インセット。インセットが大きいほど内側の曲線が深く削られる
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);
}
値が稜線と面を変える基準 — 指数値・角度値・比率値
9つの項目はすべて起動時にCSS.supportsでcorner-shapeの対応を確認し、対応する環境だけがsuperellipse・scoop・notch・bevelといった実際の形状キーワードを受け取ります。対応しない環境では、どの項目もJavaScriptで計算したborder-radiusの値に静かに切り替わり、輪郭の丸さや角度の近似だけで同じ操作感を保ちます。08はこの2つの描画を1つの画面で並べる項目で、左のペインだけが対応環境でsuperellipse(2.6)を追加で受け取り、右のペインは常にborder-radiusの近似のままなので、対応・非対応の違いを実際に見比べられます。ここで言う近似は、同じ半径の値を丸い角として当てはめるという意味での近似であり、角の曲線そのものを描き直すポリフィルとは役割が違います。この呼び分けの意味は、後のFAQでもう一度説明に立ち上がります。
9項目はいずれも起動直後に window.matchMedia('(prefers-reduced-motion: reduce)') の値を reduce という変数に保存しており、CSSの transition を止めるだけでなく、JavaScript側のアニメーション分岐そのものにもこの値を使います。たとえば04は reduce が真のとき springOvershoot の計算を経由せず、ノッチ深さをいきなり目標値まで進めます。09は reduce が真だと内側の曲線のインセットが呼吸する代わりに1つの固定値へ止まります。動きの有無は項目ごとに実装が分かれますが、matchMediaで検知するという入口は9項目で共通しています。固定の仕方も項目ごとに読み方が違い、01は流れの到着点である SUPER_N に、07ははしごの最上段に、08はコサイン往復の折り返しに置くため、止まった姿がそれぞれ別の性格を見せます。このように止まり方そのものが項目の性格を写すので、動きを減らした設定で開いてもページはただの静的な絵にはならず、各項目の代表的な形が一枚ずつ並んだ見本帳のように読めます。
この編には3つのバリエーションも用意されています。1つ目は01の指数上限をSUPER_N 4.0から8.0まで広げ、折り返しの終着点をより角ばった形に押し広げます。2つ目は02の角度上限をBEVEL_DEG 18度から36度まで広げ、斜面幅がさらに大きく育つようにします。3つ目もまた01が対象で、SWEEP_MSを2400msから1200msへ縮め、指数が円と四角のあいだを往復する速さを2倍にします。3つとも定数の値を差し替えるだけであり、計算層の関数そのものは1行も変わらないため、元の9項目を読んだ人には同じコードの別の調律に見えます。
指数値・角度値・比率値という3つの軸は、9項目それぞれの計算層がどんな入力を受け取るかで分かれています。スーパー楕円のように曲線の式そのものへ数値を渡す項目もあれば、ベベルのように三角比を経由してから半径へ変換する項目、モーフィングのように2つの固定形状のあいだの位置だけを渡す項目もあり、同じ corner-shape という性質でも値の意味は項目ごとに異なります。
同じ数値でも、軸が変われば角の曲線に届くまでの道のりが変わります。指数は式の形そのものを連続的に変え、角度は三角比を通ってから2つの半径に分かれ、比率は動かない2つの端点のあいだの位置だけを運びます。だから、値の単位が同じ px や度数であっても、角がどう変わるかは計算層が通る関数が決めます。03のスクープは押している時間という入力を曲げて凹みの深さに替え、07のはしごは時間を5段の固定値へ量子化してから補間するなど、入力の種類はさらに分かれます。05と08は時計の位相そのものを入力にするため、ユーザーが値に触らずにも曲線が動き続けます。02の斜面幅は角度から三角比で導かれる従属値なので、指で触っているのは角度の1つだけで、幅のほうは計算が連れてきます。
軸が違えば、値を動かしたときにユーザーの目に映る変化の場所も自然と変わります。
3つの項目は、それぞれ全く異なる基準に反応しています。01の稜線は指数値に反応します — 同じ40px半径でも、指数1では円に近く、4.0ではスクワークルになるため、角がどれだけ四角く読めるかは指数だけが決めます(css corner shape squircle の最も純粋な形がこれです)。02の面は角度値に反応します — BEVEL_RY は14pxのまま固定で、Math.tan が横軸半径だけを広げるため、斜面幅は角度のタンジェントに従い、角度を2倍にすれば幅は2倍を超えて広がります。06の輪郭は比率値に反応します — round という形も bevel という形も自分では動かず、その2つのあいだを移動する補間比率だけが動くため、同じカードに2つの固定した端点と1つの旅する値が乗っています。9項目は1つのzipにまとまっており、開くパスワードはx4zdvxz7のひとつだけで、パターンごとに別のパスワードを探す必要はありません。
このcss squircle border radius の集まりは、既出の3つの記事の隣に置けます。一行でコピペOK ― CSS カード ホバー アニメーション9選はプロパティ1つでカード全体を持ち上げますが、ここでは06がカードの位置ではなく角の曲線をモーフィングさせます。【コピペOK】css バッジ デザイン 9選 — NEW・SALE・認証バッジはバッジの形を静的な構成として作る一方、02は1つのベベル面を生きた値に変えます。プロフィール UI デザイン 9種 — 画像ファイルなしはブロック全体を部品から組み立てる記事で、09のインセットフレームはそのようなブロックが持てる縁の層です。
どこで壊れるか — 非対応ブラウザ、フォールバック、reduced-motion
css corner-shape のデモがどこで滑らかさを失うか、3つの場所で見えます。
プロパティがなくなり、近似が引き継ぐとき
9項目はどれも起動時に一度だけ CSS.supports を確認し、確認が偽なら border-radius の近似を書き込みます — 01は指数が大きいほど半径を狭め、03と04は凹んだスクープと掘られたノッチを同じ大きさの丸い角と入れ替えます。フォールバックでも輪郭は動き続けますが、性格は変わります。スクープは凸に、ノッチは丸に読めるため、border-radius 経由で見る css スクワークル は本物の形の親戚であって形そのものではありません。08はまさにその差を画面に置くために存在し、確認が偽のとき左のキャプションは「非対応フォールバック」に自分で書き換わるため、対応なしのブラウザでも比較は本当のことを語り続けます。
スプリングの途中で次のタブ切り替えが来るとき
04のノッチ深さは切り替えのあと SPRING_MS のあいだだけスプリングを追い、鳴り終わっていないスプリングの途中に次の切り替えが来ると、現在の深さからブレンドするのではなく新しい phaseStart で時計をやり直します — 溝は新しいタブへ飛んで、曲線の最初からもう一度鳴ります。
reduced-motion は各項目をそれぞれ違う状態で止める
9項目はそれぞれ独自の prefers-reduced-motion 分岐を持ち、その内容は項目ごとに異なります。01は指数を SUPER_N に固定し、02は角度を BEVEL_DEG に固定し、03はスクープ半径を即座に SCOOP_MAX へ適用し、04はスプリングなしでノッチ深さをまっすぐ NOTCH_MAX へ置き、05は波の位相を固定された t で凍らせ、06は比率をホバー目標へまっすぐ設定し、07は最上段へ一気に上がり、08は t を 0.5(コサイン掃引の R_MAX 状態)に固定し、09はインセットを INSET_MAX で固定します。Sass 側でも、同じメディアクエリの下で全項目に transition: none が対になっています。
3つのバリエーションとアクセシビリティ
| バリエーション | 変わるもの | 変わらないもの |
|---|---|---|
| スクワークル指数を上げる | 01 SUPER_N 4.0 → 8.0 | MIN_N、SWEEP_MS、40px半径 |
| ベベル角度を広げる | 02 BEVEL_DEG 18 → 36 | MIN_DEG、BEVEL_RY、BEVEL_MS |
| 指数の掃引を速くする | 01 SWEEP_MS 2400 → 1200 | SUPER_N、MIN_N、プリセット一覧 |
01の SUPER_N を4.0から8.0へ上げると、ソースは指数を MIN_N(1)から上限まで滑らかな補間で流すため、流れが届く稜線はさらに四角形側へ深くなり、上限を変えるだけで流れ全体の質感が変わります。02の BEVEL_DEG を18から36へ上げると、横の深さは BEVEL_RY × tan(角度) で計算され斜面幅は hypot で測られるため、角度を2倍にすれば斜面幅は2倍を超えて広がります。01の SWEEP_MS を2400msから1200msへ縮めると、ソースは経過時間を SWEEP_MS で割った三角波で指数を流すため、円と四角のあいだを行き来する流れがちょうど2倍速くなります。
入力の側では、9つのファイルを grep するとリスナーは3通りに分かれます。01と02は click だけを聞きます。04・05・07・08は click と keydown を組み合わせます。03は押し続けを追うため keydown・keyup に pointerdown・pointerup・pointercancel・pointerleave を加え、06は focus・blur・click とともに pointerenter・pointerleave を読み、09は keydown・keyup と pointerdown・pointerup・pointerleave を配線します。すべてのボタンは読み上げ可能なラベルかテキスト内容を持ち、カードの下の数値は aria-live="polite" の段落なので、値が変わるたびに読み上げられます。01・02・03・05・06・09の切り替え式ボタンは状態を aria-pressed で公開し、04のタブは代わりに aria-selected を使います。7つの項目では自動巡回は is-demo クラスがあるあいだ、かつ reduced-motion が無効のあいだだけ走り、最初の実際のポインターやキーの入力がそのクラスを外すため、訪問者が操作を引き受けたあとに値は巡回をやめます。05と08の波とコサインは時計を止めない限り動き続けますが、クリックで一時停止できるため、訪問者が主導権を取り戻す道は9項目とも同じです。
FAQ
css corner shape はどこでも動きますか、それともこの css corner shape 例 にはフォールバックが必要ですか
各項目は起動時に同じ1行の確認 — CSS.supports に corner-shape のペアを渡す — を実行し、失敗すれば border-radius の近似を書き込みます。対応する環境ではプロパティそのものが使え、どこでもなければ、輪郭は同じ数値のまま近似として動き続けます。08は2つのレンダリングを並べて、その違いを説明ではなく目で見える形にしています。
この css squircle border radius のパターンは、それぞれ別ファイルですか、それとも設定違いの1コンポーネントですか
9つとも独立したファイルです。項目ごとに自分自身の定数と apply 関数を持ち、上のコード欄はテンプレートに数値を差し込んだ出力ではなく、そのファイルから直接抜き出した抜粋です。zipには9つの html・scss・js ファイルが一緒に入っているため、他の8つを引き連れずに1つのパターンだけ持ち上げられます。
08の近似ペインは、なぜポリフィルではなくフォールバックと呼ぶのですか
プロパティがない場所にそれをインストールしないからです。このペインは、ネイティブのレンダリングの隣に border-radius の代役を描いて比べるだけです。本物のポリフィルは角の曲線そのものを合成しなければなりませんが、このペインは、そのプロパティを持たないブラウザが同じ半径値に対して何を描くかを示すだけです。
Sources: MDN — corner-shape · MDN — border-radius · MDN — superellipse() · MDN — @supports









