06押している間に色相が加速して回るボタン
ボタンを押している間は色相の角度がだんだん速く回って加速し、離すと慣性で速度が落ちて止まった色のまま残る。
使いどころ色相の選択をボタン一つで済ませる入力
MAX_SPEEDHUE_STARTHUE_STEPHOLD_MS

<main class="stage">
<div class="bc is-demo" id="bcRoot">
<button class="bc__btn" id="bcBtn" type="button">長押し</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 押している間に色相が加速して回るボタン — 計測 累積面積 15.999% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css ボタン 色 変更 9パターン — ホバー途中の色が濁らないよう計算した場面
このデモが属する記事
css ボタン 色 変更 9パターン — ホバー途中の色が濁らないよう計算した場面ボタンの色をホバーで変えるのは一行で済むが、その一行は途中の色を濁らせ、グラデーションを動かせず、文字を途中で読めなくする — 02番・04番・07番がそれを一つずつ塞ぐ。css ボタン 色 変更は、ホバーや押下に合わせて背景色やグラデーシ この記事のデモをすべて見る


