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


