08A Power Slider That Turns Along an Arc and Measures Progress by Angle
The handle moves along a half circle arc and measures progress by its angle from the center, and the fill on the arc rises by the same angle. Releasing past the threshold angle makes the whole arc flash once and the power icon go off.
Best forScreen for risky actions such as power off or emergency stop
THRESH_DEGanglePFLASH_TAU

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="The handle of a power off slider moves along a half circle arc and measures progress by its angle from the center, the arc fill rises by the same angle, and past the threshold angle the whole arc flashes once and the power icon in the middle goes off"></div>
<p class="vj__status" data-role="status" aria-hidden="true">Angle 0° (threshold 144°)</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 밀어서 잠금해제 — 트랙·손잡이·채움은 무대 폭(cqw)에 비례한다. 손잡이 위치와 채움 끝은 JS 가 transform 으로 준다
$ink-soft: rgba($subject-ink, .6);
$card-bg: mix($subject-cream, $subject-lilac, 82%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $card-bg;
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.cap--note { font-size: max(10px, 2.9cqw); font-weight: 600; }
// 트랙 — 폭·왼쪽·위는 JS 가 cqw 로 준다
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini { pointer-events: none; }
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-pill;
background: rgba(255, 255, 255, .82);
box-shadow: inset 0 .6cqw 1.4cqw rgba($subject-ink, .12);
}
// 채움 — 트랙 폭 그대로 두고 JS 가 translateX 로 끝 위치를 민다
.sl__fill {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: rgba($subject-mint, .72);
transform: translateX(-100%);
}
.sl--bad .sl__fill { background: rgba($subject-pink, .85); }
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: $subject-ink;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, .98);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active { cursor: grabbing; }
.sl__handle:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
// 문턱 표시 선 — 트랙 위로 살짝 튀어나온다
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: .5cqw;
margin-left: -.25cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
// 01 — 트랙 끝 바깥으로 늘어나는 고무줄 띠. 늘어날수록 가늘어진다
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: $r-pill;
background: linear-gradient(90deg, rgba($subject-pink, .95), rgba($subject-pink, .55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
// 손가락이 실제로 있는 자리 — 손잡이와 떨어진 거리가 저항의 크기
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
// 02 — 예상 도착점 점선 잔상
.ghost {
position: absolute;
box-sizing: border-box;
border: .6cqw dashed $subject-ink;
border-radius: 50%;
background: rgba($subject-pink, .4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit { background: rgba($subject-mint, .5); }
// 04 — 완료 때 손잡이 둘레로 한 번 퍼지는 링
.pulse {
position: absolute;
border-radius: 50%;
background: $subject-mint;
opacity: 0;
pointer-events: none;
}
// 05 — 글자 하나하나가 광택과 손잡이 위치에 따라 따로 투명해진다
.sl--shimmer .sl__bg { background: mix(#ffffff, $subject-lilac, 55%); }
.sl--shimmer .sl__label { white-space: pre; }
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
// 06 — 완료 뒤 도는 로딩 링
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: .9cqw solid rgba($subject-ink, .15);
border-top-color: $subject-ink;
border-radius: 50%;
opacity: 0;
}
// 07 — 쉐브론 세 개. 위치는 JS 가 왼쪽(cqw)으로 준다
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: $subject-ink;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
// 08 — 반원 호. 선 굵기는 viewBox 단위
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track { stroke: rgba(255, 255, 255, .88); stroke-width: 7; }
.arc__fill { stroke: $subject-mint; stroke-width: 7; stroke-dasharray: 0 95; }
.arc__fill.is-off { stroke: $subject-pink; }
.arc__flash { stroke: #ffffff; stroke-width: 11; opacity: 0; }
.arc__mark { stroke: $subject-ink; stroke-width: .7; opacity: .5; }
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: $subject-ink;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: $subject-ink;
}
// 09 — 잠금 화면. 풍경은 JS 가 진행도로 흐림을 준다
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, mix($subject-sky, #ffffff, 55%), mix($subject-cream, $subject-pink, 62%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: mix($subject-cream, $subject-pink, 50%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far { top: 58%; bottom: -60%; background: mix($subject-mint, #ffffff, 55%); }
.lock__hill--near { top: 72%; bottom: -60%; background: mix($subject-mint, $subject-lilac, 70%); }
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: $subject-ink;
text-shadow: 0 0 2cqw rgba(255, 255, 255, .85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: $r-control;
background: rgba(255, 255, 255, .88);
box-shadow: $shadow-press;
color: $subject-ink;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg { background: rgba(255, 255, 255, .8); }
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
z-index: 2;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden), 값은 손잡이의 aria-valuetext 가 읽어 준다
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 완료한 최종 문장을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 트윈 없이 잠금이 풀린 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
}
.cap {
position: absolute;
left: 0;
right: 0;
padding: 0 4cqw;
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.cap--note {
font-size: max(10px, 2.9cqw);
font-weight: 600;
}
.sl {
position: absolute;
height: 15cqw;
}
.sl--mini {
pointer-events: none;
}
.sl__bg {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
box-shadow: inset 0 0.6cqw 1.4cqw rgba(23, 20, 26, 0.12);
}
.sl__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(76, 212, 166, 0.72);
transform: translateX(-100%);
}
.sl--bad .sl__fill {
background: rgba(255, 158, 187, 0.85);
}
.sl__label {
position: absolute;
left: 15cqw;
right: 2cqw;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 4.2cqw;
font-weight: 800;
line-height: 1;
white-space: nowrap;
color: #17141a;
}
.sl__handle {
position: absolute;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 7cqw;
cursor: grab;
touch-action: none;
}
.sl__handle:active {
cursor: grabbing;
}
.sl__handle:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.sl__handle--arc {
left: 0;
top: 0;
}
.ico {
display: grid;
place-items: center;
line-height: 0;
}
.ico svg {
display: block;
width: 1em;
height: 1em;
}
.sl__handle .ico--check-circle {
position: absolute;
inset: 0;
opacity: 0;
}
.sl__mark {
position: absolute;
top: 0;
bottom: 0;
width: 0.5cqw;
margin-left: -0.25cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.38);
}
.sl > .sl__mark {
top: -1.6cqw;
bottom: -1.6cqw;
}
.rub {
position: absolute;
top: 1.25cqw;
height: 12.5cqw;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 158, 187, 0.95), rgba(255, 158, 187, 0.55));
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
}
.fing {
position: absolute;
left: 0;
top: 4.5cqw;
width: 6cqw;
height: 6cqw;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
opacity: 0;
pointer-events: none;
}
.ghost {
position: absolute;
box-sizing: border-box;
border: 0.6cqw dashed #17141a;
border-radius: 50%;
background: rgba(255, 158, 187, 0.4);
opacity: 0;
pointer-events: none;
}
.ghost.is-hit {
background: rgba(76, 212, 166, 0.5);
}
.pulse {
position: absolute;
border-radius: 50%;
background: #4cd4a6;
opacity: 0;
pointer-events: none;
}
.sl--shimmer .sl__bg {
background: rgb(87.6470588235%, 83.2352941176%, 100%);
}
.sl--shimmer .sl__label {
white-space: pre;
}
.ch {
display: inline-block;
}
.sheen {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 20cqw;
background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
opacity: 0;
pointer-events: none;
}
.spin {
position: absolute;
inset: 20%;
box-sizing: border-box;
border: 0.9cqw solid rgba(23, 20, 26, 0.15);
border-top-color: #17141a;
border-radius: 50%;
opacity: 0;
}
.sl--hint .ico--caret-right {
position: absolute;
top: 50%;
margin-top: -3.5cqw;
font-size: 7cqw;
color: #17141a;
}
.sl--hint .sl__label {
justify-content: flex-end;
padding-right: 1cqw;
font-size: 3.8cqw;
}
.arc {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.arc__track,
.arc__fill,
.arc__flash {
fill: none;
stroke-linecap: round;
}
.arc__track {
stroke: rgba(255, 255, 255, 0.88);
stroke-width: 7;
}
.arc__fill {
stroke: #4cd4a6;
stroke-width: 7;
stroke-dasharray: 0 95;
}
.arc__fill.is-off {
stroke: #ff9ebb;
}
.arc__flash {
stroke: #ffffff;
stroke-width: 11;
opacity: 0;
}
.arc__mark {
stroke: #17141a;
stroke-width: 0.7;
opacity: 0.5;
}
.pw {
position: absolute;
left: 50%;
top: 31cqw;
margin-left: -7.5cqw;
font-size: 15cqw;
line-height: 0;
color: #17141a;
}
.pw__label {
position: absolute;
left: 0;
right: 0;
top: 47cqw;
text-align: center;
font-size: 3.6cqw;
font-weight: 800;
color: #17141a;
}
.lock {
position: absolute;
inset: 0;
overflow: hidden;
}
.lock__img {
position: absolute;
inset: -3cqw;
background: linear-gradient(180deg, rgb(68.7254901961%, 88.137254902%, 100%), rgb(100%, 83.6%, 83.7882352941%));
}
.lock__sun {
position: absolute;
left: 66%;
top: 26%;
width: 14cqw;
height: 14cqw;
border-radius: 50%;
background: rgb(100%, 79.4117647059%, 81.7647058824%);
}
.lock__hill {
position: absolute;
left: -10%;
right: -10%;
border-radius: 50%;
}
.lock__hill--far {
top: 58%;
bottom: -60%;
background: rgb(61.3921568627%, 90.7254901961%, 80.8039215686%);
}
.lock__hill--near {
top: 72%;
bottom: -60%;
background: rgb(42.6274509804%, 77.0196078431%, 75.568627451%);
}
.lock__clock {
position: absolute;
left: 0;
right: 0;
top: 13cqw;
text-align: center;
font-size: 17cqw;
font-weight: 800;
line-height: 1;
color: #17141a;
text-shadow: 0 0 2cqw rgba(255, 255, 255, 0.85);
transform-origin: 50% 0;
}
.lock__card {
position: absolute;
left: 22cqw;
right: 22cqw;
height: 7cqw;
display: flex;
align-items: center;
padding: 0 3cqw;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
font-size: 3.2cqw;
font-weight: 700;
}
.sl--lock .sl__bg {
background: rgba(255, 255, 255, 0.8);
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
z-index: 2;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
z-index: 2;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
const ICON = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t, pts) {
if (t <= pts[0][0]) {
return pts[0][1];
}
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) {
return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0]));
}
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
function capAt(parent, text, topCqw, extra) {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name, parent) {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
const sl = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn = function (e) { return e.clientX; };
let scaleFn = function () { return 0.01; };
let handleEl = null;
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled() { return sl.mode === M_DONE; }
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset() {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
sl.down = false;
sl.fx = 0;
sl.vEma = 0;
sl.doneT = 0;
sl.ok = false;
sl.relP = 0;
sl.relV = 0;
sl.relT = -1;
sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release() {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h) {
const f = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true;
sl.mode = M_DRAG;
sl.vEma = 0;
sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = true;
sl.v = sl.vEma;
sl.mode = M_FIN;
}
}
else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) {
sl.p = 0;
sl.v = 0;
sl.mode = M_IDLE;
}
}
else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) {
sl.p = 1;
sl.v = 0;
sl.mode = M_DONE;
sl.doneT = 0;
}
}
else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) {
fullReset();
}
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt) {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint() {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) {
handleEl.setAttribute('aria-valuenow', String(pc));
shownPc = pc;
}
if (txt !== shownText) {
handleEl.setAttribute('aria-valuetext', txt);
shownText = txt;
}
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest() {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
sl.ok = true;
paint();
}
function play() {
sceneT = 0;
fullReset();
paint();
if (!paused) {
startLoop();
}
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake() {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) {
sl.down = false;
sl.mode = M_IDLE;
sl.v = 0;
}
}
if (!still) {
startLoop();
}
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow() {
if (sl.ok) {
sl.p = 1;
sl.mode = M_DONE;
sl.doneT = 9;
}
else {
sl.p = 0;
sl.mode = M_IDLE;
}
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d) {
if (sl.mode >= M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.v = 0;
sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
}
}
else {
paint();
}
}
function toStart() {
if (still || sl.mode === M_DONE) {
fullReset();
paint();
return;
}
sl.down = false;
sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow() {
if (sl.mode === M_DONE) {
fullReset();
paint();
return;
}
if (sl.mode === M_FIN) {
return;
}
sl.down = false;
sl.ptrDown = false;
sl.ok = true;
sl.relT = sceneT;
if (still) {
settleNow();
}
else {
sl.mode = M_FIN;
sl.v = 0;
}
}
function wireHandle(h, label) {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'mouse' && e.button !== 0) {
return;
}
wake();
if (still && sl.mode === M_DONE) {
fullReset();
}
if (sl.mode >= M_FIN) {
return;
}
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) {
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
h.addEventListener('pointermove', function (e) {
if (!sl.ptrDown) {
return;
}
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) {
sl.p = clamp(mapDrag(sl.ptrX), 0, 1);
sl.mode = M_DRAG;
paint();
}
}, { signal: off.signal });
const up = function () {
if (!sl.ptrDown) {
return;
}
sl.ptrDown = false;
if (still) {
sl.ok = verdict(sl.p, 0);
settleNow();
}
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
wake();
nudge(KEY_STEP);
}
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
wake();
nudge(-KEY_STEP);
}
else if (e.key === 'Home') {
wake();
toStart();
}
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') {
wake();
completeNow();
}
else {
hit = false;
}
if (hit) {
e.preventDefault();
}
}, { signal: off.signal });
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
const LOOP_S = 3.8;
const REST_T = 3.0;
// 호 — 무대 100 x 62.5 단위(cqw) 기준 중심, 반지름, 손잡이 지름
const CX = 50;
const CY = 53;
const R = 30;
const HANDLE_CQW = 12.5;
// 임계 각도 — 시작(왼쪽 끝)에서 이만큼 돌아가면 완료. 호는 반원(180도)
const THRESH_DEG = 144;
const THRESH = THRESH_DEG / 180;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 완료 순간 호 전체가 번쩍이는 시간 상수(초)
const FLASH_TAU = 0.18;
const HANDLE_LABEL = 'Power off handle: turn it right along the arc to switch off';
const SVG_NS = 'http://www.w3.org/2000/svg';
const ARC_LEN = Math.PI * R;
let handle;
let power;
let powerLabel;
let fillArc;
let flashArc;
let arrow;
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 호 위 한 점 — 진행도 p 는 왼쪽 끝(180도)에서 오른쪽 끝(0도)까지, 중심에서의 각도 θ = π(1 − p)
function arcX(p) { return CX + R * Math.cos(Math.PI * (1 - p)); }
function arcY(p) { return CY - R * Math.sin(Math.PI * (1 - p)); }
// 포인터 위치 → 진행도 — 중심에서 본 각도를 재서 극좌표로 바꾼다. 호 아래쪽은 가까운 끝으로 붙인다
function angleP(e) {
const r = scene.getBoundingClientRect();
const ctrX = r.left + r.width * CX / 100;
const ctrY = r.top + r.height * CY / 62.5;
let a = Math.atan2(ctrY - e.clientY, e.clientX - ctrX);
if (a < -Math.PI / 2) {
a = Math.PI;
}
else if (a < 0) {
a = 0;
}
return 1 - a / Math.PI;
}
// 시연 손가락 — 짝수 바퀴는 임계 각도를 넘겨 놓아 끄고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.92]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
function svgPath(parent, name) {
const e = document.createElementNS(SVG_NS, 'path');
e.setAttribute('class', cls(name));
e.setAttribute('d', 'M ' + (CX - R) + ' ' + CY + ' A ' + R + ' ' + R + ' 0 0 1 ' + (CX + R) + ' ' + CY);
parent.appendChild(e);
return e;
}
function build() {
scene.textContent = '';
rawFn = angleP;
scaleFn = function () { return 1; };
capAt(scene, 'Progress is measured by angle on an arc (threshold {a} degrees)'.replace('{a}', String(THRESH_DEG)), 11, '');
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', cls('arc'));
svg.setAttribute('viewBox', '0 0 100 62.5');
svg.setAttribute('aria-hidden', 'true');
scene.appendChild(svg);
svgPath(svg, 'arc__track');
fillArc = svgPath(svg, 'arc__fill');
flashArc = svgPath(svg, 'arc__flash');
const mark = document.createElementNS(SVG_NS, 'line');
const tx = Math.cos(Math.PI * (1 - THRESH));
const ty = Math.sin(Math.PI * (1 - THRESH));
mark.setAttribute('class', cls('arc__mark'));
mark.setAttribute('x1', String(CX + (R - 6.5) * tx));
mark.setAttribute('y1', String(CY - (R - 6.5) * ty));
mark.setAttribute('x2', String(CX + (R + 6.5) * tx));
mark.setAttribute('y2', String(CY - (R + 6.5) * ty));
svg.appendChild(mark);
power = mk('div', 'pw', scene);
icon('power', power);
powerLabel = mk('div', 'pw__label', scene);
handle = mk('div', 'sl__handle sl__handle--arc', scene);
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
arrow = icon('caret-right', handle);
wireHandle(handle, HANDLE_LABEL);
}
function reset() { void 0; }
function tick(h) { void h; }
function draw() {
const p = sl.p;
handle.style.transform = 'translate(' + (arcX(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw,' + (arcY(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw)';
// 손잡이 안 화살표는 호의 접선 방향을 본다 — 왼쪽 끝에서는 위, 꼭대기에서는 오른쪽, 오른쪽 끝에서는 아래
arrow.style.transform = 'rotate(' + ((p - 0.5) * 180).toFixed(1) + 'deg)';
const shown = clamp(p, 0, 1);
fillArc.style.strokeDasharray = (ARC_LEN * shown).toFixed(2) + ' ' + ARC_LEN.toFixed(2);
const off = sl.mode === M_DONE;
const t = off ? sl.doneT : 0;
flashArc.style.opacity = off ? (0.9 * Math.exp(-t / FLASH_TAU)).toFixed(3) : '0';
fillArc.classList.toggle(cls('is-off'), off);
// 가운데 전원 아이콘은 진행도가 반을 넘기면서 옅어지고, 꺼진 뒤에는 가장 옅다
const dim = easeInOut((shown - 0.5) / 0.5);
power.style.opacity = (1 - 0.65 * dim).toFixed(3);
const txt = off ? 'Power off' : 'Power on';
if (powerLabel.textContent !== txt) {
powerLabel.textContent = txt;
}
}
function statusText() {
return 'Angle {a}° (threshold {t}°)'.replace('{a}', String(Math.round(clamp(sl.p, 0, 1) * 180))).replace('{t}', String(THRESH_DEG));
}
function finalText() { return 'Power is off'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ArcPowerSlider - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ArcPowerSlider.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ArcPowerSlider() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
const ICON: { [k: string]: string } = {
'arrow-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>',
'caret-right': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>',
'check-circle': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg>',
'lock-simple-open': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80H96V56a32 32 0 0 1 32-32c15.37 0 29.2 11 32.16 25.59a8 8 0 0 0 15.68-3.18C171.32 24.15 151.2 8 128 8a48.05 48.05 0 0 0-48 48v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16m0 128H48V96h160z"/></g></svg>',
'lock-simple': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160z"/></g></svg>',
'power': '<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M120 128V48a8 8 0 0 1 16 0v80a8 8 0 0 1-16 0m60.37-78.7a8 8 0 0 0-8.74 13.4C194.74 77.77 208 101.57 208 128a80 80 0 0 1-160 0c0-26.43 13.26-50.23 36.37-65.3a8 8 0 0 0-8.74-13.4C47.9 67.38 32 96.06 32 128a96 96 0 0 0 192 0c0-31.94-15.9-60.62-43.63-78.7"/></g></svg>'
};
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 꺾은선 — [시각, 값] 쌍 사이를 직선으로 잇고 양끝 바깥은 끝 값을 그대로 쓴다
function ramp(t: number, pts: number[][]): number {
if (t <= pts[0][0]) { return pts[0][1]; }
for (let i = 1; i < pts.length; i += 1) {
if (t <= pts[i][0]) { return lerp(pts[i - 1][1], pts[i][1], (t - pts[i - 1][0]) / (pts[i][0] - pts[i - 1][0])); }
}
return pts[pts.length - 1][1];
}
// 장면 시계(초)와 몇 번째 바퀴인가 — 바퀴마다 시연 손가락의 움직임이 달라져 완료와 되돌림이 번갈아 나온다
let sceneT = 0;
let cycle = 0;
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
function capAt(parent: HTMLElement, text: string, topCqw: number, extra: string): HTMLElement {
const c = mk('div', 'cap ' + extra, parent);
c.textContent = text;
c.style.top = topCqw + 'cqw';
return c;
}
function icon(name: string, parent: HTMLElement): HTMLElement {
const s = mk('span', 'ico ico--' + name, parent);
s.innerHTML = ICON[name];
return s;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 손잡이 상태 — p 는 진행도(0 시작, 1 끝. 고무줄 늘어남은 1 초과, 스프링 오버슈트는 0 미만), v 는 진행도/초
const M_IDLE = 0;
const M_DRAG = 1;
const M_BACK = 2;
const M_FIN = 3;
const M_DONE = 4;
interface Finger { down: boolean; x: number; }
interface Slide {
p: number; v: number; mode: number; down: boolean; fx: number; vEma: number; doneT: number;
ok: boolean; relP: number; relV: number; relT: number;
ptrDown: boolean; ptrX: number; ptrStart: number; ptrP0: number;
}
const sl: Slide = {
p: 0, v: 0, mode: M_IDLE, down: false, fx: 0, vEma: 0, doneT: 0,
ok: false, relP: 0, relV: 0, relT: -1,
ptrDown: false, ptrX: 0, ptrStart: 0, ptrP0: 0
};
// 손가락 값을 손잡이가 따라가는 빠르기(1/초)와 속도 추정의 평활 빠르기(1/초)
const FOLLOW = 40;
const VEL_SMOOTH = 20;
// 사람이 만진 장면이 완료된 뒤 처음으로 되돌리기까지(초)
const DONE_HOLD_S = 1.8;
// 방향키 한 번의 진행도 간격
const KEY_STEP = 0.1;
// 임계를 넘는 순간 놓지 않아도 자동 완료하는 장면만 켠다
let autoCross = false;
// 포인터 원시값(직선 트랙은 clientX, 호 슬라이더는 각도)과 진행도 환산 배율
let rawFn: (e: PointerEvent) => number = function (e: PointerEvent): number { return e.clientX; };
let scaleFn: () => number = function (): number { return 0.01; };
let handleEl: HTMLElement | null = null;
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let wasSettled = false;
function isSettled(): boolean { return sl.mode === M_DONE; }
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function fullReset(): void {
sl.p = 0; sl.v = 0; sl.mode = M_IDLE; sl.down = false; sl.fx = 0; sl.vEma = 0; sl.doneT = 0;
sl.ok = false; sl.relP = 0; sl.relV = 0; sl.relT = -1; sl.ptrDown = false;
wasSettled = false;
liveEl.textContent = '';
reset();
}
function release(): void {
sl.relP = sl.p;
sl.relV = sl.vEma;
sl.relT = sceneT;
sl.ok = verdict(sl.p, sl.vEma);
sl.v = sl.vEma;
sl.mode = sl.ok ? M_FIN : M_BACK;
}
function stepPhysics(h: number): void {
const f: Finger = demo ? fingerAt(sceneT, cycle) : { down: sl.ptrDown, x: sl.ptrX };
sl.fx = f.x;
if (f.down && !sl.down && sl.mode < M_FIN) {
sl.down = true; sl.mode = M_DRAG; sl.vEma = 0; sl.v = 0;
}
if (!f.down && sl.down) {
sl.down = false;
release();
}
if (sl.mode === M_DRAG) {
const old = sl.p;
sl.p += (mapDrag(f.x) - sl.p) * (1 - Math.exp(-h * FOLLOW));
sl.vEma += ((sl.p - old) / h - sl.vEma) * (1 - Math.exp(-h * VEL_SMOOTH));
if (autoCross && sl.p >= THRESH) {
sl.down = false;
sl.relP = sl.p; sl.relV = sl.vEma; sl.relT = sceneT;
sl.ok = true; sl.v = sl.vEma; sl.mode = M_FIN;
}
} else if (sl.mode === M_BACK) {
sl.v += (-SPR_K * sl.p - SPR_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(sl.p) < 0.002 && Math.abs(sl.v) < 0.03) { sl.p = 0; sl.v = 0; sl.mode = M_IDLE; }
} else if (sl.mode === M_FIN) {
sl.v += (FIN_K * (1 - sl.p) - FIN_C * sl.v) * h;
sl.p += sl.v * h;
if (Math.abs(1 - sl.p) < 0.002 && Math.abs(sl.v) < 0.05) { sl.p = 1; sl.v = 0; sl.mode = M_DONE; sl.doneT = 0; }
} else if (sl.mode === M_DONE) {
sl.doneT += h;
if (!demo && sl.doneT > DONE_HOLD_S) { fullReset(); }
}
}
// 한 프레임을 1/240초 이하 조각으로 쪼개 쌓는다 — 프레임 간격이 달라도 스프링이 같은 곡선을 그린다
function update(dt: number): void {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
stepPhysics(h);
tick(h);
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (demo && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
fullReset();
}
update(dt);
}
let shownText = '';
let shownPc = -1;
function paint(): void {
draw();
const txt = statusText();
const pc = Math.round(clamp(sl.p, 0, 1) * 100);
if (handleEl) {
if (pc !== shownPc) { handleEl.setAttribute('aria-valuenow', String(pc)); shownPc = pc; }
if (txt !== shownText) { handleEl.setAttribute('aria-valuetext', txt); shownText = txt; }
}
pushStatus(txt);
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 트윈 없이 잠금이 풀린 한 장
function showRest(): void {
stopLoop();
sceneT = REST_T;
fullReset();
sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; sl.ok = true;
paint();
}
function play(): void {
sceneT = 0;
fullReset();
paint();
if (!paused) { startLoop(); }
}
// 사람이 처음 손대면 시연을 끄고 알림 영역을 켠다. 멈춰 있었다면 다시 돌린다
function wake(): void {
if (paused) {
paused = false;
pauseBtn.setAttribute('aria-pressed', 'false');
}
if (demo) {
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
// 시연 손가락이 누르고 있었다면 떼고, 손잡이는 있던 자리에서 이어받는다
if (sl.down) { sl.down = false; sl.mode = M_IDLE; sl.v = 0; }
}
if (!still) { startLoop(); }
}
// 움직임 줄이기에서의 최종 상태 — 임계 판정에 따라 풀린 한 장 또는 처음 자리
function settleNow(): void {
if (sl.ok) { sl.p = 1; sl.mode = M_DONE; sl.doneT = 9; } else { sl.p = 0; sl.mode = M_IDLE; }
sl.v = 0;
sl.down = false;
paint();
}
// 키보드 — 방향키로 한 칸씩, Home 은 처음으로, End·Enter·Space 는 끝까지(완료 뒤에 누르면 처음으로)
function nudge(d: number): void {
if (sl.mode >= M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.v = 0; sl.mode = M_IDLE;
sl.p = clamp(sl.p + d, 0, 1);
if (sl.p >= THRESH) {
sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; }
} else { paint(); }
}
function toStart(): void {
if (still || sl.mode === M_DONE) { fullReset(); paint(); return; }
sl.down = false; sl.ptrDown = false;
sl.mode = sl.p > 0.002 ? M_BACK : M_IDLE;
}
function completeNow(): void {
if (sl.mode === M_DONE) { fullReset(); paint(); return; }
if (sl.mode === M_FIN) { return; }
sl.down = false; sl.ptrDown = false; sl.ok = true; sl.relT = sceneT;
if (still) { settleNow(); } else { sl.mode = M_FIN; sl.v = 0; }
}
function wireHandle(h: HTMLElement, label: string): void {
handleEl = h;
h.setAttribute('role', 'slider');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-label', label);
h.setAttribute('aria-valuemin', '0');
h.setAttribute('aria-valuemax', '100');
h.setAttribute('aria-valuenow', '0');
h.addEventListener('pointerdown', function (e: PointerEvent): void {
if (e.pointerType === 'mouse' && e.button !== 0) { return; }
wake();
if (still && sl.mode === M_DONE) { fullReset(); }
if (sl.mode >= M_FIN) { return; }
e.preventDefault();
h.setPointerCapture(e.pointerId);
h.focus({ preventScroll: true });
sl.ptrDown = true;
sl.ptrStart = rawFn(e);
sl.ptrP0 = clamp(sl.p, 0, 1);
sl.ptrX = sl.ptrP0;
sl.down = false;
if (still) { sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
h.addEventListener('pointermove', function (e: PointerEvent): void {
if (!sl.ptrDown) { return; }
sl.ptrX = sl.ptrP0 + (rawFn(e) - sl.ptrStart) * scaleFn();
if (still) { sl.p = clamp(mapDrag(sl.ptrX), 0, 1); sl.mode = M_DRAG; paint(); }
}, { signal: off.signal });
const up = function (): void {
if (!sl.ptrDown) { return; }
sl.ptrDown = false;
if (still) { sl.ok = verdict(sl.p, 0); settleNow(); }
};
h.addEventListener('pointerup', up, { signal: off.signal });
h.addEventListener('pointercancel', up, { signal: off.signal });
h.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
let hit = true;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { wake(); nudge(KEY_STEP); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { wake(); nudge(-KEY_STEP); }
else if (e.key === 'Home') { wake(); toStart(); }
else if (e.key === 'End' || e.key === 'Enter' || e.key === ' ') { wake(); completeNow(); }
else { hit = false; }
if (hit) { e.preventDefault(); }
}, { signal: off.signal });
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 풀린 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
const LOOP_S = 3.8;
const REST_T = 3.0;
// 호 — 무대 100 x 62.5 단위(cqw) 기준 중심, 반지름, 손잡이 지름
const CX = 50;
const CY = 53;
const R = 30;
const HANDLE_CQW = 12.5;
// 임계 각도 — 시작(왼쪽 끝)에서 이만큼 돌아가면 완료. 호는 반원(180도)
const THRESH_DEG = 144;
const THRESH = THRESH_DEG / 180;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 완료 순간 호 전체가 번쩍이는 시간 상수(초)
const FLASH_TAU = 0.18;
const HANDLE_LABEL = '전원 끄기 손잡이 — 호를 따라 오른쪽으로 돌려 끄기';
const SVG_NS = 'http://www.w3.org/2000/svg';
const ARC_LEN = Math.PI * R;
let handle: HTMLElement;
let power: HTMLElement;
let powerLabel: HTMLElement;
let fillArc: SVGPathElement;
let flashArc: SVGPathElement;
let arrow: HTMLElement;
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 호 위 한 점 — 진행도 p 는 왼쪽 끝(180도)에서 오른쪽 끝(0도)까지, 중심에서의 각도 θ = π(1 − p)
function arcX(p: number): number { return CX + R * Math.cos(Math.PI * (1 - p)); }
function arcY(p: number): number { return CY - R * Math.sin(Math.PI * (1 - p)); }
// 포인터 위치 → 진행도 — 중심에서 본 각도를 재서 극좌표로 바꾼다. 호 아래쪽은 가까운 끝으로 붙인다
function angleP(e: PointerEvent): number {
const r = scene.getBoundingClientRect();
const ctrX = r.left + r.width * CX / 100;
const ctrY = r.top + r.height * CY / 62.5;
let a = Math.atan2(ctrY - e.clientY, e.clientX - ctrX);
if (a < -Math.PI / 2) { a = Math.PI; } else if (a < 0) { a = 0; }
return 1 - a / Math.PI;
}
// 시연 손가락 — 짝수 바퀴는 임계 각도를 넘겨 놓아 끄고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.92]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [1.2, 0.5]]) };
}
function svgPath(parent: Element, name: string): SVGPathElement {
const e = document.createElementNS(SVG_NS, 'path');
e.setAttribute('class', cls(name));
e.setAttribute('d', 'M ' + (CX - R) + ' ' + CY + ' A ' + R + ' ' + R + ' 0 0 1 ' + (CX + R) + ' ' + CY);
parent.appendChild(e);
return e;
}
function build(): void {
scene.textContent = '';
rawFn = angleP;
scaleFn = function (): number { return 1; };
capAt(scene, '호 위에서 각도로 진행도를 잰다 (임계 {a}°)'.replace('{a}', String(THRESH_DEG)), 11, '');
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', cls('arc'));
svg.setAttribute('viewBox', '0 0 100 62.5');
svg.setAttribute('aria-hidden', 'true');
scene.appendChild(svg);
svgPath(svg, 'arc__track');
fillArc = svgPath(svg, 'arc__fill');
flashArc = svgPath(svg, 'arc__flash');
const mark = document.createElementNS(SVG_NS, 'line');
const tx = Math.cos(Math.PI * (1 - THRESH));
const ty = Math.sin(Math.PI * (1 - THRESH));
mark.setAttribute('class', cls('arc__mark'));
mark.setAttribute('x1', String(CX + (R - 6.5) * tx));
mark.setAttribute('y1', String(CY - (R - 6.5) * ty));
mark.setAttribute('x2', String(CX + (R + 6.5) * tx));
mark.setAttribute('y2', String(CY - (R + 6.5) * ty));
svg.appendChild(mark);
power = mk('div', 'pw', scene);
icon('power', power);
powerLabel = mk('div', 'pw__label', scene);
handle = mk('div', 'sl__handle sl__handle--arc', scene);
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
arrow = icon('caret-right', handle);
wireHandle(handle, HANDLE_LABEL);
}
function reset(): void { void 0; }
function tick(h: number): void { void h; }
function draw(): void {
const p = sl.p;
handle.style.transform = 'translate(' + (arcX(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw,' + (arcY(p) - HANDLE_CQW / 2).toFixed(3) + 'cqw)';
// 손잡이 안 화살표는 호의 접선 방향을 본다 — 왼쪽 끝에서는 위, 꼭대기에서는 오른쪽, 오른쪽 끝에서는 아래
arrow.style.transform = 'rotate(' + ((p - 0.5) * 180).toFixed(1) + 'deg)';
const shown = clamp(p, 0, 1);
fillArc.style.strokeDasharray = (ARC_LEN * shown).toFixed(2) + ' ' + ARC_LEN.toFixed(2);
const off = sl.mode === M_DONE;
const t = off ? sl.doneT : 0;
flashArc.style.opacity = off ? (0.9 * Math.exp(-t / FLASH_TAU)).toFixed(3) : '0';
fillArc.classList.toggle(cls('is-off'), off);
// 가운데 전원 아이콘은 진행도가 반을 넘기면서 옅어지고, 꺼진 뒤에는 가장 옅다
const dim = easeInOut((shown - 0.5) / 0.5);
power.style.opacity = (1 - 0.65 * dim).toFixed(3);
const txt = off ? '전원 꺼짐' : '전원 켜짐';
if (powerLabel.textContent !== txt) { powerLabel.textContent = txt; }
}
function statusText(): string {
return '각도 {a}° (임계 {t}°)'.replace('{a}', String(Math.round(clamp(sl.p, 0, 1) * 180))).replace('{t}', String(THRESH_DEG));
}
function finalText(): string { return '전원이 꺼졌습니다'; }
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="group" aria-label={'전원 끄기 슬라이더의 손잡이가 반원 호를 따라 움직이며 중심에서의 각도로 진행도를 재고 호 안쪽 채움이 같은 각도만큼 차오르며 임계 각도를 넘기면 호 전체가 한 번 번쩍이고 가운데 전원 아이콘이 꺼진다'}></div>
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'각도 0° (임계 144°)'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 A Power Slider That Turns Along an Arc and Measures Progress by Angle — Measured area 19.829% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Slide to Unlock Button: 9 Slider Scenes
The post this demo lives in
Slide to Unlock Button: 9 Slider ScenesIn a slide to unlock button the release decides everything: 02 adds speed to a short release, 03 springs back when it fa See all demos in this post
If you like this effect - similar demos
Buttons01 A Send Button That Folds Into a Paper Plane and Banks Along a CurveSend Button Animation CSS: 9 Fold-and-Fly Buttons
Buttons01 Speed dial that turns 45 degrees to hand out actions9 Icon Button CSS Patterns — Press, Drag, Expand
Buttons01 Accordion-reveal value toggle — when the button's open value changes, its rounding and shadow values change along, presenting the content belowExpanding Button UI: 9 Shape-Morph Patterns