07물리형 노브 다이얼 — 손잡이 자체가 눌리고 튀는 질감의 다이얼
손잡이를 누르면 살짝 가라앉았다가 떼는 순간 튀어 오르듯 되돌아와, 실제 부품을 만지는 무게감을 냅니다.
쓰임장비 시뮬레이터 UI, 사운드 믹서 패널
springStepSPRING_KSPRING_DAMP

<main class="stage">
<div class="kd is-demo" id="kd">
<button class="kd__knob" id="kdKnob" type="button" aria-label="눌러서 밝기 다이얼 튕기기">
<span class="kd__face"></span>
</button>
<p class="kd__value" id="kdValue">밝기 40%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스프링 오버슛 다이얼 — 계산 겹은 index.html 의 감쇠 스프링(springStep)에 있다.
// 여기는 원형 손잡이 하나만 그리고 --scale 하나로 눌림·튕김을 전부 구동한다.
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-orange-deep;
@include stage($stage-paper);
.kd { display: flex; flex-direction: column; align-items: center; gap: $sp-5; }
.kd__knob {
position: relative;
appearance: none;
border: none;
border-radius: 50%;
width: 148px;
height: 148px;
cursor: pointer;
background: radial-gradient(circle at 32% 28%, rgba($subject-cream, .95), $color 72%);
box-shadow: $shadow-raised;
transform: scale(var(--scale, 1));
transform-origin: center;
}
.kd__knob:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 4px; }
.kd__face {
position: absolute;
inset: 18px;
border-radius: 50%;
border: 2px solid rgba($subject-cream, .5);
}
.kd__face::after {
content: "";
position: absolute;
top: 10px;
left: calc(50% - 3px);
width: 6px;
height: 20px;
border-radius: $r-pill;
background: $subject-cream;
}
.kd__value {
margin: 0;
color: $subject-ink;
font-weight: 800;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.kd__knob { width: 108px; height: 108px; }
.kd__face { inset: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.kd__knob { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kd {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.kd__knob {
position: relative;
appearance: none;
border: none;
border-radius: 50%;
width: 148px;
height: 148px;
cursor: pointer;
background: radial-gradient(circle at 32% 28%, rgba(255, 247, 230, 0.95), #c93000 72%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(var(--scale, 1));
transform-origin: center;
}
.kd__knob:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 4px;
}
.kd__face {
position: absolute;
inset: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 247, 230, 0.5);
}
.kd__face::after {
content: "";
position: absolute;
top: 10px;
left: calc(50% - 3px);
width: 6px;
height: 20px;
border-radius: 999px;
background: #fff7e6;
}
.kd__value {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.kd__knob {
width: 108px;
height: 108px;
}
.kd__face {
inset: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.kd__knob {
transition: none;
}
}(function () {
var SPRING_K = 0.18; // 용수철 상수 — 클수록 목표로 더 빨리 당겨진다
var SPRING_DAMP = 0.72; // 감쇠 — 1에 가까울수록 오래 흔들린다
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('kd');
var knob = document.getElementById('kdKnob');
var valueEl = document.getElementById('kdValue');
var isDemo = true;
var LEVELS = [10, 40, 70, 100];
var levelIdx = 1;
var scaleCur = 1;
var scaleVel = 0;
var scaleTarget = 1;
var pressed = false;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 감쇠 스프링. 매 프레임 목표까지의 거리에 SPRING_K 를 곱해 가속하고,
// 누적 속도에 SPRING_DAMP 를 곱해 서서히 잦아들게 한다(단순 lerp 와 달리 목표를 넘겨 튕긴다).
function springStep() {
if (reduce) { scaleCur = scaleTarget; scaleVel = 0; return; }
var force = (scaleTarget - scaleCur) * SPRING_K;
scaleVel = (scaleVel + force) * SPRING_DAMP;
scaleCur += scaleVel;
}
function apply() {
knob.style.setProperty('--scale', scaleCur.toFixed(4));
valueEl.textContent = '밝기 ' + LEVELS[levelIdx] + '%';
}
function press() {
human();
pressed = true;
scaleTarget = 0.86;
}
function release() {
if (!pressed) return;
pressed = false;
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
scaleVel += reduce ? 0 : 0.03;
setTimeout(function () { scaleTarget = 1; }, reduce ? 0 : 90);
}
knob.addEventListener('pointerdown', press);
knob.addEventListener('pointerup', release);
knob.addEventListener('pointerleave', function () { if (pressed) release(); });
knob.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); release(); }
});
// 자동 루프: 누름 → 뗌 → 스프링 정착을 반복한다. 사람 조작과 같은 springStep()/apply() 를 쓴다.
function loop(ts) {
if (isDemo && !reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.06) { scaleTarget = 0.86; }
else if (phase < 0.12 && scaleTarget !== 1.12) {
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
} else if (phase >= 0.12 && phase < 0.2) { scaleTarget = 1; }
} else if (isDemo && reduce) {
scaleTarget = 1;
}
springStep();
apply();
requestAnimationFrame(loop);
}
apply();
requestAnimationFrame(loop);
})();// SpringKnobDial — vanilla 판(run/374/demo/07-arc-knob-dial/index.html) 의 React 포트.
// 계산 겹: 감쇠 스프링(springStep) — 매 프레임 목표까지의 거리에 SPRING_K 를 곱해 가속하고
// 누적 속도에 SPRING_DAMP 를 곱해 서서히 잦아들게 해 목표를 넘겨 튕기게 한다.
import { useEffect, useRef, useState } from "react";
import styles from "./SpringKnobDial.module.scss";
type Box<T> = { current: T };
const SPRING_K = 0.18;
const SPRING_DAMP = 0.72;
const LOOP_MS = 2000;
const LEVELS = [10, 40, 70, 100];
export default function SpringKnobDial() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const knobRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [level, setLevel] = useState(LEVELS[1]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const knob = knobRef.current;
if (!root || !knob) return;
let isDemo = true;
let levelIdx = 1;
let scaleCur = 1;
let scaleVel = 0;
let scaleTarget = 1;
let pressed = false;
let releaseTimer = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function springStep() {
if (reduce) { scaleCur = scaleTarget; scaleVel = 0; return; }
const force = (scaleTarget - scaleCur) * SPRING_K;
scaleVel = (scaleVel + force) * SPRING_DAMP;
scaleCur += scaleVel;
}
function apply() {
knob!.style.setProperty("--scale", scaleCur.toFixed(4));
setLevel(LEVELS[levelIdx]);
}
function press() { human(); pressed = true; scaleTarget = 0.86; }
function release() {
if (!pressed) return;
pressed = false;
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
scaleVel += reduce ? 0 : 0.03;
releaseTimer = window.setTimeout(function () { scaleTarget = 1; }, reduce ? 0 : 90);
}
function onDown() { press(); }
function onUp() { release(); }
function onLeave() { if (pressed) release(); }
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); press(); release(); }
}
knob.addEventListener("pointerdown", onDown);
knob.addEventListener("pointerup", onUp);
knob.addEventListener("pointerleave", onLeave);
knob.addEventListener("keydown", onKeydown);
function loop(ts: number) {
if (isDemo && !reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.06) { scaleTarget = 0.86; }
else if (phase < 0.12 && scaleTarget !== 1.12) {
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
} else if (phase >= 0.12 && phase < 0.2) { scaleTarget = 1; }
} else if (isDemo && reduce) {
scaleTarget = 1;
}
springStep();
apply();
rafRef.current = requestAnimationFrame(loop);
}
apply();
rafRef.current = requestAnimationFrame(loop);
return () => {
knob.removeEventListener("pointerdown", onDown);
knob.removeEventListener("pointerup", onUp);
knob.removeEventListener("pointerleave", onLeave);
knob.removeEventListener("keydown", onKeydown);
window.clearTimeout(releaseTimer);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.kd + " " + styles.isDemo} ref={rootRef}>
<button className={styles.knob} ref={knobRef} type="button" aria-label="눌러서 밝기 다이얼 튕기기">
<span className={styles.face} />
</button>
<p className={styles.value}>밝기 {level}%</p>
</div>
</main>
);
}07 물리형 노브 다이얼 — 손잡이 자체가 눌리고 튀는 질감의 다이얼 — 측정 누적면적 18.514% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 게이지 바 9종 — 손잡이가 계산으로 도는 원형 컨트롤
이 데모가 속한 글
css 게이지 바 9종 — 손잡이가 계산으로 도는 원형 컨트롤아홉 개의 원형 컨트롤 중 하나는 드래그 각도 값이 관성으로 감쇠하며 손잡이가 서서히 멈추고, 하나는 각 구간 값이 각도로 변환되어 css 게이지 바가 원을 그리며 채워지고, 하나는 스냅 판정 값에 따라 가장 가까운 이 글의 데모 전부 보기


