06Button Whose Hue Accelerates While Held
While the button is held its hue angle spins faster and faster, then slows by inertia on release and stays at the color where it stopped.
Best forChoosing a hue with a single button
MAX_SPEEDHUE_STARTHUE_STEPHOLD_MS

<main class="stage">
<div class="bc is-demo" id="bcRoot">
<button class="bc__btn" id="bcBtn" type="button">Press and hold</button>
<p class="bc__meta"><span class="bc__tag">HUE</span> <b id="bcHue">270</b><span>deg</span> <span class="bc__tag">SPEED</span> <b id="bcSpeed">0</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 색상 각도(--hue)는 속도를 쌓아 얻는 값이다. 누르면 속도가 가속하고, 떼면 마찰로 줄어 멈춘 색에 남는다.
$hue-sat: 72%;
$hue-light: 82%;
@include stage($stage-ink);
.bc {
width: min(100%, 320px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.bc__btn {
--hue: 270;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: hsl(var(--hue), $hue-sat, $hue-light);
color: $subject-ink;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
}
.bc__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.bc__btn:active { transform: translateY(1px); }
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: $sp-1;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.bc__tag { margin: 0 $sp-1 0 $sp-2; font-weight: 700; letter-spacing: .08em; }
.bc__tag:first-child { margin-left: 0; }
.bc__meta b { min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; color: $stage-paper; }
@media (prefers-reduced-motion: reduce) {
.bc__btn { transition: none; }
.bc__btn:active { transform: 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);
}
.bc {
width: min(100%, 320px);
display: grid;
gap: 12px;
justify-items: center;
}
.bc__btn {
--hue: 270;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: hsl(var(--hue), 72%, 82%);
color: #17141a;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
}
.bc__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.bc__btn:active {
transform: translateY(1px);
}
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.bc__tag {
margin: 0 4px 0 8px;
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__tag:first-child {
margin-left: 0;
}
.bc__meta b {
min-width: 3ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
.bc__btn:active {
transform: none;
}
}(function () {
// 기법: 누르는 동안 색상 각속도가 ACCEL 로 커지고(상한 MAX_SPEED), 떼면 FRICTION 으로 줄어 멈춘다.
// 색상 각도는 속도를 매 프레임 쌓아 얻는다. 시간은 rAF 타임스탬프 간격(dt)만 쓴다.
var ACCEL = 600;
var MAX_SPEED = 720;
var FRICTION = 2.4;
var HUE_START = 270;
var HUE_STEP = 60;
var START_MS = 200;
var HOLD_MS = 900;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var hueEl = document.getElementById('bcHue');
var speedEl = document.getElementById('bcSpeed');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var hue = HUE_START;
var speed = 0;
var pressed = false;
var t0 = null;
var lastTs = null;
function paint() {
btn.style.setProperty('--hue', hue.toFixed(1));
hueEl.textContent = String(Math.round(hue));
speedEl.textContent = String(Math.round(speed));
}
function stopDemo() { root.classList.remove('is-demo'); }
function press() {
stopDemo();
pressed = true;
if (reduce) { hue = (hue + HUE_STEP) % 360; paint(); }
}
function release() { pressed = false; }
btn.addEventListener('pointerdown', function (e) {
try { btn.setPointerCapture(e.pointerId); } catch (err) { /* 캡처를 못 걸어도 누름은 동작한다 */ }
press();
});
btn.addEventListener('pointerup', release);
btn.addEventListener('pointercancel', release);
btn.addEventListener('blur', release);
btn.addEventListener('keydown', function (e) {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) press();
});
btn.addEventListener('keyup', function (e) {
if (e.key === ' ' || e.key === 'Enter') release();
});
function demoPressed(now) {
return root.classList.contains('is-demo') && now >= START_MS && (now - START_MS) % CYCLE_MS < HOLD_MS;
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demoPressed(now) || pressed) {
speed = Math.min(MAX_SPEED, speed + ACCEL * dt);
} else {
speed *= Math.exp(-FRICTION * dt);
if (speed < 0.5) speed = 0;
}
hue = (hue + speed * dt) % 360;
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// HueHoldButton — vanilla 판(06-btn-hue-hold-inertia-ramp) 의 React 포트.
// 누르는 동안 색상 각속도가 가속하고(MAX_SPEED 상한), 떼면 FRICTION 으로 줄어 멈춘다.
import { useEffect, useRef } from "react";
import styles from "./HueHoldButton.module.scss";
type Box<T> = { current: T };
type PointerLike = Event & { pointerId: number };
type KeyLike = Event & { key: string; repeat: boolean };
const ACCEL = 600;
const MAX_SPEED = 720;
const FRICTION = 2.4;
const HUE_START = 270;
const HUE_STEP = 60;
const START_MS = 200;
const HOLD_MS = 900;
const CYCLE_MS = 2000;
export default function HueHoldButton() {
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const hueRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const speedRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const btn = btnRef.current;
const hueEl = hueRef.current;
const speedEl = speedRef.current;
if (!btn || !hueEl || !speedEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let hue = HUE_START;
let speed = 0;
let pressed = false;
let demo = !reduce;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const paint = (): void => {
btn.style.setProperty("--hue", hue.toFixed(1));
hueEl.textContent = String(Math.round(hue));
speedEl.textContent = String(Math.round(speed));
};
const press = (): void => {
demo = false;
pressed = true;
if (reduce) { hue = (hue + HUE_STEP) % 360; paint(); }
};
const release = (): void => { pressed = false; };
const onDown = (e: Event): void => {
try { btn.setPointerCapture((e as PointerLike).pointerId); } catch (err) { /* 캡처를 못 걸어도 누름은 동작한다 */ }
press();
};
const onKeyDown = (e: Event): void => {
const k = e as KeyLike;
if ((k.key === " " || k.key === "Enter") && !k.repeat) press();
};
const onKeyUp = (e: Event): void => {
const k = e as KeyLike;
if (k.key === " " || k.key === "Enter") release();
};
btn.addEventListener("pointerdown", onDown);
btn.addEventListener("pointerup", release);
btn.addEventListener("pointercancel", release);
btn.addEventListener("blur", release);
btn.addEventListener("keydown", onKeyDown);
btn.addEventListener("keyup", onKeyUp);
const demoPressed = (now: number): boolean => demo && now >= START_MS && (now - START_MS) % CYCLE_MS < HOLD_MS;
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (demoPressed(now) || pressed) {
speed = Math.min(MAX_SPEED, speed + ACCEL * dt);
} else {
speed *= Math.exp(-FRICTION * dt);
if (speed < 0.5) speed = 0;
}
hue = (hue + speed * dt) % 360;
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) paint();
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("pointerdown", onDown);
btn.removeEventListener("pointerup", release);
btn.removeEventListener("pointercancel", release);
btn.removeEventListener("blur", release);
btn.removeEventListener("keydown", onKeyDown);
btn.removeEventListener("keyup", onKeyUp);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.bc}>
<button className={styles.bc__btn} type="button" ref={btnRef}>꾹 누르기</button>
<p className={styles.bc__meta}>
<span className={styles.bc__tag}>HUE</span> <b ref={hueRef}>270</b><span>deg</span> <span className={styles.bc__tag}>SPEED</span> <b ref={speedRef}>0</b>
</p>
</div>
</main>
);
}06 Button Whose Hue Accelerates While Held — Measured area 15.999% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Button Color Transition: 9 Hover Demos
The post this demo lives in
CSS Button Color Transition: 9 Hover DemosOne line recolors a button on hover, yet it muddies the midpoint, cannot move a gradient and loses the label midway — sc See all demos in this post
If you like this effect - similar demos
Buttons08 Flowing wait — a button whose surface brightness value sweeps in one direction while content isn't readyCTA Button Design: 9 Interaction Patterns
Buttons05 Button Whose Distance Value Becomes a Color-interpolation PointCSS Button Hover Effects — 9 Proximity Patterns
Buttons04 Width-inertia value toggle — while an expand value grows, a width value overshoots its target and returnsExpanding Button UI: 9 Shape-Morph Patterns