05A Guide Label That Fades Where the Handle Passes While a Shimmer Sweeps It
While idle, a bright band sweeps the letters from left to right, and once the handle moves, the letters it has passed turn transparent in proportion to progress. The sweep time (SHIM_PERIOD) and the width over which letters vanish (FADE_W) are tuned separately.
Best forMicrocopy effect that makes a button hint stand out
SHIM_PERIODSHIM_WFADE_W

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="group" aria-label="A bright band sweeps the guide text of a slide to unlock track from left to right, and once the handle moves the letters it has passed turn transparent in proportion to progress"></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.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 광택 띠 — 한 번 훑는 시간(초), 띠의 폭(글줄 길이에 대한 비율), 지나간 글자가 투명해지는 폭(cqw)
const SHIM_PERIOD = 1.6;
const SHIM_W = 0.3;
const FADE_W = 12;
// 글자가 가만히 있을 때의 불투명도 — 광택이 닿으면 1 까지 올라간다
const BASE_ALPHA = 0.8;
const LABEL_TEXT = 'Slide to unlock';
const HANDLE_LABEL = 'Unlock handle: a slider whose passed letters fade out';
let main;
let sheen;
let chars = [];
// 글자마다 트랙 안 가운데 위치(cqw)와 글줄의 왼쪽 끝·길이(cqw) — 처음 그릴 때 한 번, 폭이 바뀌면 다시 잰다
let charX = [];
let lineL = 0;
let lineW = 1;
let measuredW = 0;
function mapDrag(x) { return clamp(x, 0, 1); }
function verdict(p, v) { void v; return p >= THRESH; }
// 시연 손가락 — 광택 띠가 훑는 모습을 보여 준 뒤 0.35초에 누르고, 짝수 바퀴는 끝까지, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t, c) {
if (c % 2 === 0) {
return { down: t >= 0.35 && t < 1.6, x: ramp(t, [[0.35, 0], [0.75, 0.12], [1.2, 0.75], [1.45, 0.9]]) };
}
return { down: t >= 0.35 && t < 1.4, x: ramp(t, [[0.35, 0], [1.4, 0.5]]) };
}
function build() {
scene.textContent = '';
capAt(scene, 'A shimmer sweeps, and the passed letters fade', 11, '');
main = makeLin(scene, 'sl--shimmer', '', 21, 1);
chars = [];
for (const ch of LABEL_TEXT) {
const s = mk('span', 'ch', main.label);
s.textContent = ch;
chars.push(s);
}
sheen = mk('div', 'sheen', main.bg);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, 'Sweep time {s} s · fade width {w} cqw'.replace('{s}', String(SHIM_PERIOD)).replace('{w}', String(FADE_W)), 42, 'cap--note');
}
function measure() {
const w = rootW();
if (w === measuredW) {
return;
}
measuredW = w;
const tr = main.track.getBoundingClientRect();
const k = 100 / w;
charX = [];
for (let i = 0; i < chars.length; i += 1) {
const r = chars[i].getBoundingClientRect();
charX.push((r.left + r.width / 2 - tr.left) * k);
}
const first = chars[0].getBoundingClientRect();
const last = chars[chars.length - 1].getBoundingClientRect();
lineL = (first.left - tr.left) * k;
lineW = Math.max(1, (last.right - first.left) * k);
}
function reset() { measuredW = 0; }
function tick(h) { void h; }
function draw() {
measure();
placeHandle(main, sl.p);
const xr = PAD_CQW + sl.p * travelCqw() + HANDLE_CQW;
// 광택 띠의 중심(글줄 길이에 대한 비율) — 한 번 훑고 쉬지 않고 이어진다. 손을 대면 서서히 꺼진다
const idle = 1 - clamp(sl.p / 0.12, 0, 1);
const s = ((sceneT / SHIM_PERIOD) % 1) * (1 + 2 * SHIM_W) - SHIM_W;
for (let i = 0; i < chars.length; i += 1) {
const f = (charX[i] - lineL) / lineW;
const gain = Math.max(0, 1 - Math.abs(f - s) / SHIM_W) * idle;
const gone = clamp((xr - charX[i]) / FADE_W, 0, 1);
chars[i].style.opacity = ((BASE_ALPHA + (1 - BASE_ALPHA) * gain) * (1 - gone)).toFixed(3);
chars[i].style.transform = 'scale(' + (1 + 0.1 * gain).toFixed(3) + ')';
}
const sx = lineL + s * lineW - 10;
sheen.style.transform = 'translateX(' + sx.toFixed(2) + 'cqw) skewX(-20deg)';
sheen.style.opacity = (0.6 * idle).toFixed(3);
}
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();
};
})();// ShimmerLabelMask - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ShimmerLabelMask.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ShimmerLabelMask() {
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.8;
const REST_T = 3.0;
const TRACK_CQW = 72;
const HANDLE_CQW = 12.5;
const PAD_CQW = 1.25;
const THRESH = 0.8;
const SPR_K = 120;
const SPR_C = 14;
const FIN_K = 150;
const FIN_C = 24;
// 광택 띠 — 한 번 훑는 시간(초), 띠의 폭(글줄 길이에 대한 비율), 지나간 글자가 투명해지는 폭(cqw)
const SHIM_PERIOD = 1.6;
const SHIM_W = 0.3;
const FADE_W = 12;
// 글자가 가만히 있을 때의 불투명도 — 광택이 닿으면 1 까지 올라간다
const BASE_ALPHA = 0.8;
const LABEL_TEXT = '밀어서 잠금 해제';
const HANDLE_LABEL = '잠금 해제 손잡이 — 지나간 글자가 흐려지는 슬라이더';
let main: Lin;
let sheen: HTMLElement;
let chars: HTMLElement[] = [];
// 글자마다 트랙 안 가운데 위치(cqw)와 글줄의 왼쪽 끝·길이(cqw) — 처음 그릴 때 한 번, 폭이 바뀌면 다시 잰다
let charX: number[] = [];
let lineL = 0;
let lineW = 1;
let measuredW = 0;
function mapDrag(x: number): number { return clamp(x, 0, 1); }
function verdict(p: number, v: number): boolean { void v; return p >= THRESH; }
// 시연 손가락 — 광택 띠가 훑는 모습을 보여 준 뒤 0.35초에 누르고, 짝수 바퀴는 끝까지, 홀수 바퀴는 반쯤에서 놓는다
function fingerAt(t: number, c: number): Finger {
if (c % 2 === 0) {
return { down: t >= 0.35 && t < 1.6, x: ramp(t, [[0.35, 0], [0.75, 0.12], [1.2, 0.75], [1.45, 0.9]]) };
}
return { down: t >= 0.35 && t < 1.4, x: ramp(t, [[0.35, 0], [1.4, 0.5]]) };
}
function build(): void {
scene.textContent = '';
capAt(scene, '광택이 훑고, 지나간 글자는 흐려진다', 11, '');
main = makeLin(scene, 'sl--shimmer', '', 21, 1);
chars = [];
for (const ch of LABEL_TEXT) {
const s = mk('span', 'ch', main.label);
s.textContent = ch;
chars.push(s);
}
sheen = mk('div', 'sheen', main.bg);
wireHandle(main.handle, HANDLE_LABEL);
capAt(scene, '훑는 속도 {s}초 · 사라지는 폭 {w}cqw'.replace('{s}', String(SHIM_PERIOD)).replace('{w}', String(FADE_W)), 42, 'cap--note');
}
function measure(): void {
const w = rootW();
if (w === measuredW) { return; }
measuredW = w;
const tr = main.track.getBoundingClientRect();
const k = 100 / w;
charX = [];
for (let i = 0; i < chars.length; i += 1) {
const r = chars[i].getBoundingClientRect();
charX.push((r.left + r.width / 2 - tr.left) * k);
}
const first = chars[0].getBoundingClientRect();
const last = chars[chars.length - 1].getBoundingClientRect();
lineL = (first.left - tr.left) * k;
lineW = Math.max(1, (last.right - first.left) * k);
}
function reset(): void { measuredW = 0; }
function tick(h: number): void { void h; }
function draw(): void {
measure();
placeHandle(main, sl.p);
const xr = PAD_CQW + sl.p * travelCqw() + HANDLE_CQW;
// 광택 띠의 중심(글줄 길이에 대한 비율) — 한 번 훑고 쉬지 않고 이어진다. 손을 대면 서서히 꺼진다
const idle = 1 - clamp(sl.p / 0.12, 0, 1);
const s = ((sceneT / SHIM_PERIOD) % 1) * (1 + 2 * SHIM_W) - SHIM_W;
for (let i = 0; i < chars.length; i += 1) {
const f = (charX[i] - lineL) / lineW;
const gain = Math.max(0, 1 - Math.abs(f - s) / SHIM_W) * idle;
const gone = clamp((xr - charX[i]) / FADE_W, 0, 1);
chars[i].style.opacity = ((BASE_ALPHA + (1 - BASE_ALPHA) * gain) * (1 - gone)).toFixed(3);
chars[i].style.transform = 'scale(' + (1 + 0.1 * gain).toFixed(3) + ')';
}
const sx = lineL + s * lineW - 10;
sheen.style.transform = 'translateX(' + sx.toFixed(2) + 'cqw) skewX(-20deg)';
sheen.style.opacity = (0.6 * idle).toFixed(3);
}
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>
);
}05 A Guide Label That Fades Where the Handle Passes While a Shimmer Sweeps It — Measured area 13.636% · moving frames 17/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
Buttons08 Flowing wait — a button whose surface brightness value sweeps in one direction while content isn't readyCTA Button Design: 9 Interaction Patterns
Buttons01 Press-value overshoot — the pressed value passes its target value and swings back, repeating while the swing shrinksButton Press Animation UI: 9 Calculation Patterns
Buttons02 A Submit Button That Springs Back While Its Shadow Tracks the HeightSend Button Animation CSS: 9 Fold-and-Fly Buttons