07Radius ladder — one card's radius value steps up and down a fixed five-rung ladder within the same curve kind
One card climbs a five-rung radius ladder, each rung change interpolated smoothly, so the same curve's character streams across sizes.
Best forA design system's corner-token spec page
RUNGSSTEP_MS$subject-pink

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="card" id="card" type="button" aria-label="Climb the radius ladder one rung (arrow keys move it)">
<span id="rung">1</span><span class="of">/5</span>
</button>
<p class="readout" id="readout" aria-live="polite">Radius 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]; // 사다리 칸마다의 Radius 값(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 = 'Radius ' + 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 Radius ladder — one card's radius value steps up and down a fixed five-rung ladder within the same curve kind — Measured area 0.797% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · CSS corner-shape: 9 Corner Cutting Patterns
The post this demo lives in
CSS corner-shape: 9 Corner Cutting Patternscss corner-shape, live. Nine corner-shape patterns: one sweeps a superellipse exponent from circle to square, one interp See all demos in this post
If you like this effect - similar demos
base09 Rounded corner comparison — a card placing a circular-arc corner next to a smooth squircle cornerCSS Reset: 9 Patterns From Basics to Safe-Area
base03 Tracking and leading tunerCSS Typography Playground: 9 Demos, No Webfonts
Dashboard UI06 Stacked and grouped show the same numbers twiceChart UI Design: 9 Interactions You Can Drag