03Scoop button — an inward-cut scoop curve value moves and the button outline changes with it
As the inward curve value deepens, the button's edge turns concave, building the exact opposite impression of a convex rounded button.
Best forA next-step call button, a tag-remove button
SCOOP_MAXHOLD_MS$subject-mint

<main class="stage">
<div class="wrap is-demo" id="wrap">
<button class="btn" id="btn" type="button" aria-pressed="false" aria-label="Press to grow the scoop depth">Press</button>
<p class="readout" id="readout" aria-live="polite">Scoop radius 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; // 다 눌렀을 때 도달하는 Scoop radius 상한(px)
var SCOOP_MIN = 4; // 손 뗀 상태의 Scoop radius 하한(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 = 'Scoop radius ' + 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 button — an inward-cut scoop curve value moves and the button outline changes with it — Measured area 1.99% · moving frames 21/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 UI09 Diverging Bars Split Up and Down From a Zero Line That Moves With the DataBar Chart Animation: 9 Demos From Spring to Race