01A Rubber Track That Gets Heavier to Pull Near the End
When the finger pulls past the track end, the handle follows only as far as a damping curve allows, and a rubber band stretches out past the track end to the handle, growing thinner. On release it returns to the end position and settles. The small track below is the failing version with no limit, where the handle flies past the end.
Best forBase screen that stops a handle from flying off the track
KNEESTRETCHmapDrag

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="On a slide to unlock track the handle follows less and less near the end while a rubber band stretches past the track end, then it returns to the end position on release; the small track below shows the wrong way, with no limit, where the handle flies past the end"></div>
<p class="vj__status" data-role="status" aria-hidden="true">Progress 0%</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 });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw() { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW() { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale() { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent, extra, labelText, topCqw, scale) {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l, p) {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p) { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
// 한 바퀴 길이(초)와 움직임 줄이기에서 몇 초 뒤의 장면을 그릴지
const LOOP_S = 3.6;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 이 진행도 이상으로 놓으면 끝까지 가고, 못 미치면 처음으로 돌아간다
const THRESH = 0.8;
// 고무줄 — 진행도 KNEE 부터 저항이 시작되고 늘어남은 1 + STRETCH 에 점근한다
const KNEE = 0.78;
const STRETCH = 0.2;
// 임계에 못 미쳐 놓았을 때 처음으로 돌아가는 스프링(SPR)과 끝으로 가는 스프링(FIN)의 세기
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 200;
const FIN_C = 16;
// 트랙 끝 바깥으로 늘어나는 띠의 기본 폭(cqw)과 늘어날수록 가늘어지는 정도
const BAND_W = 20;
const BAND_THIN = 6;
const HANDLE_LABEL = 'Unlock handle: slide it right, and pulling past the end stretches it like a rubber band';
let main;
let mini;
let band;
let ring;
let badX = 0;
// 손가락 값 x 를 손잡이 진행도로 — KNEE 까지는 그대로, 그 뒤는 끝에 다가갈수록 점점 덜 따라간다
function mapDrag(x) {
if (x <= KNEE) {
return Math.max(x, 0);
}
const span = 1 + STRETCH - KNEE;
return KNEE + span * (1 - Math.exp(-(x - KNEE) / span));
}
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝을 넘겨 당겼다 놓고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.45, x: ramp(t, [[0.2, 0], [0.75, 0.85], [1.15, 1.3], [1.45, 1.3]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [0.9, 0.55], [1.3, 0.6]]) };
}
function build() {
scene.textContent = '';
capAt(scene, 'A rubber track that gets heavier the more you pull', 11, '');
main = makeLin(scene, 'sl--rubber', 'Slide to unlock', 21, 1);
band = mk('div', 'rub', main.track);
band.style.left = (TRACK_CQW - 4) + 'cqw';
band.style.width = BAND_W + 'cqw';
main.track.insertBefore(band, main.handle);
ring = mk('div', 'fing', main.track);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, 'A track with no limit: the handle flies past the end', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
}
function reset() { badX = 0; }
function tick(h) {
void h;
if (sl.down) {
badX = clamp(sl.fx, 0, 1.3);
}
}
function draw() {
placeHandle(main, sl.p);
const over = Math.max(0, sl.p - 1);
const w = over > 0 ? 4 - PAD_CQW + over * travelCqw() : 0;
band.style.transform = 'scaleX(' + (w / BAND_W).toFixed(3) + ') scaleY(' + (1 / (1 + over * BAND_THIN)).toFixed(3) + ')';
band.style.opacity = over > 0 ? '1' : '0';
// 손가락이 실제로 있는 자리 — 손잡이와의 거리가 곧 저항이다
ring.style.transform = 'translateX(' + (xOf(sl.fx) - 3).toFixed(2) + 'cqw)';
ring.style.opacity = sl.down ? clamp((sl.fx - sl.p - 0.06) / 0.06, 0, 1).toFixed(3) : '0';
placeHandle(mini, badX);
}
function statusText() {
return 'Progress {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
function finalText() { return 'Unlocked'; }
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// RubberTrackResist - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./RubberTrackResist.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function RubberTrackResist() {
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 });
// 직선 트랙 — 손잡이가 움직이는 거리(cqw)는 트랙 폭에서 손잡이 폭과 양쪽 여백을 뺀 값
function travelCqw(): number { return TRACK_CQW - HANDLE_CQW - 2 * PAD_CQW; }
function rootW(): number { return root.clientWidth || 480; }
// 포인터 이동 px 를 진행도로 — 1 이 트랙 끝
function linearScale(): number { return 100 / (travelCqw() * rootW()); }
scaleFn = linearScale;
interface Lin { track: HTMLElement; bg: HTMLElement; fill: HTMLElement; label: HTMLElement; handle: HTMLElement; ico: HTMLElement; }
// 무대 안에서 가로 가운데, 위에서 topCqw 에 트랙을 놓는다. scale 이 1 보다 작으면 같은 좌표계를 줄여 아래에 작게 깐다
function makeLin(parent: HTMLElement, extra: string, labelText: string, topCqw: number, scale: number): Lin {
const track = mk('div', 'sl ' + extra, parent);
track.style.width = TRACK_CQW + 'cqw';
track.style.left = ((100 - TRACK_CQW) / 2).toFixed(2) + 'cqw';
track.style.top = topCqw + 'cqw';
if (scale !== 1) {
track.style.transform = 'scale(' + scale + ')';
track.setAttribute('aria-hidden', 'true');
}
const bg = mk('div', 'sl__bg', track);
const fill = mk('div', 'sl__fill', bg);
const label = mk('div', 'sl__label', bg);
label.textContent = labelText;
const handle = mk('div', 'sl__handle', track);
handle.style.left = PAD_CQW + 'cqw';
handle.style.top = PAD_CQW + 'cqw';
handle.style.width = HANDLE_CQW + 'cqw';
handle.style.height = HANDLE_CQW + 'cqw';
const ico = icon('arrow-right', handle);
return { track: track, bg: bg, fill: fill, label: label, handle: handle, ico: ico };
}
function placeHandle(l: Lin, p: number): void {
l.handle.style.transform = 'translateX(' + (p * travelCqw()).toFixed(3) + 'cqw)';
const end = PAD_CQW + p * travelCqw() + HANDLE_CQW / 2;
l.fill.style.transform = 'translateX(' + (Math.max(end, 0) - TRACK_CQW).toFixed(3) + 'cqw)';
}
// 트랙 안 x(진행도) 의 가운데 가로 위치(cqw, 트랙 왼쪽 끝 기준)
function xOf(p: number): number { return PAD_CQW + p * travelCqw() + HANDLE_CQW / 2; }
// 한 바퀴 길이(초)와 움직임 줄이기에서 몇 초 뒤의 장면을 그릴지
const LOOP_S = 3.6;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
// 이 진행도 이상으로 놓으면 끝까지 가고, 못 미치면 처음으로 돌아간다
const THRESH = 0.8;
// 고무줄 — 진행도 KNEE 부터 저항이 시작되고 늘어남은 1 + STRETCH 에 점근한다
const KNEE = 0.78;
const STRETCH = 0.2;
// 임계에 못 미쳐 놓았을 때 처음으로 돌아가는 스프링(SPR)과 끝으로 가는 스프링(FIN)의 세기
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 200;
const FIN_C = 16;
// 트랙 끝 바깥으로 늘어나는 띠의 기본 폭(cqw)과 늘어날수록 가늘어지는 정도
const BAND_W = 20;
const BAND_THIN = 6;
const HANDLE_LABEL = '잠금 해제 손잡이 — 오른쪽으로 밀기, 끝을 넘겨 당기면 고무줄처럼 늘어남';
let main: Lin;
let mini: Lin;
let band: HTMLElement;
let ring: HTMLElement;
let badX = 0;
// 손가락 값 x 를 손잡이 진행도로 — KNEE 까지는 그대로, 그 뒤는 끝에 다가갈수록 점점 덜 따라간다
function mapDrag(x: number): number {
if (x <= KNEE) { return Math.max(x, 0); }
const span = 1 + STRETCH - KNEE;
return KNEE + span * (1 - Math.exp(-(x - KNEE) / span));
}
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 짝수 바퀴는 끝을 넘겨 당겼다 놓고, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.2 && t < 1.45, x: ramp(t, [[0.2, 0], [0.75, 0.85], [1.15, 1.3], [1.45, 1.3]]) };
}
return { down: t >= 0.2 && t < 1.3, x: ramp(t, [[0.2, 0], [0.9, 0.55], [1.3, 0.6]]) };
}
function build(): void {
scene.textContent = '';
capAt(scene, '당길수록 무거워지는 고무줄 트랙', 11, '');
main = makeLin(scene, 'sl--rubber', '밀어서 잠금 해제', 21, 1);
band = mk('div', 'rub', main.track);
band.style.left = (TRACK_CQW - 4) + 'cqw';
band.style.width = BAND_W + 'cqw';
main.track.insertBefore(band, main.handle);
ring = mk('div', 'fing', main.track);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '한계 없는 트랙 — 손잡이가 끝을 넘어 튄다', 40, 'cap--bad');
mini = makeLin(scene, 'sl--mini sl--bad', '', 46, 0.6);
}
function reset(): void { badX = 0; }
function tick(h: number): void {
void h;
if (sl.down) { badX = clamp(sl.fx, 0, 1.3); }
}
function draw(): void {
placeHandle(main, sl.p);
const over = Math.max(0, sl.p - 1);
const w = over > 0 ? 4 - PAD_CQW + over * travelCqw() : 0;
band.style.transform = 'scaleX(' + (w / BAND_W).toFixed(3) + ') scaleY(' + (1 / (1 + over * BAND_THIN)).toFixed(3) + ')';
band.style.opacity = over > 0 ? '1' : '0';
// 손가락이 실제로 있는 자리 — 손잡이와의 거리가 곧 저항이다
ring.style.transform = 'translateX(' + (xOf(sl.fx) - 3).toFixed(2) + 'cqw)';
ring.style.opacity = sl.down ? clamp((sl.fx - sl.p - 0.06) / 0.06, 0, 1).toFixed(3) : '0';
placeHandle(mini, badX);
}
function statusText(): string {
return '진행도 {n}%'.replace('{n}', String(Math.round(sl.p * 100)));
}
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%'}</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>
);
}01 A Rubber Track That Gets Heavier to Pull Near the End — Measured area 19.147% · moving frames 14/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
Buttons04 Pull-distance stretch — the button's shape value stretches by an amount tied to how far the pointer's distance value has moved away, then returns when releasedButton Press Animation UI: 9 Calculation Patterns
Buttons02 Button that unlocks when the form is valid9 button state css patterns, loading to done
Buttons01 Ink-diffusion Button That Spreads From the Click PointButton Click Effect CSS: 9 Canvas Pixel Patterns