03Eight-handle corner radius
Each corner gets its own horizontal and vertical radius, written with the slash syntax. Eight different values give a blob-like asymmetric shape.
Best forSquishy avatar frames, organic section dividers
border-radius--r-tl--r-vy

<main class="stage">
<div class="gen is-demo" id="gen">
<div class="gen__top">
<div class="gen__preview-wrap" aria-hidden="true">
<div class="gen__preview"></div>
<span class="gen__dot gen__dot--tl"></span><span class="gen__dot gen__dot--tr"></span>
<span class="gen__dot gen__dot--br"></span><span class="gen__dot gen__dot--bl"></span>
</div>
<div class="gen__grid">
<label class="gen__row">
<span class="gen__lab">Top L</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="12" data-var="--r-tl" aria-label="Top-left corner radius">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">Top R</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="48" data-var="--r-tr" aria-label="Top-right corner radius">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">Bot R</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="12" data-var="--r-br" aria-label="Bottom-right corner radius">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">Bot L</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="48" data-var="--r-bl" aria-label="Bottom-left corner radius">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
<label class="gen__row">
<span class="gen__lab">Vert radius</span>
<span class="gen__slot">
<input class="gen__rng" type="range" min="0" max="80" step="4" value="28" data-var="--r-vy" aria-label="Corner vertical radius">
<span class="gen__thumb" aria-hidden="true"></span>
</span>
</label>
</div>
</div>
<div class="gen__out">
<code class="gen__code"><span class="gen__prop">border-radius:</span> <span class="gen__v gen__v--tl"></span>px <span class="gen__v gen__v--tr"></span>px <span class="gen__v gen__v--br"></span>px <span class="gen__v gen__v--bl"></span>px / <span class="gen__v gen__v--vy"></span>px</code>
<button class="gen__copy" type="button" id="copy">Copy</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 여덟 손잡이 모서리 — 네 모서리 가로 반경 + 공통 세로 반경을 사용자 지정 속성에 모은다.
// 슬래시 문법(/ 뒤 세로 반경)이 비대칭 블롭을 만든다. 모서리 점 네 개가 미리보기 위에
// 반경 값을 따라 붙는다 — 어느 모서리가 움직이는지 눈으로 보인다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(23, 20, 26, .14);
$track: rgba(23, 20, 26, .18);
$rail: 4px;
$thumb: 12px;
@property --r-tl { syntax: "<integer>"; inherits: true; initial-value: 12; }
@property --r-tr { syntax: "<integer>"; inherits: true; initial-value: 48; }
@property --r-br { syntax: "<integer>"; inherits: true; initial-value: 12; }
@property --r-bl { syntax: "<integer>"; inherits: true; initial-value: 48; }
@property --r-vy { syntax: "<integer>"; inherits: true; initial-value: 28; }
@include stage($stage-yellow);
.gen {
min-width: 0; // stage grid item 의 자동 최소폭(min-content) 을 끊는다 — BD9
--r-tl: 12;
--r-tr: 48;
--r-br: 12;
--r-bl: 48;
--r-vy: 28;
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.gen__top { display: flex; align-items: center; gap: $sp-3; }
// ── 미리보기 — 슬래시 문법 그대로
.gen__preview-wrap { position: relative; flex: 0 0 auto; width: 124px; height: 84px; }
.gen__preview {
box-sizing: border-box; width: 124px; height: 84px;
background: $subject-blue;
// 모서리별 longhand 두 값(가로 세로)은 슬래시 문법과 동치다 — 값이 속성마다 붙어 있어
// 어느 모서리가 움직이는지 읽기도 쉽다
border-top-left-radius: calc(var(--r-tl) * 1px) calc(var(--r-vy) * 1px);
border-top-right-radius: calc(var(--r-tr) * 1px) calc(var(--r-vy) * 1px);
border-bottom-right-radius: calc(var(--r-br) * 1px) calc(var(--r-vy) * 1px);
border-bottom-left-radius: calc(var(--r-bl) * 1px) calc(var(--r-vy) * 1px);
}
// 모서리 점 — 각 모서리 반경만큼 안쪽으로 붙는다
.gen__dot {
position: absolute; width: 8px; height: 8px; margin: -4px;
border-radius: 50%; background: $subject-ink;
}
.gen__dot--tl { left: calc(var(--r-tl) * 1px); top: calc(var(--r-vy) * 1px); }
.gen__dot--tr { right: calc(var(--r-tr) * 1px); top: calc(var(--r-vy) * 1px); }
.gen__dot--br { right: calc(var(--r-br) * 1px); bottom: calc(var(--r-vy) * 1px); }
.gen__dot--bl { left: calc(var(--r-bl) * 1px); bottom: calc(var(--r-vy) * 1px); }
.gen__grid { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $sp-1 $sp-2; }
.gen__row { display: flex; align-items: center; gap: $sp-1; cursor: pointer; }
.gen__lab {
flex: 0 0 auto; width: 42px;
color: $subject-ink;
font-weight: 700; font-size: 10px; line-height: 1.4;
}
.gen__slot {
position: relative; flex: 1 1 auto; min-width: 0;
display: grid; align-items: center; height: 16px;
}
.gen__slot::before {
content: ""; grid-area: 1 / 1;
height: $rail; border-radius: $r-pill; background: $track;
}
.gen__rng {
grid-area: 1 / 1; appearance: none; -webkit-appearance: none;
width: 100%; min-width: 0; height: 16px; margin: 0; background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: $thumb; height: $thumb; border: 0; border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-pill; }
.gen__thumb {
position: absolute; top: 50%; left: calc(var(--pct) * 1%);
width: $thumb; height: $thumb; margin-top: -($thumb * .5); margin-left: -($thumb * .5);
border-radius: 50%; background: $color;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb { --pct: calc(var(--r-tl) / 80 * 100); }
.gen__row:nth-child(2) .gen__thumb { --pct: calc(var(--r-tr) / 80 * 100); }
.gen__row:nth-child(3) .gen__thumb { --pct: calc(var(--r-br) / 80 * 100); }
.gen__row:nth-child(4) .gen__thumb { --pct: calc(var(--r-bl) / 80 * 100); }
.gen__row:nth-child(5) .gen__thumb { --pct: calc(var(--r-vy) / 80 * 100); }
.gen__out { display: flex; align-items: flex-start; gap: $sp-2; }
.gen__code {
flex: 1 1 auto; min-width: 0;
padding: $sp-2; border-radius: $r-sm;
background: $subject-ink; color: $subject-cream;
font-family: $mono; font-weight: 500; font-size: 10px; line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
.gen__prop { color: $subject-blue-light; }
.gen__v { font-weight: 700; }
.gen__v--tl::after { counter-reset: v var(--r-tl); content: counter(v); }
.gen__v--tr::after { counter-reset: v var(--r-tr); content: counter(v); }
.gen__v--br::after { counter-reset: v var(--r-br); content: counter(v); }
.gen__v--bl::after { counter-reset: v var(--r-bl); content: counter(v); }
.gen__v--vy::after { counter-reset: v var(--r-vy); content: counter(v); }
.gen__copy {
flex: 0 0 auto;
padding: $sp-2 $sp-3; border: 0; border-radius: $r-sm;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 — 왼위/오아 가 한 쌍, 오위/왼아 가 다른 쌍으로 엇갈려 찌그러진다
@keyframes rTL { 0%, 100% { --r-tl: 12; } 50% { --r-tl: 64; } }
@keyframes rTR { 0%, 100% { --r-tr: 48; } 50% { --r-tr: 8; } }
@keyframes rBR { 0%, 100% { --r-br: 12; } 50% { --r-br: 64; } }
@keyframes rBL { 0%, 100% { --r-bl: 48; } 50% { --r-bl: 8; } }
@keyframes rVY { 0%, 100% { --r-vy: 28; } 40% { --r-vy: 68; } 80% { --r-vy: 16; } }
.gen.is-demo {
animation-name: rTL, rTR, rBR, rBL, rVY;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo { animation: none !important; }
}
@media (max-width: 340px) {
.gen { gap: $sp-2; padding: $sp-2; }
.gen__top { gap: $sp-2; }
.gen__preview-wrap, .gen__preview { width: 100px; height: 72px; }
.gen__code { font-size: 9px; }
}@property --r-tl {
syntax: "<integer>";
inherits: true;
initial-value: 12;
}
@property --r-tr {
syntax: "<integer>";
inherits: true;
initial-value: 48;
}
@property --r-br {
syntax: "<integer>";
inherits: true;
initial-value: 12;
}
@property --r-bl {
syntax: "<integer>";
inherits: true;
initial-value: 48;
}
@property --r-vy {
syntax: "<integer>";
inherits: true;
initial-value: 28;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gen {
min-width: 0;
--r-tl: 12;
--r-tr: 48;
--r-br: 12;
--r-bl: 48;
--r-vy: 28;
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.gen__top {
display: flex;
align-items: center;
gap: 12px;
}
.gen__preview-wrap {
position: relative;
flex: 0 0 auto;
width: 124px;
height: 84px;
}
.gen__preview {
box-sizing: border-box;
width: 124px;
height: 84px;
background: #2f6df6;
border-top-left-radius: calc(var(--r-tl) * 1px) calc(var(--r-vy) * 1px);
border-top-right-radius: calc(var(--r-tr) * 1px) calc(var(--r-vy) * 1px);
border-bottom-right-radius: calc(var(--r-br) * 1px) calc(var(--r-vy) * 1px);
border-bottom-left-radius: calc(var(--r-bl) * 1px) calc(var(--r-vy) * 1px);
}
.gen__dot {
position: absolute;
width: 8px;
height: 8px;
margin: -4px;
border-radius: 50%;
background: #17141a;
}
.gen__dot--tl {
left: calc(var(--r-tl) * 1px);
top: calc(var(--r-vy) * 1px);
}
.gen__dot--tr {
right: calc(var(--r-tr) * 1px);
top: calc(var(--r-vy) * 1px);
}
.gen__dot--br {
right: calc(var(--r-br) * 1px);
bottom: calc(var(--r-vy) * 1px);
}
.gen__dot--bl {
left: calc(var(--r-bl) * 1px);
bottom: calc(var(--r-vy) * 1px);
}
.gen__grid {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
}
.gen__row {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.gen__lab {
flex: 0 0 auto;
width: 42px;
color: #17141a;
font-weight: 700;
font-size: 10px;
line-height: 1.4;
}
.gen__slot {
position: relative;
flex: 1 1 auto;
min-width: 0;
display: grid;
align-items: center;
height: 16px;
}
.gen__slot::before {
content: "";
grid-area: 1/1;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.gen__rng {
grid-area: 1/1;
appearance: none;
-webkit-appearance: none;
width: 100%;
min-width: 0;
height: 16px;
margin: 0;
background: transparent;
cursor: pointer;
}
.gen__rng::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: transparent;
}
.gen__rng:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 999px;
}
.gen__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1%);
width: 12px;
height: 12px;
margin-top: -6px;
margin-left: -6px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.gen__row:nth-child(1) .gen__thumb {
--pct: calc(var(--r-tl) / 80 * 100);
}
.gen__row:nth-child(2) .gen__thumb {
--pct: calc(var(--r-tr) / 80 * 100);
}
.gen__row:nth-child(3) .gen__thumb {
--pct: calc(var(--r-br) / 80 * 100);
}
.gen__row:nth-child(4) .gen__thumb {
--pct: calc(var(--r-bl) / 80 * 100);
}
.gen__row:nth-child(5) .gen__thumb {
--pct: calc(var(--r-vy) / 80 * 100);
}
.gen__out {
display: flex;
align-items: flex-start;
gap: 8px;
}
.gen__code {
flex: 1 1 auto;
min-width: 0;
padding: 8px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 500;
font-size: 10px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.gen__prop {
color: #5b8cf8;
}
.gen__v {
font-weight: 700;
}
.gen__v--tl::after {
counter-reset: v var(--r-tl);
content: counter(v);
}
.gen__v--tr::after {
counter-reset: v var(--r-tr);
content: counter(v);
}
.gen__v--br::after {
counter-reset: v var(--r-br);
content: counter(v);
}
.gen__v--bl::after {
counter-reset: v var(--r-bl);
content: counter(v);
}
.gen__v--vy::after {
counter-reset: v var(--r-vy);
content: counter(v);
}
.gen__copy {
flex: 0 0 auto;
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
}
.gen__copy:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@keyframes rTL {
0%, 100% {
--r-tl: 12;
}
50% {
--r-tl: 64;
}
}
@keyframes rTR {
0%, 100% {
--r-tr: 48;
}
50% {
--r-tr: 8;
}
}
@keyframes rBR {
0%, 100% {
--r-br: 12;
}
50% {
--r-br: 64;
}
}
@keyframes rBL {
0%, 100% {
--r-bl: 48;
}
50% {
--r-bl: 8;
}
}
@keyframes rVY {
0%, 100% {
--r-vy: 28;
}
40% {
--r-vy: 68;
}
80% {
--r-vy: 16;
}
}
.gen.is-demo {
animation-name: rTL, rTR, rBR, rBL, rVY;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.gen.is-demo {
animation: none !important;
}
}
@media (max-width: 340px) {
.gen {
gap: 8px;
padding: 8px;
}
.gen__top {
gap: 8px;
}
.gen__preview-wrap, .gen__preview {
width: 100px;
height: 72px;
}
.gen__code {
font-size: 9px;
}
}var gen = document.getElementById('gen');
var copyBtn = document.getElementById('copy');
var ranges = Array.prototype.slice.call(gen.querySelectorAll('.gen__rng'));
var COPY_LABEL = 'Copy';
var DONE_LABEL = 'Copied';
// 네 모서리 X 반경 + 공통 Vert radius — 다섯 값이면 슬래시 문법이 성립한다
function readVar(name) {
return parseInt(getComputedStyle(gen).getPropertyValue(name), 10) || 0;
}
function radiusText() {
return 'border-radius: ' + readVar('--r-tl') + 'px ' + readVar('--r-tr') + 'px ' + readVar('--r-br') + 'px ' + readVar('--r-bl') + 'px / ' + readVar('--r-vy') + 'px;';
}
// 클립보드는 iframe·http 환경에서 막힐 수 있다 — 거부된 프로미스를 반드시 catch 로 받아 선택 방식으로 떨어진다
function fallbackCopy(text) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
try { document.execCommand('copy'); } catch (err) { /* 여기까지 막히면 조용히 포기한다 */ }
document.body.removeChild(area);
}
ranges.forEach(function (input) {
input.addEventListener('input', function () {
gen.classList.remove('is-demo');
gen.style.setProperty(input.dataset.var, input.value);
});
input.addEventListener('pointerdown', function () { gen.classList.remove('is-demo'); });
});
copyBtn.addEventListener('click', function () {
gen.classList.remove('is-demo');
var text = radiusText();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
} else {
fallbackCopy(text);
}
copyBtn.textContent = DONE_LABEL;
setTimeout(function () { copyBtn.textContent = COPY_LABEL; }, 1200);
});import { useState, useMemo } from 'react';
import styles from './RadiusHandles.module.scss';
type Axis = { key: string; label: string; min: number; max: number };
// ambient 선언에 React 이벤트 제네릭이 없어 로컬 타입으로 받는다 (blog-demo TS7006 항)
type RangeEvt = { target: { value: string } };
const AXES: Axis[] = [
{ key: 'tl', label: '왼위', min: 0, max: 80 },
{ key: 'tr', label: '오위', min: 0, max: 80 },
{ key: 'br', label: '오아', min: 0, max: 80 },
{ key: 'bl', label: '왼아', min: 0, max: 80 },
{ key: 'vy', label: '세로 반경', min: 0, max: 80 }
];
export type RadiusHandlesProps = {
duration?: string;
easing?: string;
color?: string;
};
export default function RadiusHandles({ duration = '2s', easing = 'cubic-bezier(.2, .8, .2, 1)', color = '#17141a' }: RadiusHandlesProps) {
const [values, setValues] = useState<Record<string, number>>({ tl: 12, tr: 48, br: 12, bl: 48, vy: 28 });
const [live, setLive] = useState(true);
const codeText = useMemo(() => {
return `border-radius: ${values.tl}px ${values.tr}px ${values.br}px ${values.bl}px / ${values.vy}px;`;
}, [values]);
const style = {
'--dur-loop': duration,
'--ease-spring': easing,
'--accent': color,
'--r-tl': values.tl,
'--r-tr': values.tr,
'--r-br': values.br,
'--r-bl': values.bl,
'--r-vy': values.vy
} as Record<string, string | number>;
function copy() {
setLive(false);
try {
navigator.clipboard.writeText(codeText);
} catch (err) {
/* 클립보드가 막힌 환경에서는 조용히 넘어간다 */
}
}
return (
<div className={live ? `${styles.gen} ${styles.isDemo}` : styles.gen} style={style}>
<div className={styles.top}>
<div className={styles.previewWrap} aria-hidden="true">
<div className={styles.preview}></div>
<span className={styles.dot} style={{ left: values.tl, top: values.vy }}></span>
<span className={styles.dot} style={{ right: values.tr, top: values.vy }}></span>
<span className={styles.dot} style={{ right: values.br, bottom: values.vy }}></span>
<span className={styles.dot} style={{ left: values.bl, bottom: values.vy }}></span>
</div>
<div className={styles.grid}>
{AXES.map((axis) => (
<label className={styles.row} key={axis.key}>
<span className={styles.lab}>{axis.label}</span>
<span className={styles.slot}>
<input
className={styles.rng}
type="range"
min={axis.min}
max={axis.max}
step={4}
value={values[axis.key]}
aria-label={axis.label}
onChange={(e: RangeEvt) => {
setLive(false);
setValues((v) => ({ ...v, [axis.key]: Number(e.target.value) }));
}}
/>
<span
className={styles.thumb}
aria-hidden="true"
style={{ left: `${(values[axis.key] / axis.max) * 100}%` }}
/>
</span>
</label>
))}
</div>
</div>
<div className={styles.out}>
<code className={styles.code}>
<span className={styles.prop}>border-radius:</span> {values.tl}px {values.tr}px {values.br}px {values.bl}px / {values.vy}px
</code>
<button className={styles.copy} type="button" onClick={copy}>복사</button>
</div>
</div>
);
}03 Eight-handle corner radius — Measured area 5.821% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · CSS Generator: 9 Sliders That Write the Code
The post this demo lives in
CSS Generator: 9 Sliders That Write the CodeA CSS generator is a small panel that turns a CSS value into a control you can move: drag a slider, read the code. Nine See all demos in this post
If you like this effect - similar demos
Creative UI04 Collision avoidance — nodes push apart by their own radius, never overlapForce Directed Graph: 9 Network Layout Demos
Creative UI04 Drag-inertia knob — a rotating handle that eases to a stop after releaseCSS Radial Menu: 9 Circular Control Patterns
Creative UI01 White-Border Die-Cut Sticker CardCSS Sticker Peel Effect: 9 Styles, Border to Holo