CSS 스위치 버튼 9종 — 실물처럼 눌리는 레버·다이얼·이젝트
css 스위치 버튼은 화면에서 켜고 끄는 스위치를 실물 레버·로커·다이얼처럼 보이게 만든 버튼입니다. 여기 아홉 개는 라이브러리 없이 SCSS 와 짧은 JS 로만 짰고, 누르면 그 자리에서 기울고 돌고 튀어나오며 화면낭독기에도 상태가 읽힙니다.
01 skeuo-toggle-lever
02 skeuo-rocker-switch
03 skeuo-dial-knob
04 skeuo-push-slider
05 skeuo-bang-button
06 skeuo-pull-knob
07 skeuo-seesaw-switch
08 skeuo-breaker-switch
09 skeuo-cassette-eject
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 토글 레버 스위치
- 02 로커 전원 스위치
- 03 회전 다이얼 노브
- 04 밀기 페이더 슬라이더
- 05 비상 뱅 버튼
- 06 당겨 여는 풀 노브
- 07 시소 틸트 스위치
- 08 차단기 브레이커 스위치
- 09 카세트 이젝트 버튼
아홉 개를 이 순서로 늘어놓은 기준은 손이 하는 일입니다. 앞의 둘(01·02)은 한 번 눌러 켜고 끄는 전원, 다음 둘(03·04)은 값을 연속으로 정하는 조절기, 가운데 둘(05·06)은 누르고 당겨 상태를 바꾸는 큰 동작이고, 뒤의 둘(07·08)은 판과 손잡이를 젖혀 골라 끄는 물건이며, 마지막(09)은 꺼내는 동작으로 문을 닫습니다. 무대는 먹·페이퍼·옐로·주황 넷으로 돌렸고, 주황 무대 위 글자는 전부 먹으로 찍었습니다. css 스위치를 고르는 일은 곧 상태를 몇 개 둘지 정하는 일인데, 이어지는 항목마다 그 답이 코드로 남아 있습니다. 누르는 순간의 움직임을 더하고 싶으면 CSS 버튼 애니메이션 9종이, 눌린 뒤 알려주는 마크업이 궁금하면 버튼 클릭 효과 9종이 각각 이웃입니다.
01토글 레버 스위치
금속 레버가 관절 위에서 좌우로 기울어 켜짐·꺼짐을 가르는 스위치로, 오디오 랙 전원 패널이나 기기 설정 화면의 모드 전환에 어울립니다.

<main class="stage">
<div class="tl is-demo" id="tl">
<section class="tl__panel">
<p class="tl__head"><span class="tl__led" aria-hidden="true"></span><span class="tl__name">전원</span></p>
<div class="tl__assembly">
<span class="tl__bracket" aria-hidden="true"></span>
<span class="tl__slot" aria-hidden="true"></span>
<span class="tl__dome" aria-hidden="true"></span>
<button class="tl__hit" id="tlLever" type="button" role="switch" aria-checked="false" aria-label="전원 레버">
<span class="tl__armWrap" aria-hidden="true"><span class="tl__grip"></span></span>
</button>
</div>
<p class="tl__marks" aria-hidden="true"><span class="tl__mark tl__mark--off">OFF</span><span class="tl__mark tl__mark--on">ON</span></p>
<p class="tl__state" role="status"><span class="tl__wordOff">꺼짐</span><span class="tl__wordOn">켜짐</span></p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 토글 레버 스위치 — 금속 레버가 관절(돔)을 축으로 좌우로 기울어 on·off 를 가른다.
// 손을 떼는 순간의 스프링 튕김은 $easing(오버슈트 베지에)이 transition 한 번으로 만든다.
// 켜짐은 LED 점과 ON 글자 둘로 알린다(색만으로 상태를 말하지 않는다). role="switch" + aria-checked.
// 미리보기(.is-demo)는 레버가 좌우로 왕복하며 LED·글자가 steps(1,end) 컷으로 따라 바뀐다.
$duration: $dur-quick;
$easing: cubic-bezier(.2, 1.4, .4, 1); // = $ease-pop — 칩 값 그대로 노출
$color: $stage-paper;
@include stage($stage-ink);
.tl { display: grid; place-items: center; width: 100%; }
.tl__panel {
box-sizing: border-box; width: clamp(190px, 60vw, 230px);
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
padding: $sp-3; border-radius: $r-card;
background: rgba($subject-cream, .06);
border: 1px solid rgba($subject-cream, .18);
box-shadow: $shadow-raised;
}
.tl__head { display: flex; align-items: center; gap: $sp-2; margin: 0; height: 16px; }
.tl__led {
width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto;
background: rgba($stage-yellow, .16);
box-shadow: inset 0 0 0 1px rgba($stage-yellow, .35);
}
.tl__name {
font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1;
letter-spacing: .18em; color: rgba($subject-cream, .78);
}
// ── 레버 어셈블리 ────────────────────────────────────────────
.tl__assembly { position: relative; width: 130px; height: 92px; }
.tl__bracket {
position: absolute; left: 0; bottom: 0; width: 130px; height: 22px;
border-radius: $r-control;
background: linear-gradient(180deg, $color, rgba($subject-ink, .30)), $color;
box-shadow: 0 2px 0 rgba($stage-ink, .8), inset 0 1px 0 rgba($subject-cream, .8);
}
.tl__slot {
position: absolute; left: 50%; top: 72px; width: 72px; height: 6px;
margin-left: -36px; border-radius: $r-pill; background: rgba($stage-ink, .6);
box-shadow: inset 0 2px 3px rgba($stage-ink, .9), 0 1px 0 rgba($subject-cream, .16);
}
.tl__dome {
position: absolute; left: calc(50% - 13px); bottom: 3px; width: 26px; height: 26px;
border-radius: 50%;
background: linear-gradient(155deg, rgba($subject-cream, .95), rgba($subject-cream, .35) 55%, rgba($stage-ink, .65));
box-shadow: 0 1px 2px rgba($stage-ink, .9);
}
.tl__hit {
position: absolute; inset: 0; z-index: 5; margin: 0; padding: 0;
border: 0; background: transparent; cursor: pointer;
}
.tl__hit:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; border-radius: $r-control; }
// 레버 팔 — transform-origin 을 팔 밑(돔 중심)에 두고 좌우로 기울어진다
.tl__armWrap {
position: absolute; left: calc(50% - 5px); bottom: 15px; width: 10px; height: 80px;
border-radius: $r-pill; transform-origin: 50% 100%; transform: rotate(-24deg);
transition: transform $duration $easing;
background: linear-gradient(90deg,
rgba($stage-ink, .55), rgba($subject-cream, .82) 42%,
rgba($subject-cream, .95) 50%, rgba($subject-cream, .70) 62%, rgba($stage-ink, .50));
box-shadow: 0 4px 8px rgba($stage-ink, .55);
}
.tl__grip {
position: absolute; top: -13px; left: calc(50% - 15px); width: 30px; height: 30px;
border-radius: 50%;
background: linear-gradient(150deg, rgba($subject-cream, .96), rgba($subject-cream, .45) 48%, rgba($stage-ink, .55));
box-shadow: 0 6px 10px rgba($stage-ink, .6), inset 0 1px 1px rgba($subject-cream, .9);
}
// ── 상태 ─────────────────────────────────────────────────────
.tl__marks { display: flex; justify-content: space-between; width: 118px; margin: 0; }
.tl__mark {
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1; letter-spacing: .14em;
}
.tl__mark--off { color: rgba($subject-cream, .78); }
.tl__mark--on { color: rgba($subject-cream, .72); }
.tl__state { position: relative; margin: 0; width: 60px; height: 16px; }
.tl__wordOff, .tl__wordOn {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
.tl__wordOff { color: rgba($subject-cream, .85); }
.tl__wordOn { color: $stage-yellow; display: none; }
// 켜짐 — 레버 기울기 · LED · ON 글자 · 상태 단어가 함께 바뀐다
.tl.is-on .tl__armWrap { transform: rotate(24deg); }
.tl.is-on .tl__led {
background: $stage-yellow;
box-shadow: 0 0 10px rgba($stage-yellow, .85), 0 0 0 3px rgba($stage-yellow, .25);
}
.tl.is-on .tl__mark--on { color: $stage-yellow; }
.tl.is-on .tl__mark--off { color: rgba($subject-cream, .72); }
.tl.is-on .tl__wordOn { display: flex; }
.tl.is-on .tl__wordOff { display: none; }
// ── 미리보기 루프(2초 한 바퀴) ────────────────────────────────
// 레버는 오버슈트를 넣었다가 제자리로 돌아온다. LED·글자는 steps(1,end) 컷.
.tl.is-demo .tl__armWrap {
animation-name: tlSwing; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes tlSwing {
0% { transform: rotate(-24deg); }
8% { transform: rotate(-24deg); }
38% { transform: rotate(28deg); }
44% { transform: rotate(23deg); }
48% { transform: rotate(24deg); }
68% { transform: rotate(24deg); }
88% { transform: rotate(-28deg); }
94% { transform: rotate(-23deg); }
97% { transform: rotate(-24deg); }
100% { transform: rotate(-24deg); }
}
.tl.is-demo .tl__led {
animation-name: tlLed; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes tlLed {
0% { background: rgba($stage-yellow, .16); box-shadow: inset 0 0 0 1px rgba($stage-yellow, .35); }
46% { background: $stage-yellow; box-shadow: 0 0 10px rgba($stage-yellow, .85), 0 0 0 3px rgba($stage-yellow, .25); }
70% { background: rgba($stage-yellow, .16); box-shadow: inset 0 0 0 1px rgba($stage-yellow, .35); }
100% { background: rgba($stage-yellow, .16); box-shadow: inset 0 0 0 1px rgba($stage-yellow, .35); }
}
.tl.is-demo .tl__mark--on {
animation-name: tlMarkOn; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes tlMarkOn {
0% { color: rgba($subject-cream, .72); }
46% { color: $stage-yellow; }
70% { color: rgba($subject-cream, .72); }
100% { color: rgba($subject-cream, .72); }
}
.tl.is-demo .tl__mark--off {
animation-name: tlMarkOff; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes tlMarkOff {
0% { color: rgba($subject-cream, .78); }
46% { color: rgba($subject-cream, .72); }
70% { color: rgba($subject-cream, .78); }
100% { color: rgba($subject-cream, .78); }
}
// 상태 단어는 루프에서 둘 다 띄운 뒤 opacity 컷으로 갈아 낀다(정적 규칙의 display 는 건드리지 않는다)
.tl.is-demo .tl__wordOn { display: flex; opacity: 0; }
.tl.is-demo .tl__wordOff { opacity: 1; }
.tl.is-demo .tl__wordOn {
animation-name: tlWordOn; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes tlWordOn {
0% { opacity: 0; }
46% { opacity: 1; }
70% { opacity: 0; }
100% { opacity: 0; }
}
.tl.is-demo .tl__wordOff {
animation-name: tlWordOff; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes tlWordOff {
0% { opacity: 1; }
46% { opacity: 0; }
70% { opacity: 1; }
100% { opacity: 1; }
}
@media (max-width: 340px) {
.tl__panel { gap: $sp-1; padding: $sp-2; }
.tl__assembly { width: 110px; height: 66px; }
.tl__bracket { width: 110px; height: 18px; }
.tl__slot { top: 52px; left: calc(50% - 30px); width: 60px; }
.tl__dome { width: 20px; height: 20px; left: calc(50% - 10px); bottom: 2px; }
.tl__armWrap { height: 56px; bottom: 11px; }
.tl__grip { width: 24px; height: 24px; left: calc(50% - 12px); top: -10px; }
.tl__marks { width: 100px; }
.tl__state { height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.tl.is-demo .tl__armWrap { animation: none !important; transform: rotate(-24deg) !important; }
.tl.is-demo .tl__led { animation: none !important; }
.tl.is-demo .tl__mark--on { animation: none !important; }
.tl.is-demo .tl__mark--off { animation: none !important; }
.tl.is-demo .tl__wordOn { animation: none !important; opacity: 0 !important; }
.tl.is-demo .tl__wordOff { animation: none !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tl {
display: grid;
place-items: center;
width: 100%;
}
.tl__panel {
box-sizing: border-box;
width: clamp(190px, 60vw, 230px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
border: 1px solid rgba(255, 247, 230, 0.18);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.tl__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
height: 16px;
}
.tl__led {
width: 10px;
height: 10px;
border-radius: 50%;
flex: 0 0 auto;
background: rgba(255, 210, 63, 0.16);
box-shadow: inset 0 0 0 1px rgba(255, 210, 63, 0.35);
}
.tl__name {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1;
letter-spacing: 0.18em;
color: rgba(255, 247, 230, 0.78);
}
.tl__assembly {
position: relative;
width: 130px;
height: 92px;
}
.tl__bracket {
position: absolute;
left: 0;
bottom: 0;
width: 130px;
height: 22px;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff, rgba(23, 20, 26, 0.3)), #ffffff;
box-shadow: 0 2px 0 rgba(23, 20, 26, 0.8), inset 0 1px 0 rgba(255, 247, 230, 0.8);
}
.tl__slot {
position: absolute;
left: 50%;
top: 72px;
width: 72px;
height: 6px;
margin-left: -36px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.6);
box-shadow: inset 0 2px 3px rgba(23, 20, 26, 0.9), 0 1px 0 rgba(255, 247, 230, 0.16);
}
.tl__dome {
position: absolute;
left: calc(50% - 13px);
bottom: 3px;
width: 26px;
height: 26px;
border-radius: 50%;
background: linear-gradient(155deg, rgba(255, 247, 230, 0.95), rgba(255, 247, 230, 0.35) 55%, rgba(23, 20, 26, 0.65));
box-shadow: 0 1px 2px rgba(23, 20, 26, 0.9);
}
.tl__hit {
position: absolute;
inset: 0;
z-index: 5;
margin: 0;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.tl__hit:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
border-radius: 12px;
}
.tl__armWrap {
position: absolute;
left: calc(50% - 5px);
bottom: 15px;
width: 10px;
height: 80px;
border-radius: 999px;
transform-origin: 50% 100%;
transform: rotate(-24deg);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
background: linear-gradient(90deg, rgba(23, 20, 26, 0.55), rgba(255, 247, 230, 0.82) 42%, rgba(255, 247, 230, 0.95) 50%, rgba(255, 247, 230, 0.7) 62%, rgba(23, 20, 26, 0.5));
box-shadow: 0 4px 8px rgba(23, 20, 26, 0.55);
}
.tl__grip {
position: absolute;
top: -13px;
left: calc(50% - 15px);
width: 30px;
height: 30px;
border-radius: 50%;
background: linear-gradient(150deg, rgba(255, 247, 230, 0.96), rgba(255, 247, 230, 0.45) 48%, rgba(23, 20, 26, 0.55));
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.6), inset 0 1px 1px rgba(255, 247, 230, 0.9);
}
.tl__marks {
display: flex;
justify-content: space-between;
width: 118px;
margin: 0;
}
.tl__mark {
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
letter-spacing: 0.14em;
}
.tl__mark--off {
color: rgba(255, 247, 230, 0.78);
}
.tl__mark--on {
color: rgba(255, 247, 230, 0.72);
}
.tl__state {
position: relative;
margin: 0;
width: 60px;
height: 16px;
}
.tl__wordOff, .tl__wordOn {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
.tl__wordOff {
color: rgba(255, 247, 230, 0.85);
}
.tl__wordOn {
color: #ffd23f;
display: none;
}
.tl.is-on .tl__armWrap {
transform: rotate(24deg);
}
.tl.is-on .tl__led {
background: #ffd23f;
box-shadow: 0 0 10px rgba(255, 210, 63, 0.85), 0 0 0 3px rgba(255, 210, 63, 0.25);
}
.tl.is-on .tl__mark--on {
color: #ffd23f;
}
.tl.is-on .tl__mark--off {
color: rgba(255, 247, 230, 0.72);
}
.tl.is-on .tl__wordOn {
display: flex;
}
.tl.is-on .tl__wordOff {
display: none;
}
.tl.is-demo .tl__armWrap {
animation-name: tlSwing;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes tlSwing {
0% {
transform: rotate(-24deg);
}
8% {
transform: rotate(-24deg);
}
38% {
transform: rotate(28deg);
}
44% {
transform: rotate(23deg);
}
48% {
transform: rotate(24deg);
}
68% {
transform: rotate(24deg);
}
88% {
transform: rotate(-28deg);
}
94% {
transform: rotate(-23deg);
}
97% {
transform: rotate(-24deg);
}
100% {
transform: rotate(-24deg);
}
}
.tl.is-demo .tl__led {
animation-name: tlLed;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes tlLed {
0% {
background: rgba(255, 210, 63, 0.16);
box-shadow: inset 0 0 0 1px rgba(255, 210, 63, 0.35);
}
46% {
background: #ffd23f;
box-shadow: 0 0 10px rgba(255, 210, 63, 0.85), 0 0 0 3px rgba(255, 210, 63, 0.25);
}
70% {
background: rgba(255, 210, 63, 0.16);
box-shadow: inset 0 0 0 1px rgba(255, 210, 63, 0.35);
}
100% {
background: rgba(255, 210, 63, 0.16);
box-shadow: inset 0 0 0 1px rgba(255, 210, 63, 0.35);
}
}
.tl.is-demo .tl__mark--on {
animation-name: tlMarkOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes tlMarkOn {
0% {
color: rgba(255, 247, 230, 0.72);
}
46% {
color: #ffd23f;
}
70% {
color: rgba(255, 247, 230, 0.72);
}
100% {
color: rgba(255, 247, 230, 0.72);
}
}
.tl.is-demo .tl__mark--off {
animation-name: tlMarkOff;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes tlMarkOff {
0% {
color: rgba(255, 247, 230, 0.78);
}
46% {
color: rgba(255, 247, 230, 0.72);
}
70% {
color: rgba(255, 247, 230, 0.78);
}
100% {
color: rgba(255, 247, 230, 0.78);
}
}
.tl.is-demo .tl__wordOn {
display: flex;
opacity: 0;
}
.tl.is-demo .tl__wordOff {
opacity: 1;
}
.tl.is-demo .tl__wordOn {
animation-name: tlWordOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes tlWordOn {
0% {
opacity: 0;
}
46% {
opacity: 1;
}
70% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.tl.is-demo .tl__wordOff {
animation-name: tlWordOff;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes tlWordOff {
0% {
opacity: 1;
}
46% {
opacity: 0;
}
70% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.tl__panel {
gap: 4px;
padding: 8px;
}
.tl__assembly {
width: 110px;
height: 66px;
}
.tl__bracket {
width: 110px;
height: 18px;
}
.tl__slot {
top: 52px;
left: calc(50% - 30px);
width: 60px;
}
.tl__dome {
width: 20px;
height: 20px;
left: calc(50% - 10px);
bottom: 2px;
}
.tl__armWrap {
height: 56px;
bottom: 11px;
}
.tl__grip {
width: 24px;
height: 24px;
left: calc(50% - 12px);
top: -10px;
}
.tl__marks {
width: 100px;
}
.tl__state {
height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.tl.is-demo .tl__armWrap {
animation: none !important;
transform: rotate(-24deg) !important;
}
.tl.is-demo .tl__led {
animation: none !important;
}
.tl.is-demo .tl__mark--on {
animation: none !important;
}
.tl.is-demo .tl__mark--off {
animation: none !important;
}
.tl.is-demo .tl__wordOn {
animation: none !important;
opacity: 0 !important;
}
.tl.is-demo .tl__wordOff {
animation: none !important;
opacity: 1 !important;
}
}var root = document.getElementById('tl');
var lever = document.getElementById('tlLever');
function setOn(on) {
lever.setAttribute('aria-checked', on ? 'true' : 'false');
root.classList.toggle('is-on', on);
}
function toggle() {
root.classList.remove('is-demo');
setOn(lever.getAttribute('aria-checked') !== 'true');
}
lever.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
lever.addEventListener('click', toggle);// ToggleLever — vanilla 판(run/013/demo/01-skeuo-toggle-lever/index.html) 의 React 포트.
// 금속 레버가 관절을 축으로 좌우로 기울어 on·off 를 가르고, transition 이 오버슈트로 한 번 튕긴다.
// 값·키프레임은 vanilla 와 같다(다시 타이핑하지 않는다). 클릭하면 미리보기 루프를 뗀다.
import { useState } from "react";
import styles from "./ToggleLever.module.scss";
export type ToggleLeverProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = { [k: string]: any };
const NAME_TEXT = "전원";
const STATE_OFF = "꺼짐";
const STATE_ON = "켜짐";
const LEVER_LABEL = "전원 레버";
export default function ToggleLever({
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)", // = $ease-pop
color = "#ffffff",
}: ToggleLeverProps) {
const [on, setOn] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [styles.root, demo ? styles.isDemo : "", on ? styles.isOn : ""].filter(Boolean).join(" ");
function toggle() {
setDemo(false);
setOn((prev) => !prev);
}
function wake() {
setDemo(false);
}
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<section className={styles.panel}>
<p className={styles.head}><span className={styles.led} aria-hidden="true"></span><span className={styles.name}>{NAME_TEXT}</span></p>
<div className={styles.assembly}>
<span className={styles.bracket} aria-hidden="true"></span>
<span className={styles.slot} aria-hidden="true"></span>
<span className={styles.dome} aria-hidden="true"></span>
<button
className={styles.hit}
type="button"
role="switch"
aria-checked={on}
aria-label={LEVER_LABEL}
onPointerDown={wake}
onClick={toggle}
>
<span className={styles.armWrap} aria-hidden="true"><span className={styles.grip}></span></span>
</button>
</div>
<p className={styles.marks} aria-hidden="true">
<span className={`${styles.mark} ${styles.markOff}`}>OFF</span>
<span className={`${styles.mark} ${styles.markOn}`}>ON</span>
</p>
<p className={styles.state} role="status">
<span className={styles.wordOff}>{STATE_OFF}</span>
<span className={styles.wordOn}>{STATE_ON}</span>
</p>
</section>
</div>
</main>
);
}01 · 토글 레버 스위치 — 측정 누적면적 4.119% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
관절 위에서 도는 레버 팔
레버 팔(.tl__armWrap)은 transform-origin: 50% 100% 로 밑단 돔의 중심을 축으로 삼아 rotate(-24deg) 와 rotate(24deg) 사이만 오가고, 켜지면 LED 점과 ON 글자, 상태 단어가 함께 바뀝니다.
.tl__armWrap {
position: absolute; left: calc(50% - 5px); bottom: 15px; width: 10px; height: 80px;
border-radius: $r-pill; transform-origin: 50% 100%; transform: rotate(-24deg);
transition: transform $duration $easing;
background: linear-gradient(90deg,
rgba($stage-ink, .55), rgba($subject-cream, .82) 42%,
rgba($subject-cream, .95) 50%, rgba($subject-cream, .70) 62%, rgba($stage-ink, .50));
box-shadow: 0 4px 8px rgba($stage-ink, .55);
}
오버슈트로 살아나는 스프링 결
손을 떼는 순간의 튕김은 cubic-bezier(.2, 1.4, .4, 1) 의 오버슈트 한 번에서 나오고, 미리보기에도 38% 에 28도를 지나쳤다가 48% 에 24도로 돌아오는 결이 그대로 들어 있습니다(움직인 프레임 18/23).
@keyframes tlSwing {
0% { transform: rotate(-24deg); }
8% { transform: rotate(-24deg); }
38% { transform: rotate(28deg); }
44% { transform: rotate(23deg); }
48% { transform: rotate(24deg); }
68% { transform: rotate(24deg); }
88% { transform: rotate(-28deg); }
94% { transform: rotate(-23deg); }
97% { transform: rotate(-24deg); }
100% { transform: rotate(-24deg); }
}
02로커 전원 스위치
윗면 I·아랫면 O 두 cap 을 가진 실물 전원 스위치 목업으로, 기기 뒷면이나 무선 마우스 설정 카드에 두는 결입니다.

<main class="stage">
<div class="rs is-demo" id="rs">
<section class="rs__plate">
<p class="rs__head"><span class="rs__lamp" aria-hidden="true"></span><span class="rs__onWord">ON</span></p>
<div class="rs__bezel">
<button class="rs__rocker" id="rsRocker" type="button" role="switch" aria-checked="false" aria-label="전원 로커">
<span class="rs__cap rs__capTop"><b class="rs__io">I</b></span>
<span class="rs__cap rs__capBottom"><b class="rs__io">O</b></span>
</button>
</div>
<p class="rs__state" role="status"><span class="rs__wordOff">꺼짐</span><span class="rs__wordOn">켜짐</span></p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 로커 전원 스위치 — 윗면(I)을 누르면 누른 쪽이 translateY 로 가라앉으며
// inset 그림자가 깊어지고 반대쪽(O)이 떠오른다. 두 cap 의 그림자 리스트는
// 구조를 맞춰(둘 다 inset 2장) 보간이 되게 했다. 켜짐은 주황 표시등과 ON 글자로 —
// 색만으로 상태를 알리지 않는다. role="switch" + aria-checked.
// 미리보기(.is-demo)는 위·아래를 번갈아 눌러 보고 LED·단어는 steps(1,end) 컷.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-yellow);
.rs { display: grid; place-items: center; width: 100%; }
.rs__plate {
box-sizing: border-box; width: clamp(136px, 44vw, 164px);
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
padding: $sp-3; border-radius: $r-card;
background: linear-gradient(180deg, rgba($subject-ink, .06), rgba($subject-ink, 0) 26%, rgba($subject-ink, 0) 74%, rgba($subject-ink, .10)), $color;
box-shadow: $shadow-raised, inset 0 1px 0 rgba($stage-paper, .9);
}
.rs__head { display: flex; align-items: center; gap: $sp-2; margin: 0; height: 18px; }
.rs__lamp {
width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto;
background: rgba($subject-ink, .16);
box-shadow: inset 0 1px 3px rgba($subject-ink, .5), 0 0 0 3px rgba($subject-ink, .82);
}
.rs__onWord {
font-weight: 800; font-size: clamp(10px, 2.6vw, 12px); line-height: 1;
letter-spacing: .2em; color: rgba($subject-ink, .74);
}
// ── 로커 ─────────────────────────────────────────────────────
.rs__bezel {
padding: $sp-1; border-radius: $r-control;
background: rgba($subject-ink, .26);
box-shadow: inset 0 3px 6px rgba($subject-ink, .55), 0 1px 0 rgba($stage-paper, .8);
}
.rs__rocker {
display: flex; flex-direction: column; margin: 0; padding: 0; border: 0;
width: 84px; height: 132px; background: transparent; cursor: pointer;
}
.rs__rocker:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; border-radius: $r-control; }
.rs__cap {
display: flex; align-items: center; justify-content: center;
width: 84px; height: 66px;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.rs__capTop {
border-radius: $r-sm $r-sm 0 0;
background: linear-gradient(180deg, $color, rgba($subject-ink, .10)), $color;
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85);
}
.rs__capBottom {
border-radius: 0 0 $r-sm $r-sm;
background: linear-gradient(180deg, rgba($subject-ink, .05), rgba($subject-ink, .17)), $color;
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40);
}
.rs__io {
font-weight: 800; font-size: 13px; line-height: 1; color: rgba($subject-ink, .76);
}
// 켜짐 — 윗 cap 이 가라앉고 아랫cap 이 뜬다. 표시등·ON 글자·상태 단어가 함께 바뀐다
.rs.is-on .rs__capTop {
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40);
}
.rs.is-on .rs__capBottom {
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85);
}
.rs.is-on .rs__lamp {
background: $stage-orange;
box-shadow: 0 0 12px rgba($stage-orange, .8), 0 0 0 3px rgba($subject-ink, .82);
}
.rs.is-on .rs__onWord { color: $subject-orange-deep; }
.rs__state { position: relative; margin: 0; width: 60px; height: 16px; }
.rs__wordOff, .rs__wordOn {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
}
.rs__wordOff { color: $subject-ink; }
.rs__wordOn { display: none; }
.rs.is-on .rs__wordOn { display: flex; }
.rs.is-on .rs__wordOff { display: none; }
// ── 미리보기 루프(2초 한 바퀴) ────────────────────────────────
// 위를 누름(오버슈트 4px) → 유지 → 놓음(튕겨 올라옴) → 유지. 컷 요소는 steps(1,end).
.rs.is-demo .rs__capTop {
animation-name: rsPressTop; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes rsPressTop {
0% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
12% { transform: translateY(4px); box-shadow: inset 0 5px 9px rgba($subject-ink, .38), inset 0 -1px 0 rgba($stage-paper, .35); }
18% { transform: translateY(3px); box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
46% { transform: translateY(3px); box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
58% { transform: translateY(-4px); box-shadow: inset 0 -4px 7px rgba($subject-ink, .08), inset 0 3px 0 rgba($stage-paper, .9); }
64% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
100% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
}
.rs.is-demo .rs__capBottom {
animation-name: rsPressBottom; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes rsPressBottom {
0% { transform: translateY(3px); box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
12% { transform: translateY(-4px); box-shadow: inset 0 -4px 7px rgba($subject-ink, .08), inset 0 3px 0 rgba($stage-paper, .9); }
18% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
46% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
58% { transform: translateY(4px); box-shadow: inset 0 5px 9px rgba($subject-ink, .38), inset 0 -1px 0 rgba($stage-paper, .35); }
64% { transform: translateY(3px); box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
100% { transform: translateY(3px); box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
}
.rs.is-demo .rs__lamp {
animation-name: rsLamp; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes rsLamp {
0% { background: rgba($subject-ink, .16); box-shadow: inset 0 1px 3px rgba($subject-ink, .5), 0 0 0 3px rgba($subject-ink, .82); }
16% { background: $stage-orange; box-shadow: 0 0 12px rgba($stage-orange, .8), 0 0 0 3px rgba($subject-ink, .82); }
62% { background: rgba($subject-ink, .16); box-shadow: inset 0 1px 3px rgba($subject-ink, .5), 0 0 0 3px rgba($subject-ink, .82); }
100% { background: rgba($subject-ink, .16); box-shadow: inset 0 1px 3px rgba($subject-ink, .5), 0 0 0 3px rgba($subject-ink, .82); }
}
.rs.is-demo .rs__onWord {
animation-name: rsOnWord; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes rsOnWord {
0% { color: rgba($subject-ink, .74); }
16% { color: $subject-orange-deep; }
62% { color: rgba($subject-ink, .74); }
100% { color: rgba($subject-ink, .74); }
}
.rs.is-demo .rs__wordOn { display: flex; opacity: 0; }
.rs.is-demo .rs__wordOff { opacity: 1; }
.rs.is-demo .rs__wordOn {
animation-name: rsWordOn; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes rsWordOn {
0% { opacity: 0; }
16% { opacity: 1; }
62% { opacity: 0; }
100% { opacity: 0; }
}
.rs.is-demo .rs__wordOff {
animation-name: rsWordOff; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes rsWordOff {
0% { opacity: 1; }
16% { opacity: 0; }
62% { opacity: 1; }
100% { opacity: 1; }
}
@media (max-width: 340px) {
.rs__plate { gap: $sp-1; padding: $sp-2; }
.rs__head { height: 14px; }
.rs__rocker { width: 68px; height: 104px; }
.rs__cap { width: 68px; height: 52px; }
.rs__io { font-size: 11px; }
.rs__state { height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__capTop { animation: none !important; transform: translateY(-3px) !important; }
.rs.is-demo .rs__capBottom { animation: none !important; transform: translateY(3px) !important; }
.rs.is-demo .rs__lamp { animation: none !important; }
.rs.is-demo .rs__onWord { animation: none !important; }
.rs.is-demo .rs__wordOn { animation: none !important; opacity: 0 !important; }
.rs.is-demo .rs__wordOff { animation: none !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rs {
display: grid;
place-items: center;
width: 100%;
}
.rs__plate {
box-sizing: border-box;
width: clamp(136px, 44vw, 164px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 16px;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.06), rgba(23, 20, 26, 0) 26%, rgba(23, 20, 26, 0) 74%, rgba(23, 20, 26, 0.1)), #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.rs__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
height: 18px;
}
.rs__lamp {
width: 12px;
height: 12px;
border-radius: 50%;
flex: 0 0 auto;
background: rgba(23, 20, 26, 0.16);
box-shadow: inset 0 1px 3px rgba(23, 20, 26, 0.5), 0 0 0 3px rgba(23, 20, 26, 0.82);
}
.rs__onWord {
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1;
letter-spacing: 0.2em;
color: rgba(23, 20, 26, 0.74);
}
.rs__bezel {
padding: 4px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.26);
box-shadow: inset 0 3px 6px rgba(23, 20, 26, 0.55), 0 1px 0 rgba(255, 255, 255, 0.8);
}
.rs__rocker {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 0;
width: 84px;
height: 132px;
background: transparent;
cursor: pointer;
}
.rs__rocker:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
border-radius: 12px;
}
.rs__cap {
display: flex;
align-items: center;
justify-content: center;
width: 84px;
height: 66px;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rs__capTop {
border-radius: 8px 8px 0 0;
background: linear-gradient(180deg, #ffffff, rgba(23, 20, 26, 0.1)), #ffffff;
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba(23, 20, 26, 0.1), inset 0 2px 0 rgba(255, 255, 255, 0.85);
}
.rs__capBottom {
border-radius: 0 0 8px 8px;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.05), rgba(23, 20, 26, 0.17)), #ffffff;
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba(23, 20, 26, 0.32), inset 0 -1px 0 rgba(255, 255, 255, 0.4);
}
.rs__io {
font-weight: 800;
font-size: 13px;
line-height: 1;
color: rgba(23, 20, 26, 0.76);
}
.rs.is-on .rs__capTop {
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba(23, 20, 26, 0.32), inset 0 -1px 0 rgba(255, 255, 255, 0.4);
}
.rs.is-on .rs__capBottom {
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba(23, 20, 26, 0.1), inset 0 2px 0 rgba(255, 255, 255, 0.85);
}
.rs.is-on .rs__lamp {
background: #ff4d1f;
box-shadow: 0 0 12px rgba(255, 77, 31, 0.8), 0 0 0 3px rgba(23, 20, 26, 0.82);
}
.rs.is-on .rs__onWord {
color: #c93000;
}
.rs__state {
position: relative;
margin: 0;
width: 60px;
height: 16px;
}
.rs__wordOff, .rs__wordOn {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
}
.rs__wordOff {
color: #17141a;
}
.rs__wordOn {
display: none;
}
.rs.is-on .rs__wordOn {
display: flex;
}
.rs.is-on .rs__wordOff {
display: none;
}
.rs.is-demo .rs__capTop {
animation-name: rsPressTop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes rsPressTop {
0% {
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba(23, 20, 26, 0.1), inset 0 2px 0 rgba(255, 255, 255, 0.85);
}
12% {
transform: translateY(4px);
box-shadow: inset 0 5px 9px rgba(23, 20, 26, 0.38), inset 0 -1px 0 rgba(255, 255, 255, 0.35);
}
18% {
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba(23, 20, 26, 0.32), inset 0 -1px 0 rgba(255, 255, 255, 0.4);
}
46% {
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba(23, 20, 26, 0.32), inset 0 -1px 0 rgba(255, 255, 255, 0.4);
}
58% {
transform: translateY(-4px);
box-shadow: inset 0 -4px 7px rgba(23, 20, 26, 0.08), inset 0 3px 0 rgba(255, 255, 255, 0.9);
}
64% {
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba(23, 20, 26, 0.1), inset 0 2px 0 rgba(255, 255, 255, 0.85);
}
100% {
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba(23, 20, 26, 0.1), inset 0 2px 0 rgba(255, 255, 255, 0.85);
}
}
.rs.is-demo .rs__capBottom {
animation-name: rsPressBottom;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes rsPressBottom {
0% {
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba(23, 20, 26, 0.32), inset 0 -1px 0 rgba(255, 255, 255, 0.4);
}
12% {
transform: translateY(-4px);
box-shadow: inset 0 -4px 7px rgba(23, 20, 26, 0.08), inset 0 3px 0 rgba(255, 255, 255, 0.9);
}
18% {
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba(23, 20, 26, 0.1), inset 0 2px 0 rgba(255, 255, 255, 0.85);
}
46% {
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba(23, 20, 26, 0.1), inset 0 2px 0 rgba(255, 255, 255, 0.85);
}
58% {
transform: translateY(4px);
box-shadow: inset 0 5px 9px rgba(23, 20, 26, 0.38), inset 0 -1px 0 rgba(255, 255, 255, 0.35);
}
64% {
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba(23, 20, 26, 0.32), inset 0 -1px 0 rgba(255, 255, 255, 0.4);
}
100% {
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba(23, 20, 26, 0.32), inset 0 -1px 0 rgba(255, 255, 255, 0.4);
}
}
.rs.is-demo .rs__lamp {
animation-name: rsLamp;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rsLamp {
0% {
background: rgba(23, 20, 26, 0.16);
box-shadow: inset 0 1px 3px rgba(23, 20, 26, 0.5), 0 0 0 3px rgba(23, 20, 26, 0.82);
}
16% {
background: #ff4d1f;
box-shadow: 0 0 12px rgba(255, 77, 31, 0.8), 0 0 0 3px rgba(23, 20, 26, 0.82);
}
62% {
background: rgba(23, 20, 26, 0.16);
box-shadow: inset 0 1px 3px rgba(23, 20, 26, 0.5), 0 0 0 3px rgba(23, 20, 26, 0.82);
}
100% {
background: rgba(23, 20, 26, 0.16);
box-shadow: inset 0 1px 3px rgba(23, 20, 26, 0.5), 0 0 0 3px rgba(23, 20, 26, 0.82);
}
}
.rs.is-demo .rs__onWord {
animation-name: rsOnWord;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rsOnWord {
0% {
color: rgba(23, 20, 26, 0.74);
}
16% {
color: #c93000;
}
62% {
color: rgba(23, 20, 26, 0.74);
}
100% {
color: rgba(23, 20, 26, 0.74);
}
}
.rs.is-demo .rs__wordOn {
display: flex;
opacity: 0;
}
.rs.is-demo .rs__wordOff {
opacity: 1;
}
.rs.is-demo .rs__wordOn {
animation-name: rsWordOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rsWordOn {
0% {
opacity: 0;
}
16% {
opacity: 1;
}
62% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rs.is-demo .rs__wordOff {
animation-name: rsWordOff;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rsWordOff {
0% {
opacity: 1;
}
16% {
opacity: 0;
}
62% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.rs__plate {
gap: 4px;
padding: 8px;
}
.rs__head {
height: 14px;
}
.rs__rocker {
width: 68px;
height: 104px;
}
.rs__cap {
width: 68px;
height: 52px;
}
.rs__io {
font-size: 11px;
}
.rs__state {
height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__capTop {
animation: none !important;
transform: translateY(-3px) !important;
}
.rs.is-demo .rs__capBottom {
animation: none !important;
transform: translateY(3px) !important;
}
.rs.is-demo .rs__lamp {
animation: none !important;
}
.rs.is-demo .rs__onWord {
animation: none !important;
}
.rs.is-demo .rs__wordOn {
animation: none !important;
opacity: 0 !important;
}
.rs.is-demo .rs__wordOff {
animation: none !important;
opacity: 1 !important;
}
}var root = document.getElementById('rs');
var rocker = document.getElementById('rsRocker');
function setOn(on) {
rocker.setAttribute('aria-checked', on ? 'true' : 'false');
root.classList.toggle('is-on', on);
}
function toggle() {
root.classList.remove('is-demo');
setOn(rocker.getAttribute('aria-checked') !== 'true');
}
rocker.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
rocker.addEventListener('click', toggle);// RockerSwitch — vanilla 판(run/013/demo/02-skeuo-rocker-switch/index.html) 의 React 포트.
// 윗면(I)을 누르면 그쪽이 가라앉고 반대쪽(O)이 뜬다. 값·키프레임은 vanilla 와 같다.
import { useState } from "react";
import styles from "./RockerSwitch.module.scss";
export type RockerSwitchProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = { [k: string]: any };
const ROCKER_LABEL = "전원 로커";
const STATE_OFF = "꺼짐";
const STATE_ON = "켜짐";
export default function RockerSwitch({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)", // = $ease-spring
color = "#ffffff",
}: RockerSwitchProps) {
const [on, setOn] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [styles.root, demo ? styles.isDemo : "", on ? styles.isOn : ""].filter(Boolean).join(" ");
function toggle() {
setDemo(false);
setOn((prev) => !prev);
}
function wake() {
setDemo(false);
}
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<section className={styles.plate}>
<p className={styles.head}><span className={styles.lamp} aria-hidden="true"></span><span className={styles.onWord}>ON</span></p>
<div className={styles.bezel}>
<button
className={styles.rocker}
type="button"
role="switch"
aria-checked={on}
aria-label={ROCKER_LABEL}
onPointerDown={wake}
onClick={toggle}
>
<span className={`${styles.cap} ${styles.capTop}`}><b className={styles.io}>I</b></span>
<span className={`${styles.cap} ${styles.capBottom}`}><b className={styles.io}>O</b></span>
</button>
</div>
<p className={styles.state} role="status">
<span className={styles.wordOff}>{STATE_OFF}</span>
<span className={styles.wordOn}>{STATE_ON}</span>
</p>
</section>
</div>
</main>
);
}02 · 로커 전원 스위치 — 측정 누적면적 3.633% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
누른 쪽이 가라앉고 반대쪽이 뜨는 구조
두 cap 의 기본값은 translateY(-3px)·translateY(3px) 인데, 켜지면 두 값이 정확히 맞바뀌고, 안쪽 그림자 두 장도 같은 모양 그대로 서로 뒤집힙니다.
.rs__capTop {
border-radius: $r-sm $r-sm 0 0;
background: linear-gradient(180deg, $color, rgba($subject-ink, .10)), $color;
transform: translateY(-3px);
box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85);
}
.rs__capBottom {
border-radius: 0 0 $r-sm $r-sm;
background: linear-gradient(180deg, rgba($subject-ink, .05), rgba($subject-ink, .17)), $color;
transform: translateY(3px);
box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40);
}
여덟 프레임만 움직이는 짧은 숨
미리보기 시간표는 12~18% 의 누름과 58~64% 의 놓음에만 움직임을 몰아넣고 나머지 구간은 그대로 붙잡고 있어, 이 판은 움직인 프레임이 8/23 로 아홉 개 중 가장 조용합니다.
@keyframes rsPressTop {
0% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
12% { transform: translateY(4px); box-shadow: inset 0 5px 9px rgba($subject-ink, .38), inset 0 -1px 0 rgba($stage-paper, .35); }
18% { transform: translateY(3px); box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
46% { transform: translateY(3px); box-shadow: inset 0 4px 8px rgba($subject-ink, .32), inset 0 -1px 0 rgba($stage-paper, .40); }
58% { transform: translateY(-4px); box-shadow: inset 0 -4px 7px rgba($subject-ink, .08), inset 0 3px 0 rgba($stage-paper, .9); }
64% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
100% { transform: translateY(-3px); box-shadow: inset 0 -3px 6px rgba($subject-ink, .10), inset 0 2px 0 rgba($stage-paper, .85); }
}
03회전 다이얼 노브
노브를 드래그하거나 화살표 키로 돌려 밝기를 0부터 100까지 고르는 조절기로, role="slider" 가 최소·최대·현재 값을 함께 읽어 줍니다.

<main class="stage">
<div class="dk is-demo" id="dk">
<div class="dk__ring" id="dkRing">
<span class="dk__ticks" aria-hidden="true"></span>
<span class="dk__arc" aria-hidden="true"></span>
<span class="dk__needle" aria-hidden="true"></span>
<div class="dk__knobWrap">
<button class="dk__knob" id="dkKnob" type="button" role="slider" aria-label="밝기 조절 다이얼"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0퍼센트"></button>
</div>
<span class="dk__scaleMin" aria-hidden="true">0</span>
<span class="dk__scaleMax" aria-hidden="true">100</span>
</div>
<div class="dk__side">
<p class="dk__label">밝기</p>
<p class="dk__readout">
<b class="dk__value" id="dkValue">0%</b>
<span class="dk__demoDigits" aria-hidden="true"><i class="dk__digit dk__digit--0">0%</i><i class="dk__digit dk__digit--1">20%</i><i class="dk__digit dk__digit--2">40%</i><i class="dk__digit dk__digit--3">60%</i><i class="dk__digit dk__digit--4">80%</i><i class="dk__digit dk__digit--5">100%</i></span>
</p>
<p class="dk__hint">드래그나 화살표 키로 조절</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 회전 다이얼 노브 — 노브를 드래그해 돌리면 각도만큼 값이 오르내린다.
// 눈금 링은 repeating-conic-gradient(눈금 11개, 27도 간격), 진행 호는 conic-gradient 가
// --fill(0~1) 에 따라 채운다. 바늘과 노브 각인은 같은 각도로 돈다.
// role="slider" + aria-valuemin/max/now, 화살표 키·Home·End 지원. 드래그는 pointermove.
// 미리보기(.is-demo)는 노브가 좌우로 왕복 트는 것을 CSS 로 재생하고 값 글자는 steps(1,end) 컷.
// 진행 호를 애니메이션하려면 --fill 이 숫자로 등록되어 있어야 한다(262-08 실측 계열)
@property --fill {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-orange);
.dk { display: flex; align-items: center; justify-content: center; gap: $sp-6; width: 100%; }
// ── 눈금 링 + 노브 ───────────────────────────────────────────
.dk__ring { position: relative; width: 168px; height: 168px; flex: 0 0 auto; }
.dk__ticks {
position: absolute; inset: 0; border-radius: 50%;
background: repeating-conic-gradient(from 224deg,
rgba($subject-ink, .85) 0 1.6deg, rgba($subject-ink, 0) 1.6deg 27deg);
}
.dk__arc {
position: absolute; inset: 7px; border-radius: 50%;
background: conic-gradient(from 225deg,
rgba($subject-ink, .82) 0 calc(var(--fill, 0) * 270deg),
rgba($subject-ink, .10) calc(var(--fill, 0) * 270deg) 270deg,
rgba($subject-ink, 0) 270deg 360deg);
}
.dk__needle { position: absolute; inset: 0; transform: rotate(calc(var(--angle, -135) * 1deg)); }
.dk__needle::before {
content: ""; position: absolute; top: 2px; left: calc(50% - 2px); width: 4px; height: 14px;
border-radius: $r-pill; background: $subject-ink;
}
.dk__knobWrap {
position: absolute; inset: 30px; display: grid; place-items: center;
}
.dk__knob {
position: relative; width: 108px; height: 108px; margin: 0; padding: 0; border: 0; border-radius: 50%;
cursor: grab; touch-action: none; font-family: inherit;
transform: rotate(calc(var(--angle, -135) * 1deg));
transition: transform $duration $easing;
background: repeating-conic-gradient(rgba($subject-ink, .24) 0 5deg, rgba($subject-ink, .05) 5deg 11deg),
linear-gradient(145deg, rgba($subject-cream, .55), rgba($subject-ink, .28)), $color;
box-shadow: 0 10px 18px rgba($subject-ink, .35), inset 0 2px 2px rgba($subject-cream, .55);
}
.dk__knob:active { cursor: grabbing; }
.dk__knob:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
// 노브 얼굴 — 새김 눈금(위쪽 각인)은 ::after, 얼굴면은 ::before
.dk__knob::before {
content: ""; position: absolute; inset: 10px; border-radius: 50%;
background: linear-gradient(160deg, rgba($stage-paper, .95), rgba($subject-ink, .20)), $color;
box-shadow: inset 0 -3px 8px rgba($subject-ink, .22), inset 0 2px 2px rgba($stage-paper, .8);
}
.dk__knob::after {
content: ""; position: absolute; top: 8px; left: calc(50% - 2.5px); width: 5px; height: 26px;
border-radius: $r-pill; background: $subject-ink;
}
.dk__scaleMin, .dk__scaleMax {
position: absolute; top: 128px; font-weight: 800; font-size: 11px; line-height: 1; color: $subject-ink;
}
.dk__scaleMin { left: 2px; }
.dk__scaleMax { right: 2px; }
// ── 값 판 ────────────────────────────────────────────────────
.dk__side { display: flex; flex-direction: column; align-items: flex-start; gap: $sp-1; }
.dk__label {
margin: 0; font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
letter-spacing: .22em; color: $subject-ink;
}
.dk__readout { position: relative; margin: 0; height: 44px; min-width: 108px; }
.dk__value {
display: flex; align-items: center; font-weight: 800;
font-size: clamp(28px, 7.5vw, 38px); line-height: 1; color: $subject-ink;
}
// 루프용 값 글자 — 정적 경로에서는 실제 값(.dk__value)이 자리를 지킨다
.dk__demoDigits { display: none; position: absolute; inset: 0; }
.dk__digit {
position: absolute; inset: 0; display: flex; align-items: center;
margin: 0; padding: 0; font-style: normal; font-weight: 800;
font-size: clamp(28px, 7.5vw, 38px); line-height: 1; color: $subject-ink;
}
.dk__hint {
margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5;
color: $subject-ink;
}
// ── 미리보기 루프(2초 한 바퀴) ────────────────────────────────
// 바늘·노브는 -135도 에서 135도 로 트고 돌아온다. 호(--fill)와 값 글자가 같은 시간표를 따른다.
.dk.is-demo .dk__needle, .dk.is-demo .dk__knob {
animation-name: dkSweep; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes dkSweep {
0% { transform: rotate(-135deg); }
45% { transform: rotate(135deg); }
50% { transform: rotate(135deg); }
95% { transform: rotate(-135deg); }
100% { transform: rotate(-135deg); }
}
.dk.is-demo {
animation-name: dkFill; animation-duration: $dur-loop;
animation-timing-function: linear; animation-iteration-count: infinite;
}
@keyframes dkFill {
0% { --fill: 0; }
45% { --fill: 1; }
50% { --fill: 1; }
95% { --fill: 0; }
100% { --fill: 0; }
}
// 루프에서는 실제 값 글자를 비우고(공간은 유지) 6단계 값 글자가 컷으로 갈아 낀다
.dk.is-demo .dk__value { visibility: hidden; }
.dk.is-demo .dk__demoDigits { display: block; }
.dk.is-demo .dk__digit {
opacity: 0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dk.is-demo .dk__digit--0 { animation-name: dkDigit-0; }
.dk.is-demo .dk__digit--1 { animation-name: dkDigit-1; }
.dk.is-demo .dk__digit--2 { animation-name: dkDigit-2; }
.dk.is-demo .dk__digit--3 { animation-name: dkDigit-3; }
.dk.is-demo .dk__digit--4 { animation-name: dkDigit-4; }
.dk.is-demo .dk__digit--5 { animation-name: dkDigit-5; }
// 45% 까지 올라갔다 50% 부터 내려오는 트는 시간표에 맞춘 값 창(위 [9i, 9(i+1)) · 아래 (95-9(i+1), 95-9i])
@keyframes dkDigit-0 {
0% { opacity: 1; }
9% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes dkDigit-1 {
0% { opacity: 0; }
9% { opacity: 1; }
18% { opacity: 0; }
77% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes dkDigit-2 {
0% { opacity: 0; }
18% { opacity: 1; }
27% { opacity: 0; }
68% { opacity: 1; }
77% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes dkDigit-3 {
0% { opacity: 0; }
27% { opacity: 1; }
36% { opacity: 0; }
59% { opacity: 1; }
68% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes dkDigit-4 {
0% { opacity: 0; }
36% { opacity: 1; }
45% { opacity: 0; }
50% { opacity: 1; }
59% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes dkDigit-5 {
0% { opacity: 0; }
45% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 340px) {
.dk { gap: $sp-3; }
.dk__ring { width: 128px; height: 128px; }
.dk__arc { inset: 5px; }
.dk__knobWrap { inset: 24px; }
.dk__knob { width: 80px; height: 80px; }
.dk__knob::after { height: 19px; top: 6px; }
.dk__needle::before { height: 11px; }
.dk__scaleMin, .dk__scaleMax { top: 98px; font-size: 9px; }
.dk__readout { height: 36px; min-width: 84px; }
}
@media (prefers-reduced-motion: reduce) {
.dk.is-demo .dk__needle { animation: none !important; transform: rotate(-135deg) !important; }
.dk.is-demo .dk__knob { animation: none !important; transform: rotate(calc(var(--angle, -135) * 1deg)) !important; }
.dk.is-demo { animation: none !important; --fill: 0 !important; }
.dk.is-demo .dk__value { visibility: visible !important; }
.dk.is-demo .dk__demoDigits { display: none !important; }
}@property --fill {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dk {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
width: 100%;
}
.dk__ring {
position: relative;
width: 168px;
height: 168px;
flex: 0 0 auto;
}
.dk__ticks {
position: absolute;
inset: 0;
border-radius: 50%;
background: repeating-conic-gradient(from 224deg, rgba(23, 20, 26, 0.85) 0 1.6deg, rgba(23, 20, 26, 0) 1.6deg 27deg);
}
.dk__arc {
position: absolute;
inset: 7px;
border-radius: 50%;
background: conic-gradient(from 225deg, rgba(23, 20, 26, 0.82) 0 calc(var(--fill, 0) * 270deg), rgba(23, 20, 26, 0.1) calc(var(--fill, 0) * 270deg) 270deg, rgba(23, 20, 26, 0) 270deg 360deg);
}
.dk__needle {
position: absolute;
inset: 0;
transform: rotate(calc(var(--angle, -135) * 1deg));
}
.dk__needle::before {
content: "";
position: absolute;
top: 2px;
left: calc(50% - 2px);
width: 4px;
height: 14px;
border-radius: 999px;
background: #17141a;
}
.dk__knobWrap {
position: absolute;
inset: 30px;
display: grid;
place-items: center;
}
.dk__knob {
position: relative;
width: 108px;
height: 108px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
cursor: grab;
touch-action: none;
font-family: inherit;
transform: rotate(calc(var(--angle, -135) * 1deg));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
background: repeating-conic-gradient(rgba(23, 20, 26, 0.24) 0 5deg, rgba(23, 20, 26, 0.05) 5deg 11deg), linear-gradient(145deg, rgba(255, 247, 230, 0.55), rgba(23, 20, 26, 0.28)), #ffffff;
box-shadow: 0 10px 18px rgba(23, 20, 26, 0.35), inset 0 2px 2px rgba(255, 247, 230, 0.55);
}
.dk__knob:active {
cursor: grabbing;
}
.dk__knob:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.dk__knob::before {
content: "";
position: absolute;
inset: 10px;
border-radius: 50%;
background: linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(23, 20, 26, 0.2)), #ffffff;
box-shadow: inset 0 -3px 8px rgba(23, 20, 26, 0.22), inset 0 2px 2px rgba(255, 255, 255, 0.8);
}
.dk__knob::after {
content: "";
position: absolute;
top: 8px;
left: calc(50% - 2.5px);
width: 5px;
height: 26px;
border-radius: 999px;
background: #17141a;
}
.dk__scaleMin, .dk__scaleMax {
position: absolute;
top: 128px;
font-weight: 800;
font-size: 11px;
line-height: 1;
color: #17141a;
}
.dk__scaleMin {
left: 2px;
}
.dk__scaleMax {
right: 2px;
}
.dk__side {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.dk__label {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
letter-spacing: 0.22em;
color: #17141a;
}
.dk__readout {
position: relative;
margin: 0;
height: 44px;
min-width: 108px;
}
.dk__value {
display: flex;
align-items: center;
font-weight: 800;
font-size: clamp(28px, 7.5vw, 38px);
line-height: 1;
color: #17141a;
}
.dk__demoDigits {
display: none;
position: absolute;
inset: 0;
}
.dk__digit {
position: absolute;
inset: 0;
display: flex;
align-items: center;
margin: 0;
padding: 0;
font-style: normal;
font-weight: 800;
font-size: clamp(28px, 7.5vw, 38px);
line-height: 1;
color: #17141a;
}
.dk__hint {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: #17141a;
}
.dk.is-demo .dk__needle, .dk.is-demo .dk__knob {
animation-name: dkSweep;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes dkSweep {
0% {
transform: rotate(-135deg);
}
45% {
transform: rotate(135deg);
}
50% {
transform: rotate(135deg);
}
95% {
transform: rotate(-135deg);
}
100% {
transform: rotate(-135deg);
}
}
.dk.is-demo {
animation-name: dkFill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dkFill {
0% {
--fill: 0;
}
45% {
--fill: 1;
}
50% {
--fill: 1;
}
95% {
--fill: 0;
}
100% {
--fill: 0;
}
}
.dk.is-demo .dk__value {
visibility: hidden;
}
.dk.is-demo .dk__demoDigits {
display: block;
}
.dk.is-demo .dk__digit {
opacity: 0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dk.is-demo .dk__digit--0 {
animation-name: dkDigit-0;
}
.dk.is-demo .dk__digit--1 {
animation-name: dkDigit-1;
}
.dk.is-demo .dk__digit--2 {
animation-name: dkDigit-2;
}
.dk.is-demo .dk__digit--3 {
animation-name: dkDigit-3;
}
.dk.is-demo .dk__digit--4 {
animation-name: dkDigit-4;
}
.dk.is-demo .dk__digit--5 {
animation-name: dkDigit-5;
}
@keyframes dkDigit-0 {
0% {
opacity: 1;
}
9% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dkDigit-1 {
0% {
opacity: 0;
}
9% {
opacity: 1;
}
18% {
opacity: 0;
}
77% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dkDigit-2 {
0% {
opacity: 0;
}
18% {
opacity: 1;
}
27% {
opacity: 0;
}
68% {
opacity: 1;
}
77% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dkDigit-3 {
0% {
opacity: 0;
}
27% {
opacity: 1;
}
36% {
opacity: 0;
}
59% {
opacity: 1;
}
68% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dkDigit-4 {
0% {
opacity: 0;
}
36% {
opacity: 1;
}
45% {
opacity: 0;
}
50% {
opacity: 1;
}
59% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dkDigit-5 {
0% {
opacity: 0;
}
45% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.dk {
gap: 12px;
}
.dk__ring {
width: 128px;
height: 128px;
}
.dk__arc {
inset: 5px;
}
.dk__knobWrap {
inset: 24px;
}
.dk__knob {
width: 80px;
height: 80px;
}
.dk__knob::after {
height: 19px;
top: 6px;
}
.dk__needle::before {
height: 11px;
}
.dk__scaleMin, .dk__scaleMax {
top: 98px;
font-size: 9px;
}
.dk__readout {
height: 36px;
min-width: 84px;
}
}
@media (prefers-reduced-motion: reduce) {
.dk.is-demo .dk__needle {
animation: none !important;
transform: rotate(-135deg) !important;
}
.dk.is-demo .dk__knob {
animation: none !important;
transform: rotate(calc(var(--angle, -135) * 1deg)) !important;
}
.dk.is-demo {
animation: none !important;
--fill: 0 !important;
}
.dk.is-demo .dk__value {
visibility: visible !important;
}
.dk.is-demo .dk__demoDigits {
display: none !important;
}
}var root = document.getElementById('dk');
var ring = document.getElementById('dkRing');
var knob = document.getElementById('dkKnob');
var valueEl = document.getElementById('dkValue');
var MIN = -135, MAX = 135; // 도(°). 0도는 정위(12시 방향)
var VAL_TEXT = '{v}퍼센트';
var value = 0;
var dragging = false;
function apply() {
var angle = MIN + (MAX - MIN) * value / 100; // 백분율 값을 각도로
root.style.setProperty('--angle', String(Math.round(angle)));
root.style.setProperty('--fill', (value / 100).toFixed(3));
knob.setAttribute('aria-valuenow', String(value));
knob.setAttribute('aria-valuetext', VAL_TEXT.replace('{v}', String(value)));
valueEl.textContent = value + '%';
}
function angleOf(x, y) {
var r = ring.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
var deg = Math.atan2(x - cx, cy - y) * 180 / Math.PI; // 정위가 0도, 시계 방향이 양수
return Math.max(MIN, Math.min(MAX, deg));
}
knob.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
knob.setPointerCapture(e.pointerId);
value = Math.round((angleOf(e.clientX, e.clientY) - MIN) / (MAX - MIN) * 100);
apply();
});
knob.addEventListener('pointermove', function (e) {
if (!dragging) { root.classList.remove('is-demo'); return; }
value = Math.round((angleOf(e.clientX, e.clientY) - MIN) / (MAX - MIN) * 100);
apply();
});
knob.addEventListener('pointerup', function () { dragging = false; });
knob.addEventListener('pointercancel', function () { dragging = false; });
knob.addEventListener('keydown', function (e) {
var step = e.shiftKey ? 10 : 5;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { value = Math.min(100, value + step); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { value = Math.max(0, value - step); }
else if (e.key === 'Home') { value = 0; }
else if (e.key === 'End') { value = 100; }
else { return; }
e.preventDefault();
root.classList.remove('is-demo');
apply();
});
apply();// DialKnob — vanilla 판(run/013/demo/03-skeuo-dial-knob/index.html) 의 React 포트.
// 노브를 드래그해 돌리면 각도만큼 값이 오르내리고 눈금 링의 바늘이 따라 섭니다.
// role="slider" + aria-valuemin/max/now, 화살표 키·Home·End 지원. 값·키프레임은 vanilla 와 같다.
// 이벤트 제네릭 대신 로컬 타입으로 선언한다(ambient 타입만 있어 implicit any 를 피한다).
import { useRef, useState } from "react";
import styles from "./DialKnob.module.scss";
export type DialKnobProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = { [k: string]: any };
type PtrEvt = {
clientX: number;
clientY: number;
pointerId: number;
currentTarget: { setPointerCapture: (id: number) => void };
};
type KeyEvt = { key: string; shiftKey: boolean; preventDefault: () => void };
const MIN = -135; // 도(°). 0도는 정위(12시 방향)
const MAX = 135;
const KNOB_LABEL = "밝기 조절 다이얼";
const LABEL_TEXT = "밝기";
const HINT_TEXT = "드래그나 화살표 키로 조절";
const VAL_TEXT = "{v}퍼센트";
const DIGITS = ["0%", "20%", "40%", "60%", "80%", "100%"];
export default function DialKnob({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)", // = $ease-spring
color = "#ffffff",
}: DialKnobProps) {
const [value, setValue] = useState(0);
const [demo, setDemo] = useState(true);
const ringRef = useRef<HTMLDivElement | null>(null);
const dragging = useRef(false);
const angle = Math.round(MIN + (MAX - MIN) * value / 100); // 백분율 값을 각도로
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--angle": String(angle),
"--fill": (value / 100).toFixed(3),
};
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
function angleOf(x: number, y: number): number {
const el = ringRef.current;
if (!el) { return MIN; }
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const deg = Math.atan2(x - cx, cy - y) * 180 / Math.PI; // 정위가 0도, 시계 방향이 양수
return Math.max(MIN, Math.min(MAX, deg));
}
function valueAt(x: number, y: number): number {
return Math.round((angleOf(x, y) - MIN) / (MAX - MIN) * 100);
}
function onPointerDown(e: PtrEvt) {
setDemo(false);
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
setValue(valueAt(e.clientX, e.clientY));
}
function onPointerMove(e: PtrEvt) {
if (!dragging.current) {
setDemo(false);
return;
}
setValue(valueAt(e.clientX, e.clientY));
}
function onPointerUp() {
dragging.current = false;
}
function onKeyDown(e: KeyEvt) {
const step = e.shiftKey ? 10 : 5;
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
setValue((v) => Math.min(100, v + step));
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
setValue((v) => Math.max(0, v - step));
} else if (e.key === "Home") {
setValue(0);
} else if (e.key === "End") {
setValue(100);
} else {
return;
}
e.preventDefault();
setDemo(false);
}
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.ring} ref={ringRef}>
<span className={styles.ticks} aria-hidden="true"></span>
<span className={styles.arc} aria-hidden="true"></span>
<span className={styles.needle} aria-hidden="true"></span>
<div className={styles.knobWrap}>
<button
className={styles.knob}
type="button"
role="slider"
aria-label={KNOB_LABEL}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
aria-valuetext={VAL_TEXT.replace("{v}", String(value))}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
></button>
</div>
<span className={styles.scaleMin} aria-hidden="true">0</span>
<span className={styles.scaleMax} aria-hidden="true">100</span>
</div>
<div className={styles.side}>
<p className={styles.label}>{LABEL_TEXT}</p>
<p className={styles.readout}>
<b className={styles.value}>{value}%</b>
<span className={styles.demoDigits} aria-hidden="true">
{DIGITS.map((d, i) => (
<i className={`${styles.digit} ${styles["digit" + String(i)]}`} key={d}>{d}</i>
))}
</span>
</p>
<p className={styles.hint}>{HINT_TEXT}</p>
</div>
</div>
</main>
);
}03 · 회전 다이얼 노브 — 측정 누적면적 13.382% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
conic-gradient 로 그린 눈금 링과 진행 호
눈금은 repeating-conic-gradient 가 27도 간격으로 찍어 가동 폭 양끝까지 11개가 놓이고, 진행 호는 conic-gradient 가 --fill(0~1) 만큼의 270도 부채꼴을 채우는데, --fill 이 @property 에 <number> 로 등록돼 있어 값이 숫자답게 보간됩니다.
.dk__ticks {
position: absolute; inset: 0; border-radius: 50%;
background: repeating-conic-gradient(from 224deg,
rgba($subject-ink, .85) 0 1.6deg, rgba($subject-ink, 0) 1.6deg 27deg);
}
.dk__arc {
position: absolute; inset: 7px; border-radius: 50%;
background: conic-gradient(from 225deg,
rgba($subject-ink, .82) 0 calc(var(--fill, 0) * 270deg),
rgba($subject-ink, .10) calc(var(--fill, 0) * 270deg) 270deg,
rgba($subject-ink, 0) 270deg 360deg);
}
atan2 의 인자를 뒤집는 이유
바늘 각도는 Math.atan2(x - cx, cy - y) 로 정위(12시)를 0도·시계 방향을 양수로 맞추는데, 인자를 원래 순서로 두면 0점과 도는 방향이 화면과 어긋나고, 이 판의 애니메이션 9개는 아홉 개 가운데 가장 많으며 누적면적은 13.382% 입니다.
function apply() {
var angle = MIN + (MAX - MIN) * value / 100; // 백분율 값을 각도로
root.style.setProperty('--angle', String(Math.round(angle)));
root.style.setProperty('--fill', (value / 100).toFixed(3));
knob.setAttribute('aria-valuenow', String(value));
knob.setAttribute('aria-valuetext', VAL_TEXT.replace('{v}', String(value)));
valueEl.textContent = value + '%';
}
function angleOf(x, y) {
var r = ring.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
var deg = Math.atan2(x - cx, cy - y) * 180 / Math.PI; // 정위가 0도, 시계 방향이 양수
return Math.max(MIN, Math.min(MAX, deg));
}
04밀기 페이더 슬라이더
채널 페이더를 좌우로 미는 믹서 콘솔의 조절기로, 값 0~10 은 aria-valuenow 와 눈금 아래 숫자에 함께 나타납니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-faders" viewBox="0 0 256 256" fill="currentColor"><g><path d="M200 40v176H56V40Z" opacity=".2"/><path d="M136 120v96a8 8 0 0 1-16 0v-96a8 8 0 0 1 16 0m64 72a8 8 0 0 0-8 8v16a8 8 0 0 0 16 0v-16a8 8 0 0 0-8-8m24-32h-16V40a8 8 0 0 0-16 0v120h-16a8 8 0 0 0 0 16h48a8 8 0 0 0 0-16m-168 0a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m24-32H64V40a8 8 0 0 0-16 0v88H32a8 8 0 0 0 0 16h48a8 8 0 0 0 0-16m72-48h-16V40a8 8 0 0 0-16 0v40h-16a8 8 0 0 0 0 16h48a8 8 0 0 0 0-16"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="pf is-demo" id="pf">
<p class="pf__hint">
<svg class="pf__ico" aria-hidden="true"><use href="#ico-faders"/></svg>
<span>좌우로 밀어 보세요</span>
</p>
<button class="pf__well" id="well" type="button" role="slider" tabindex="0"
aria-label="채널 페이더" aria-orientation="horizontal"
aria-valuemin="0" aria-valuemax="10" aria-valuenow="5">
<span class="pf__slot"></span>
<span class="pf__capWrap" id="capWrap"><span class="pf__cap"><i class="pf__stripe"></i></span></span>
</button>
<div class="pf__scale" aria-hidden="true">
<span class="pf__num">0</span><span class="pf__num">1</span><span class="pf__num">2</span><span class="pf__num">3</span><span class="pf__num">4</span><span class="pf__num">5</span><span class="pf__num">6</span><span class="pf__num">7</span><span class="pf__num">8</span><span class="pf__num">9</span><span class="pf__num">10</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 밀기 페이더 슬라이더 — 믹서 채널 페이더를 좌우로 민다. 끝에 닿으면 쿠션 이징($easing)으로
// 캡이 살짝 눌렸다(scaleX) 멈춘다. 값은 눈금 아래 숫자 0 부터 10 까지 + aria-valuenow 로 읽힌다.
// 재질감은 그라디언트 하이라이트와 inset 그림자로: 잉크 패널 위 크림빛 캡, 가운데 파란 줄.
// .is-demo 루프는 캡이 왼쪽 끝에서 오른쪽 끝으로 갔다 돌아오고 양 끝에서 눌린다(2초 한 바퀴).
// 사람이 포인터나 키로 만지면 JS 가 .is-demo 를 뗀다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-paper);
.pf { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 352px; }
.pf__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.pf__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
// 채널 패널 — 잉크 몸체에 위에서 비친 하이라이트 한 줄
.pf__well {
position: relative; overflow: hidden; width: 100%; height: clamp(72px, 20vw, 88px);
border: 0; border-radius: $r-card; padding: 0; cursor: grab; touch-action: none;
background: linear-gradient(180deg, rgba($subject-cream, .16), rgba($subject-cream, 0) 42%), $subject-ink;
box-shadow: $shadow-raised, inset 0 1px 0 rgba($subject-cream, .22), inset 0 -2px 0 rgba(0, 0, 0, .45);
}
.pf__well:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.pf__well:active { cursor: grabbing; }
// 홈이 팬 슬롯 — 페이더 캡이 다니는 길
.pf__slot {
position: absolute; left: $sp-2; right: $sp-2; top: 50%; height: clamp(14px, 4vw, 18px);
transform: translateY(-50%); border-radius: $r-pill;
background: rgba(0, 0, 0, .5);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, .7), inset 0 -1px 0 rgba($subject-cream, .14);
}
// 캡 래퍼는 슬롯 전체 폭. translateX 의 % 기준을 슬롯 폭으로 잡기 위해 분리했다
.pf__capWrap {
position: absolute; left: $sp-2; right: $sp-2; top: $sp-1; bottom: $sp-1;
transform: translateX(calc(var(--x, 0px)));
}
.pf__cap {
position: absolute; left: 0; top: 0; width: 48px; height: 100%;
border-radius: $r-control;
background:
linear-gradient(90deg, rgba($subject-ink, .38) 0 4px, rgba($subject-ink, 0) 4px 10px),
linear-gradient(180deg, rgba($stage-paper, 1) 0 18%, rgba($subject-ink, .22) 52%, rgba($stage-paper, .92) 100%);
box-shadow: 0 6px 10px rgba(0, 0, 0, .5), inset 0 1px 0 rgba(255, 255, 255, .9), inset 0 -2px 0 rgba(0, 0, 0, .25);
transition: transform $duration $easing;
}
// 캡은 스스로 완결한 요소라 눌림(scaleX)도 자기 중심에서 일어난다
.pf__cap.is-press { transform: scaleX(.82); }
.pf__stripe {
position: absolute; left: 10px; right: 10px; top: 50%; height: 4px;
transform: translateY(-50%); border-radius: $r-pill;
background: $subject-blue; box-shadow: inset 0 1px 0 rgba($subject-cream, .55), 0 1px 2px rgba(0, 0, 0, .4);
}
// 눈금 아래 숫자 — 값은 여기에도 표시된다(색만이 아니라 숫자로)
.pf__scale { display: flex; width: 100%; padding: 0 $sp-2; box-sizing: border-box; }
.pf__num {
flex: 1; text-align: center; margin: 0; border-radius: $r-xs;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.6; color: rgba($subject-ink, .78);
}
.pf__num.is-on { font-weight: 800; color: $subject-ink; box-shadow: inset 0 -2px 0 $subject-blue; }
// ── 미리보기 루프(2초 한 바퀴) ─────────────────────────────────────
// 이동(래퍼)과 쿠션 눌림(캡)을 분리: 래퍼를 scaleX 하면 끝에 있는 캡이 중심쪽으로 밀려
// 눌림이 아니라 옆걸음처럼 보인다. 낱개 속성으로 쓴다(단축은 delay 를 0 으로 리셋한다).
.pf.is-demo .pf__capWrap {
animation-name: pfTravel; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.pf.is-demo .pf__cap {
animation-name: pfCushion; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes pfTravel {
0% { transform: translateX(0); }
5% { transform: translateX(0); }
44% { transform: translateX(calc(100% - 48px)); }
55% { transform: translateX(calc(100% - 48px)); }
89% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes pfCushion {
0% { transform: scaleX(1); }
3% { transform: scaleX(.82); }
9% { transform: scaleX(1); }
44% { transform: scaleX(1); }
48% { transform: scaleX(.82); }
54% { transform: scaleX(1); }
89% { transform: scaleX(1); }
93% { transform: scaleX(.82); }
97% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@media (max-width: 340px) {
.pf { gap: $sp-2; }
.pf__well { height: 64px; }
.pf__slot { height: 12px; }
}
// reduce 에서는 루프를 끄고 가운데 값의 정적 한 장으로 둔다
@media (prefers-reduced-motion: reduce) {
.pf.is-demo .pf__capWrap { animation: none !important; }
.pf.is-demo .pf__cap { animation: none !important; }
.pf__cap { transition: 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);
}
.pf {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 352px;
}
.pf__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.pf__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.pf__well {
position: relative;
overflow: hidden;
width: 100%;
height: clamp(72px, 20vw, 88px);
border: 0;
border-radius: 16px;
padding: 0;
cursor: grab;
touch-action: none;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.16), rgba(255, 247, 230, 0) 42%), #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 1px 0 rgba(255, 247, 230, 0.22), inset 0 -2px 0 rgba(0, 0, 0, 0.45);
}
.pf__well:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pf__well:active {
cursor: grabbing;
}
.pf__slot {
position: absolute;
left: 8px;
right: 8px;
top: 50%;
height: clamp(14px, 4vw, 18px);
transform: translateY(-50%);
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.7), inset 0 -1px 0 rgba(255, 247, 230, 0.14);
}
.pf__capWrap {
position: absolute;
left: 8px;
right: 8px;
top: 4px;
bottom: 4px;
transform: translateX(calc(var(--x, 0px)));
}
.pf__cap {
position: absolute;
left: 0;
top: 0;
width: 48px;
height: 100%;
border-radius: 12px;
background: linear-gradient(90deg, rgba(23, 20, 26, 0.38) 0 4px, rgba(23, 20, 26, 0) 4px 10px), linear-gradient(180deg, white 0 18%, rgba(23, 20, 26, 0.22) 52%, rgba(255, 255, 255, 0.92) 100%);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.9), inset 0 -2px 0 rgba(0, 0, 0, 0.25);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pf__cap.is-press {
transform: scaleX(0.82);
}
.pf__stripe {
position: absolute;
left: 10px;
right: 10px;
top: 50%;
height: 4px;
transform: translateY(-50%);
border-radius: 999px;
background: #2f6df6;
box-shadow: inset 0 1px 0 rgba(255, 247, 230, 0.55), 0 1px 2px rgba(0, 0, 0, 0.4);
}
.pf__scale {
display: flex;
width: 100%;
padding: 0 8px;
box-sizing: border-box;
}
.pf__num {
flex: 1;
text-align: center;
margin: 0;
border-radius: 4px;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.6;
color: rgba(23, 20, 26, 0.78);
}
.pf__num.is-on {
font-weight: 800;
color: #17141a;
box-shadow: inset 0 -2px 0 #2f6df6;
}
.pf.is-demo .pf__capWrap {
animation-name: pfTravel;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pf.is-demo .pf__cap {
animation-name: pfCushion;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes pfTravel {
0% {
transform: translateX(0);
}
5% {
transform: translateX(0);
}
44% {
transform: translateX(calc(100% - 48px));
}
55% {
transform: translateX(calc(100% - 48px));
}
89% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes pfCushion {
0% {
transform: scaleX(1);
}
3% {
transform: scaleX(0.82);
}
9% {
transform: scaleX(1);
}
44% {
transform: scaleX(1);
}
48% {
transform: scaleX(0.82);
}
54% {
transform: scaleX(1);
}
89% {
transform: scaleX(1);
}
93% {
transform: scaleX(0.82);
}
97% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@media (max-width: 340px) {
.pf {
gap: 8px;
}
.pf__well {
height: 64px;
}
.pf__slot {
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pf.is-demo .pf__capWrap {
animation: none !important;
}
.pf.is-demo .pf__cap {
animation: none !important;
}
.pf__cap {
transition: none !important;
}
}(function () {
var root = document.getElementById('pf');
var well = document.getElementById('well');
var wrap = document.getElementById('capWrap');
var cap = wrap.querySelector('.pf__cap');
var nums = [].slice.call(document.querySelectorAll('.pf__num'));
var MIN = 0, MAX = 10;
var value = 5;
var lastPx = 0;
var pressTimer = 0;
function travel() { return Math.max(0, wrap.clientWidth - cap.offsetWidth); }
function human() { root.classList.remove('is-demo'); }
function cushion() {
cap.classList.add('is-press');
clearTimeout(pressTimer);
pressTimer = setTimeout(function () { cap.classList.remove('is-press'); }, 180);
}
function apply(px) {
wrap.style.setProperty('--x', px + 'px');
lastPx = px;
value = travel() > 0 ? Math.round(px / travel() * MAX) : 0;
well.setAttribute('aria-valuenow', String(value));
nums.forEach(function (n, i) { n.classList.toggle('is-on', i === value); });
}
apply(value / MAX * travel());
var startX = 0, startPx = 0;
well.addEventListener('pointerdown', function (e) {
human();
well.setPointerCapture(e.pointerId);
startX = e.clientX;
startPx = lastPx;
e.preventDefault();
});
well.addEventListener('pointermove', function (e) {
if (!well.hasPointerCapture(e.pointerId)) { return; }
var t = travel();
var px = Math.min(t, Math.max(0, startPx + (e.clientX - startX)));
if ((px === 0 && lastPx > 0) || (px === t && lastPx < t)) { cushion(); }
apply(px);
});
well.addEventListener('pointerup', function (e) {
if (well.hasPointerCapture(e.pointerId)) { well.releasePointerCapture(e.pointerId); }
});
well.addEventListener('keydown', function (e) {
var next = null;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { next = Math.min(MAX, value + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { next = Math.max(MIN, value - 1); }
else if (e.key === 'Home') { next = MIN; }
else if (e.key === 'End') { next = MAX; }
if (next === null) { return; }
e.preventDefault();
human();
if (next === MIN || next === MAX) { cushion(); }
apply(next / MAX * travel());
});
})();// PushFader — vanilla 판(run/013/demo/04-skeuo-push-slider) 의 React 포트.
// 좌우 드래그·화살표로 캡을 밀고 끝에서 쿠션 눌림이 일어난다. 값·타이밍은 vanilla 와 같다.
// duration·easing·color 는 props 로 받아 CSS 변수로 들어간다(기본값은 vanilla 의 토큰값).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./PushFader.module.scss";
export type PushFaderProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type WrapRef = { current: HTMLSpanElement | null };
type DragState = { current: { startX: number; startPx: number; lastPx: number; active: boolean } };
type PtrEvt = ReactPointerEvent<HTMLButtonElement>;
type KeyEvt = { key: string; preventDefault(): void };
const MAX = 10;
const CAP_W = 48; // .cap 의 고정 폭(vanilla 와 같은 값)
const HINT = "좌우로 밀어 보세요";
const ARIA_LABEL = "채널 페이더";
export default function PushFader({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffffff",
}: PushFaderProps) {
const wrapRef = useRef(null) as WrapRef;
const drag = useRef({ startX: 0, startPx: 0, lastPx: 0, active: false }) as DragState;
const [demo, setDemo] = useState(true);
const [value, setValue] = useState(5);
const [press, setPress] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function travelPx(): number {
const wrap = wrapRef.current;
return wrap ? Math.max(0, wrap.clientWidth - CAP_W) : 0;
}
function applyPx(px: number) {
const wrap = wrapRef.current;
if (wrap) { wrap.style.setProperty("--x", `${px}px`); }
drag.current.lastPx = px;
const t = travelPx();
setValue(t > 0 ? Math.round((px / t) * MAX) : 0);
}
function cushion() {
setPress(true);
window.setTimeout(() => { setPress(false); }, 180);
}
useEffect(() => {
applyPx((5 / MAX) * travelPx());
}, []);
function onPointerDown(e: PtrEvt) {
setDemo(false);
drag.current = { startX: e.clientX, startPx: drag.current.lastPx, lastPx: drag.current.lastPx, active: true };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onPointerMove(e: PtrEvt) {
if (!drag.current.active) { return; }
const t = travelPx();
const px = Math.min(t, Math.max(0, drag.current.startPx + (e.clientX - drag.current.startX)));
if ((px === 0 && drag.current.lastPx > 0) || (px === t && drag.current.lastPx < t)) { cushion(); }
applyPx(px);
}
function onPointerUp(e: PtrEvt) {
if (!drag.current.active) { return; }
drag.current.active = false;
if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); }
}
function onKeyDown(e: KeyEvt) {
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowUp") { next = Math.min(MAX, value + 1); }
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { next = Math.max(0, value - 1); }
else if (e.key === "Home") { next = 0; }
else if (e.key === "End") { next = MAX; }
if (next === null) { return; }
e.preventDefault();
setDemo(false);
if (next === 0 || next === MAX) { cushion(); }
applyPx((next / MAX) * travelPx());
}
const nums: number[] = [];
for (let i = 0; i <= MAX; i++) { nums.push(i); }
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-faders" viewBox="0 0 256 256" fill="currentColor"><g><path d="M200 40v176H56V40Z" opacity=".2"/><path d="M136 120v96a8 8 0 0 1-16 0v-96a8 8 0 0 1 16 0m64 72a8 8 0 0 0-8 8v16a8 8 0 0 0 16 0v-16a8 8 0 0 0-8-8m24-32h-16V40a8 8 0 0 0-16 0v120h-16a8 8 0 0 0 0 16h48a8 8 0 0 0 0-16m-168 0a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m24-32H64V40a8 8 0 0 0-16 0v88H32a8 8 0 0 0 0 16h48a8 8 0 0 0 0-16m72-48h-16V40a8 8 0 0 0-16 0v40h-16a8 8 0 0 0 0 16h48a8 8 0 0 0 0-16"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-faders" /></svg>
<span>{HINT}</span>
</p>
<button
className={styles.well}
type="button"
role="slider"
tabIndex={0}
aria-label={ARIA_LABEL}
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={MAX}
aria-valuenow={value}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<span className={styles.slot} />
<span className={styles.capWrap} ref={wrapRef}>
<span className={press ? `${styles.cap} ${styles.press}` : styles.cap}>
<i className={styles.stripe} />
</span>
</span>
</button>
<div className={styles.scale} aria-hidden="true">
{nums.map((n) => (
<span key={n} className={n === value ? `${styles.num} ${styles.on}` : styles.num}>{n}</span>
))}
</div>
</div>
</main>
);
}04 · 밀기 페이더 슬라이더 — 측정 누적면적 16.537% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이동과 눌림을 두 요소에 나눈 설계
캡의 이동은 래퍼의 translateX 로, 끝에서 눌리는 쿠션은 캡 자신의 scaleX(.82) 로 나눴는데, scaleX 는 제 중심을 기준으로 줄어들기 때문에 한 요소에 합치면 눌림이 중앙 쪽으로 밀리는 그림이 됩니다.
@keyframes pfTravel {
0% { transform: translateX(0); }
5% { transform: translateX(0); }
44% { transform: translateX(calc(100% - 48px)); }
55% { transform: translateX(calc(100% - 48px)); }
89% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes pfCushion {
0% { transform: scaleX(1); }
3% { transform: scaleX(.82); }
9% { transform: scaleX(1); }
44% { transform: scaleX(1); }
48% { transform: scaleX(.82); }
54% { transform: scaleX(1); }
89% { transform: scaleX(1); }
93% { transform: scaleX(.82); }
97% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
끝에 닿아야만 울리는 쿠션
쿠션은 드래그든 키보드든 캡이 실제로 끝에 닿는 순간에만 180ms 동안 붙었다 떨어지고, 누적면적 16.537% 는 조절기 두 판 가운데 더 넓게 움직이는 값입니다.
function travel() { return Math.max(0, wrap.clientWidth - cap.offsetWidth); }
function human() { root.classList.remove('is-demo'); }
function cushion() {
cap.classList.add('is-press');
clearTimeout(pressTimer);
pressTimer = setTimeout(function () { cap.classList.remove('is-press'); }, 180);
}
function apply(px) {
wrap.style.setProperty('--x', px + 'px');
lastPx = px;
value = travel() > 0 ? Math.round(px / travel() * MAX) : 0;
well.setAttribute('aria-valuenow', String(value));
nums.forEach(function (n, i) { n.classList.toggle('is-on', i === value); });
}
apply(value / MAX * travel());
05비상 뱅 버튼
붉은 돔을 누르면 원근이 걸린 장면 안으로 가라앉으며(translateZ(-120px)) 경고등이 켜지고, 눌림은 대기·가동 글자와 aria-pressed 로 전달됩니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-warning" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 91.55v72.9a8 8 0 0 1-2.34 5.66l-51.55 51.55a8 8 0 0 1-5.66 2.34h-72.9a8 8 0 0 1-5.66-2.34l-51.55-51.55a8 8 0 0 1-2.34-5.66v-72.9a8 8 0 0 1 2.34-5.66l51.55-51.55A8 8 0 0 1 91.55 32h72.9a8 8 0 0 1 5.66 2.34l51.55 51.55a8 8 0 0 1 2.34 5.66" opacity=".2"/><path d="M120 136V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m112-44.45v72.9a15.86 15.86 0 0 1-4.69 11.31l-51.55 51.55a15.86 15.86 0 0 1-11.31 4.69h-72.9a15.86 15.86 0 0 1-11.31-4.69l-51.55-51.55A15.86 15.86 0 0 1 24 164.45v-72.9a15.86 15.86 0 0 1 4.69-11.31l51.55-51.55A15.86 15.86 0 0 1 91.55 24h72.9a15.86 15.86 0 0 1 11.31 4.69l51.55 51.55A15.86 15.86 0 0 1 232 91.55m-16 0L164.45 40h-72.9L40 91.55v72.9L91.55 216h72.9L216 164.45ZM128 160a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="bb is-demo" id="bb">
<p class="bb__hint">
<svg class="bb__ico" aria-hidden="true"><use href="#ico-warning"/></svg>
<span>눌러 보세요</span>
</p>
<div class="bb__scene">
<div class="bb__panel">
<span class="bb__lampWrap">
<i class="bb__lamp"><i class="bb__lampGlow"></i></i>
<span class="bb__lampText">경고</span>
</span>
<div class="bb__well">
<i class="bb__shadow"></i>
<button class="bb__btn" id="btn" type="button" aria-pressed="false" aria-label="비상 정지 버튼">
<i class="bb__dome"></i>
</button>
</div>
<p class="bb__state">
<span class="bb__stateIdle">대기</span>
<span class="bb__stateHit">가동</span>
</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 비상 뱅 버튼 — 붉은 버튼을 누르면 원근 장면(perspective) 안으로 깊이 가라앉는다(translateZ).
// 가라앉으며 받침 그림자가 좁아지고(scaleX) 경고등이 켠다. 눌림은 색이 아니라 글자(대기/가동)와
// aria-pressed 로도 전달된다. 정적 경로는 transition 으로, 격자 미리보기(.is-demo)만 루프 키프레임.
// 사람이 버튼을 누르면 JS 가 .is-demo 를 뗀다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $error;
@include stage($stage-ink);
.bb { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; max-width: 340px; }
.bb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.bb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue-light; }
.bb__scene { display: flex; justify-content: center; }
.bb__panel {
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border-radius: $r-card; padding: $sp-2 $sp-4 $sp-3;
background: linear-gradient(180deg, rgba($stage-paper, .5), rgba($stage-paper, 0) 30%), $subject-cream;
box-shadow: $shadow-raised, inset 0 1px 0 rgba(255, 255, 255, .8), inset 0 -3px 0 rgba($subject-ink, .16);
}
.bb__lampWrap { display: inline-flex; align-items: center; gap: $sp-1; }
.bb__lamp {
position: relative; width: 14px; height: 14px; border-radius: 50%;
background: $error-soft; box-shadow: inset 0 2px 3px rgba($subject-ink, .4), inset 0 -1px 0 rgba(255, 255, 255, .7);
transition: background $duration $easing, box-shadow $duration $easing;
}
.bb__lampGlow {
position: absolute; inset: -4px; border-radius: 50%;
background: radial-gradient(circle, rgba($color, .85), rgba($color, 0) 70%);
opacity: 0;
}
.bb__lampText {
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.6; color: $subject-ink;
}
.bb.is-on .bb__lamp {
background: $color;
box-shadow: 0 0 0 2px rgba($color, .28), 0 0 14px 2px rgba($color, .65), inset 0 2px 3px rgba($subject-ink, .35);
}
// 우물 — 버튼이 들어앉은 받침. perspective 는 이 상자에 걸고 버튼은 직계 자식이어야 원근이 먹는다
.bb__well { position: relative; display: flex; justify-content: center; perspective: 600px; }
.bb__shadow {
position: absolute; left: 50%; bottom: -8px; width: 92%; height: 14px;
transform: translateX(-50%) scaleX(1); border-radius: 50%;
background: rgba($subject-ink, .34);
transition: transform $duration $easing, opacity $duration $easing;
}
.bb__btn {
position: relative; display: block; width: clamp(88px, 24vw, 108px); height: clamp(64px, 17vw, 80px);
border: 0; border-radius: 50%; padding: 0; cursor: pointer; touch-action: manipulation;
background:
radial-gradient(120% 90% at 32% 22%, rgba(255, 255, 255, .5), rgba(255, 255, 255, 0) 46%),
radial-gradient(140% 120% at 50% 118%, rgba($subject-ink, .5), rgba($subject-ink, 0) 52%),
$color;
box-shadow: 0 10px 0 rgba($subject-ink, .3), 0 14px 18px rgba($subject-ink, .35), inset 0 2px 3px rgba(255, 255, 255, .55), inset 0 -4px 6px rgba($subject-ink, .45);
transition: transform $duration $easing, box-shadow $duration $easing;
}
.bb__btn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.bb__dome {
position: absolute; left: 50%; top: 14%; width: 58%; height: 58%;
transform: translateX(-50%); border-radius: 50%;
background: radial-gradient(100% 100% at 34% 26%, rgba(255, 255, 255, .65), rgba(255, 255, 255, 0) 55%);
}
// 눌림 — 장면 깊이로 가라앉는다. 받침 그림자는 같은 타이밍에 좁아진다
.bb__btn[aria-pressed="true"] { transform: translateZ(-120px); }
.bb.is-on .bb__shadow { transform: translateX(-50%) scaleX(.55); opacity: .55; }
.bb__state { margin: $sp-2 0 0; font-weight: 800; font-size: clamp(11px, 3vw, 13px); line-height: 1.6; }
.bb__stateIdle { color: $subject-ink; }
.bb__stateHit { color: $error; }
.bb:not(.is-on) .bb__stateHit { display: none; }
.bb.is-on .bb__stateIdle { display: none; }
// ── 미리보기 루프(2초 한 바퀴) ─────────────────────────────────────
// 가라앉기(6% 부터) → 점등 hold 와 경고등 깜빡임 → 46% 부터 돌아온다(복귀는 90% 안에).
// 상태 글자 교체는 steps(1,end) 로 한 프레임에 컷 — 반투명 유령 프레임을 만들지 않는다.
.bb.is-demo .bb__btn {
animation-name: bbSink; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.bb.is-demo .bb__shadow {
animation-name: bbShadow; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.bb.is-demo .bb__lampGlow {
animation-name: bbBlink; animation-duration: $dur-loop;
animation-timing-function: linear; animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateIdle, .bb.is-demo .bb__stateHit { display: inline; }
.bb.is-demo .bb__stateIdle {
animation-name: bbIdleCut; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateHit {
animation-name: bbHitCut; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes bbSink {
0%, 6% { transform: translateZ(0); }
14%, 46% { transform: translateZ(-120px); }
54%, 100% { transform: translateZ(0); }
}
@keyframes bbShadow {
0%, 6% { transform: translateX(-50%) scaleX(1); opacity: 1; }
14%, 46% { transform: translateX(-50%) scaleX(.55); opacity: .55; }
54%, 100% { transform: translateX(-50%) scaleX(1); opacity: 1; }
}
@keyframes bbBlink {
0%, 9% { opacity: 0; }
12% { opacity: .45; }
18% { opacity: 1; }
24% { opacity: .5; }
30% { opacity: 1; }
36% { opacity: .5; }
42% { opacity: 1; }
47%, 100% { opacity: 0; }
}
@keyframes bbIdleCut {
0%, 13% { opacity: 1; }
14%, 46% { opacity: 0; }
47%, 100% { opacity: 1; }
}
@keyframes bbHitCut {
0%, 13% { opacity: 0; }
14%, 46% { opacity: 1; }
47%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.bb { gap: $sp-2; }
.bb__panel { padding: $sp-2 $sp-3; gap: 0; }
.bb__shadow { bottom: -4px; height: 10px; }
}
// reduce 에서는 루프를 끄고 버튼이 올라온 정적 한 장으로 둔다
@media (prefers-reduced-motion: reduce) {
.bb.is-demo .bb__btn { animation: none !important; }
.bb.is-demo .bb__shadow { animation: none !important; }
.bb.is-demo .bb__lampGlow { animation: none !important; opacity: 0 !important; }
.bb.is-demo .bb__stateIdle { animation: none !important; }
.bb.is-demo .bb__stateHit { animation: none !important; }
.bb__btn { transition: none !important; }
.bb__shadow { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bb {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 340px;
}
.bb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.bb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #5b8cf8;
}
.bb__scene {
display: flex;
justify-content: center;
}
.bb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border-radius: 16px;
padding: 8px 16px 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 30%), #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.8), inset 0 -3px 0 rgba(23, 20, 26, 0.16);
}
.bb__lampWrap {
display: inline-flex;
align-items: center;
gap: 4px;
}
.bb__lamp {
position: relative;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffe0d8;
box-shadow: inset 0 2px 3px rgba(23, 20, 26, 0.4), inset 0 -1px 0 rgba(255, 255, 255, 0.7);
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__lampGlow {
position: absolute;
inset: -4px;
border-radius: 50%;
background: radial-gradient(circle, rgba(194, 54, 26, 0.85), rgba(194, 54, 26, 0) 70%);
opacity: 0;
}
.bb__lampText {
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.6;
color: #17141a;
}
.bb.is-on .bb__lamp {
background: #c2361a;
box-shadow: 0 0 0 2px rgba(194, 54, 26, 0.28), 0 0 14px 2px rgba(194, 54, 26, 0.65), inset 0 2px 3px rgba(23, 20, 26, 0.35);
}
.bb__well {
position: relative;
display: flex;
justify-content: center;
perspective: 600px;
}
.bb__shadow {
position: absolute;
left: 50%;
bottom: -8px;
width: 92%;
height: 14px;
transform: translateX(-50%) scaleX(1);
border-radius: 50%;
background: rgba(23, 20, 26, 0.34);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__btn {
position: relative;
display: block;
width: clamp(88px, 24vw, 108px);
height: clamp(64px, 17vw, 80px);
border: 0;
border-radius: 50%;
padding: 0;
cursor: pointer;
touch-action: manipulation;
background: radial-gradient(120% 90% at 32% 22%, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 46%), radial-gradient(140% 120% at 50% 118%, rgba(23, 20, 26, 0.5), rgba(23, 20, 26, 0) 52%), #c2361a;
box-shadow: 0 10px 0 rgba(23, 20, 26, 0.3), 0 14px 18px rgba(23, 20, 26, 0.35), inset 0 2px 3px rgba(255, 255, 255, 0.55), inset 0 -4px 6px rgba(23, 20, 26, 0.45);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__btn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.bb__dome {
position: absolute;
left: 50%;
top: 14%;
width: 58%;
height: 58%;
transform: translateX(-50%);
border-radius: 50%;
background: radial-gradient(100% 100% at 34% 26%, rgba(255, 255, 255, 0.65), rgba(255, 255, 255, 0) 55%);
}
.bb__btn[aria-pressed=true] {
transform: translateZ(-120px);
}
.bb.is-on .bb__shadow {
transform: translateX(-50%) scaleX(0.55);
opacity: 0.55;
}
.bb__state {
margin: 8px 0 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.6;
}
.bb__stateIdle {
color: #17141a;
}
.bb__stateHit {
color: #c2361a;
}
.bb:not(.is-on) .bb__stateHit {
display: none;
}
.bb.is-on .bb__stateIdle {
display: none;
}
.bb.is-demo .bb__btn {
animation-name: bbSink;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.bb.is-demo .bb__shadow {
animation-name: bbShadow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.bb.is-demo .bb__lampGlow {
animation-name: bbBlink;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateIdle, .bb.is-demo .bb__stateHit {
display: inline;
}
.bb.is-demo .bb__stateIdle {
animation-name: bbIdleCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bb.is-demo .bb__stateHit {
animation-name: bbHitCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes bbSink {
0%, 6% {
transform: translateZ(0);
}
14%, 46% {
transform: translateZ(-120px);
}
54%, 100% {
transform: translateZ(0);
}
}
@keyframes bbShadow {
0%, 6% {
transform: translateX(-50%) scaleX(1);
opacity: 1;
}
14%, 46% {
transform: translateX(-50%) scaleX(0.55);
opacity: 0.55;
}
54%, 100% {
transform: translateX(-50%) scaleX(1);
opacity: 1;
}
}
@keyframes bbBlink {
0%, 9% {
opacity: 0;
}
12% {
opacity: 0.45;
}
18% {
opacity: 1;
}
24% {
opacity: 0.5;
}
30% {
opacity: 1;
}
36% {
opacity: 0.5;
}
42% {
opacity: 1;
}
47%, 100% {
opacity: 0;
}
}
@keyframes bbIdleCut {
0%, 13% {
opacity: 1;
}
14%, 46% {
opacity: 0;
}
47%, 100% {
opacity: 1;
}
}
@keyframes bbHitCut {
0%, 13% {
opacity: 0;
}
14%, 46% {
opacity: 1;
}
47%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.bb {
gap: 8px;
}
.bb__panel {
padding: 8px 12px;
gap: 0;
}
.bb__shadow {
bottom: -4px;
height: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.bb.is-demo .bb__btn {
animation: none !important;
}
.bb.is-demo .bb__shadow {
animation: none !important;
}
.bb.is-demo .bb__lampGlow {
animation: none !important;
opacity: 0 !important;
}
.bb.is-demo .bb__stateIdle {
animation: none !important;
}
.bb.is-demo .bb__stateHit {
animation: none !important;
}
.bb__btn {
transition: none !important;
}
.bb__shadow {
transition: none !important;
}
}(function () {
var root = document.getElementById('bb');
var btn = document.getElementById('btn');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', on ? 'false' : 'true');
root.classList.toggle('is-on', !on);
});
})();// BangButton — vanilla 판(run/013/demo/05-skeuo-bang-button) 의 React 포트.
// 누르면 원근 장면 안으로 translateZ 깊이 가라앉고 받침 그림자가 좁아지며 경고등이 켠다.
// 눌림은 aria-pressed 와 글자(대기/가동) 둘로 전달된다. 값·타이밍은 vanilla 와 같다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BangButton.module.scss";
export type BangButtonProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "눌러 보세요";
const ARIA_LABEL = "비상 정지 버튼";
const LAMP_TEXT = "경고";
const STATE_IDLE = "대기";
const STATE_HIT = "가동";
export default function BangButton({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#c2361a",
}: BangButtonProps) {
const [on, setOn] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function press() {
setDemo(false);
setOn((v) => !v);
}
const cls = [styles.root, demo ? styles.isDemo : "", on ? styles.isOn : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-warning" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 91.55v72.9a8 8 0 0 1-2.34 5.66l-51.55 51.55a8 8 0 0 1-5.66 2.34h-72.9a8 8 0 0 1-5.66-2.34l-51.55-51.55a8 8 0 0 1-2.34-5.66v-72.9a8 8 0 0 1 2.34-5.66l51.55-51.55A8 8 0 0 1 91.55 32h72.9a8 8 0 0 1 5.66 2.34l51.55 51.55a8 8 0 0 1 2.34 5.66" opacity=".2"/><path d="M120 136V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m112-44.45v72.9a15.86 15.86 0 0 1-4.69 11.31l-51.55 51.55a15.86 15.86 0 0 1-11.31 4.69h-72.9a15.86 15.86 0 0 1-11.31-4.69l-51.55-51.55A15.86 15.86 0 0 1 24 164.45v-72.9a15.86 15.86 0 0 1 4.69-11.31l51.55-51.55A15.86 15.86 0 0 1 91.55 24h72.9a15.86 15.86 0 0 1 11.31 4.69l51.55 51.55A15.86 15.86 0 0 1 232 91.55m-16 0L164.45 40h-72.9L40 91.55v72.9L91.55 216h72.9L216 164.45ZM128 160a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-warning" /></svg>
<span>{HINT}</span>
</p>
<div className={styles.scene}>
<div className={styles.panel}>
<span className={styles.lampWrap}>
<i className={styles.lamp}><i className={styles.lampGlow} /></i>
<span className={styles.lampText}>{LAMP_TEXT}</span>
</span>
<div className={styles.well}>
<i className={styles.shadow} />
<button className={styles.btn} type="button" aria-pressed={on} aria-label={ARIA_LABEL} onClick={press}>
<i className={styles.dome} />
</button>
</div>
<p className={styles.state}>
<span className={styles.stateIdle}>{STATE_IDLE}</span>
<span className={styles.stateHit}>{STATE_HIT}</span>
</p>
</div>
</div>
</div>
</main>
);
}05 · 비상 뱅 버튼 — 측정 누적면적 4.902% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
장면의 깊이를 만드는 세 겹
버튼의 플라스틱 재질은 하이라이트 radial-gradient 두 장과 두꺼운 box-shadow 로 쌓았고, 받침 그림자는 버튼과 같은 타이밍에 scaleX(.55) 로 좁아지며 깊이를 함께 말합니다.
.bb__well { position: relative; display: flex; justify-content: center; perspective: 600px; }
.bb__shadow {
position: absolute; left: 50%; bottom: -8px; width: 92%; height: 14px;
transform: translateX(-50%) scaleX(1); border-radius: 50%;
background: rgba($subject-ink, .34);
transition: transform $duration $easing, opacity $duration $easing;
}
.bb__btn {
position: relative; display: block; width: clamp(88px, 24vw, 108px); height: clamp(64px, 17vw, 80px);
border: 0; border-radius: 50%; padding: 0; cursor: pointer; touch-action: manipulation;
background:
radial-gradient(120% 90% at 32% 22%, rgba(255, 255, 255, .5), rgba(255, 255, 255, 0) 46%),
radial-gradient(140% 120% at 50% 118%, rgba($subject-ink, .5), rgba($subject-ink, 0) 52%),
$color;
box-shadow: 0 10px 0 rgba($subject-ink, .3), 0 14px 18px rgba($subject-ink, .35), inset 0 2px 3px rgba(255, 255, 255, .55), inset 0 -4px 6px rgba($subject-ink, .45);
transition: transform $duration $easing, box-shadow $duration $easing;
}
가라앉고 돌아오는 시간표
미리보기에서 버튼은 6% 에 내려가기 시작해 14% 에 바닥에 닿고 경고등 깜빡임(12~47%)이 이어지는데, 강도 평균 35.4 의 차분한 템포는 긴급 정지 컨트롤에 어울립니다.
@keyframes bbSink {
0%, 6% { transform: translateZ(0); }
14%, 46% { transform: translateZ(-120px); }
54%, 100% { transform: translateZ(0); }
}
@keyframes bbShadow {
0%, 6% { transform: translateX(-50%) scaleX(1); opacity: 1; }
14%, 46% { transform: translateX(-50%) scaleX(.55); opacity: .55; }
54%, 100% { transform: translateX(-50%) scaleX(1); opacity: 1; }
}
06당겨 여는 풀 노브
노브를 아래로 당기면 줄기가 늘어나고, 30픽셀 넘게 당겼다 놓으면 잠금이 풀려 덮개가 열리며 노브는 제자리로 돌아가는, 기계 커버·금고 잠금 해제 위젯용 결입니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-hand" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 108v44a80 80 0 0 1-160 0v-12a20 20 0 0 1 20-20h20V92a20 20 0 0 1 40 0a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M188 80a27.8 27.8 0 0 0-13.36 3.4a28 28 0 0 0-46.64-11A28 28 0 0 0 80 92v20H68a28 28 0 0 0-28 28v12a88 88 0 0 0 176 0v-44a28 28 0 0 0-28-28m12 72a72 72 0 0 1-144 0v-12a12 12 0 0 1 12-12h12v24a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="pk is-demo" id="pk">
<p class="pk__hint">
<svg class="pk__ico" aria-hidden="true"><use href="#ico-hand"/></svg>
<span>아래로 당겨 보세요</span>
</p>
<div class="pk__body">
<div class="pk__lidWrap">
<div class="pk__mouth"></div>
<div class="pk__lid" id="lid"><i class="pk__lidFace"></i></div>
</div>
<p class="pk__state">
<i class="pk__dot"></i>
<span class="pk__stateLock">잠김</span>
<span class="pk__stateOpen">열림</span>
</p>
<div class="pk__neck">
<i class="pk__stem"></i>
<button class="pk__knob" id="knob" type="button" aria-expanded="false" aria-controls="lid" aria-label="당겨 여는 잠금 노브">
<i class="pk__grip"></i>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 당겨 여는 풀 노브 — 노브를 아래로 당기면 잠깐 늘어났다가(scaleY, 줄기에 붙어 있으니 원점은 위)
// 잠금이 풀리며 덮개가 열리고(lid, rotateX + perspective), 손을 놓으면 제자리로 돌아간다(transition).
// 열림은 글자(잠김/열림)와 aria-expanded 로도 전달된다. 정적 드래그는 --pull 변수 하나로 당김+늘어남을
// 함께 구동하고, 격자 미리보기(.is-demo)만 루프 키프레임. 사람이 노브를 만지면 JS 가 .is-demo 를 뗀다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pk { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; max-width: 340px; }
.pk__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.pk__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.pk__body {
display: flex; flex-direction: column; align-items: center; width: 100%;
border-radius: $r-card; padding: $sp-3 $sp-4 $sp-2;
background: linear-gradient(180deg, rgba($stage-paper, .55), rgba($stage-paper, 0) 26%), $subject-cream;
box-shadow: $shadow-raised, inset 0 1px 0 rgba(255, 255, 255, .8), inset 0 -3px 0 rgba($subject-ink, .14);
}
// 덮개 — 위 경첩(transform-origin 50% 0)으로 열린다. 붙는 지점이 있는 요소라 비중심 원점을 쓴다
.pk__lidWrap { position: relative; perspective: 520px; width: 100%; }
.pk__mouth {
height: clamp(44px, 12vw, 60px); border-radius: $r-sm;
background: rgba($subject-ink, .92);
box-shadow: inset 0 4px 8px rgba(0, 0, 0, .6), inset 0 -1px 0 rgba($subject-cream, .18);
}
.pk__lid {
position: absolute; inset: 0; border-radius: $r-sm; transform-origin: 50% 0;
transform: rotateX(0deg);
transition: transform $dur-base $ease-pop;
background:
linear-gradient(180deg, rgba($stage-paper, .4), rgba($stage-paper, 0) 40%),
linear-gradient(180deg, $color, rgba($subject-ink, .35));
box-shadow: 0 4px 8px rgba($subject-ink, .35), inset 0 1px 0 rgba(255, 255, 255, .45), inset 0 -4px 0 rgba($subject-ink, .4);
}
.pk__lidFace {
position: absolute; left: 50%; top: 50%; width: 44%; height: 4px;
transform: translate(-50%, -50%); border-radius: $r-pill;
background: rgba($subject-cream, .6); box-shadow: 0 2px 0 rgba($subject-ink, .3);
}
.pk.is-open .pk__lid { transform: rotateX(-76deg); }
// 목 — 줄기와 노브. 당김값 하나(--pull)이 이동과 늘어남을 함께 구동한다.
// 상태 줄은 노브 위(슬롯 아래)에 둔다 — 노브가 아래로 당겨져도 글자를 덮지 않게.
.pk__neck {
display: flex; flex-direction: column; align-items: center; gap: 0;
margin-top: -$sp-1; padding: 0 $sp-3 $sp-2;
}
.pk__stem {
width: 12px; height: clamp(14px, 4vw, 18px);
background: linear-gradient(90deg, rgba($subject-ink, .8), rgba($subject-ink, .45) 50%, rgba($subject-ink, .8));
box-shadow: inset 0 1px 0 rgba($subject-cream, .25);
}
.pk__knob {
position: relative; width: clamp(56px, 15vw, 68px); height: clamp(36px, 10vw, 44px);
border: 0; border-radius: $r-control; padding: 0; cursor: grab; touch-action: none;
transform-origin: 50% 0; // 줄기에 붙어 있어 늘어남은 위 원점에서(비중심 원점 사유)
transform: translateY(calc(var(--pull, 0) * 1px)) scale(1, calc(1 + var(--pull, 0) / 220));
background:
radial-gradient(120% 140% at 30% 0%, rgba($stage-paper, .35), rgba($stage-paper, 0) 50%),
$subject-ink;
box-shadow: 0 6px 10px rgba($subject-ink, .4), inset 0 1px 0 rgba($subject-cream, .3), inset 0 -3px 0 rgba(0, 0, 0, .5);
transition: transform $dur-base $easing;
}
.pk__knob:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.pk.is-drag .pk__knob { transition: none; cursor: grabbing; }
.pk__grip {
position: absolute; left: $sp-2; right: $sp-2; top: 50%; height: 8px;
transform: translateY(-50%); border-radius: $r-pill;
background:
repeating-linear-gradient(90deg, rgba($subject-cream, .8) 0 4px, rgba($subject-cream, .25) 4px 8px);
}
// 상태 — 점 모양 + 글자 둘로 알린다(색만으로 상태를 알리지 않는다)
.pk__state { display: inline-flex; align-items: center; gap: $sp-1; margin: $sp-2 0 $sp-1; }
.pk__dot {
width: 10px; height: 10px; border-radius: 50%;
border: 2px solid $subject-ink; background: transparent;
transition: background $duration $easing;
}
.pk.is-open .pk__dot { background: $subject-ink; }
.pk__stateLock, .pk__stateOpen {
font-weight: 800; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.6; color: $subject-ink;
}
.pk:not(.is-open) .pk__stateOpen { display: none; }
.pk.is-open .pk__stateLock { display: none; }
// ── 미리보기 루프(2초 한 바퀴) ─────────────────────────────────────
// 당김(5 부터 14%) → 늘어남 홀드(잠깐 늘어났다가) → 잠금 풀림과 복귀(23 부터 43%) →
// 덮개는 26% 에 열려 76% 까지 버티고 88% 안에 닫힌다. 상태 글자는 steps(1,end) 로 컷.
.pk.is-demo .pk__knob {
animation-name: pkKnob; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.pk.is-demo .pk__lid {
animation-name: pkLid; animation-duration: $dur-loop;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
}
.pk.is-demo .pk__stateLock, .pk.is-demo .pk__stateOpen { display: inline; }
.pk.is-demo .pk__stateLock {
animation-name: pkLockCut; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.pk.is-demo .pk__stateOpen {
animation-name: pkOpenCut; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.pk.is-demo .pk__dot {
animation-name: pkDotCut; animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes pkKnob {
0%, 5% { transform: translateY(0) scale(1, 1); }
14% { transform: translateY(44px) scale(.93, 1.26); }
19% { transform: translateY(44px) scale(.93, 1.26); }
23% { transform: translateY(44px) scale(1, 1); }
43%, 100% { transform: translateY(0) scale(1, 1); }
}
@keyframes pkLid {
0%, 26% { transform: rotateX(0deg); }
38%, 76% { transform: rotateX(-76deg); }
88%, 100% { transform: rotateX(0deg); }
}
@keyframes pkLockCut {
0%, 25% { opacity: 1; }
26%, 87% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@keyframes pkOpenCut {
0%, 25% { opacity: 0; }
26%, 87% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes pkDotCut {
0%, 25% { background: transparent; }
26%, 87% { background: $subject-ink; }
88%, 100% { background: transparent; }
}
@media (max-width: 340px) {
// 320x200 에서 노브 당김(44px)+늘어남이 뷰포트를 넘지 않게 위쪽 높이를 내린다(올리지 않는다)
.pk { gap: 0; }
.pk__body { padding: $sp-1 $sp-3 $sp-1; }
.pk__mouth { height: 36px; }
.pk__state { margin: $sp-1 0 0; }
.pk__stem { height: 8px; }
.pk__knob { height: 32px; }
.pk__neck { padding: 0 $sp-3 $sp-1; }
}
// reduce 에서는 루프를 끄고 닫힌 정적 한 장으로 둔다
@media (prefers-reduced-motion: reduce) {
.pk.is-demo .pk__knob { animation: none !important; }
.pk.is-demo .pk__lid { animation: none !important; }
.pk.is-demo .pk__stateLock { animation: none !important; }
.pk.is-demo .pk__stateOpen { animation: none !important; }
.pk.is-demo .pk__dot { animation: none !important; }
.pk__knob { transition: none !important; }
.pk__lid { transition: none !important; }
.pk__dot { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pk {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 340px;
}
.pk__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.pk__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.pk__body {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
border-radius: 16px;
padding: 12px 16px 8px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 26%), #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.8), inset 0 -3px 0 rgba(23, 20, 26, 0.14);
}
.pk__lidWrap {
position: relative;
perspective: 520px;
width: 100%;
}
.pk__mouth {
height: clamp(44px, 12vw, 60px);
border-radius: 8px;
background: rgba(23, 20, 26, 0.92);
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.6), inset 0 -1px 0 rgba(255, 247, 230, 0.18);
}
.pk__lid {
position: absolute;
inset: 0;
border-radius: 8px;
transform-origin: 50% 0;
transform: rotateX(0deg);
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0) 40%), linear-gradient(180deg, #2f6df6, rgba(23, 20, 26, 0.35));
box-shadow: 0 4px 8px rgba(23, 20, 26, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.45), inset 0 -4px 0 rgba(23, 20, 26, 0.4);
}
.pk__lidFace {
position: absolute;
left: 50%;
top: 50%;
width: 44%;
height: 4px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: rgba(255, 247, 230, 0.6);
box-shadow: 0 2px 0 rgba(23, 20, 26, 0.3);
}
.pk.is-open .pk__lid {
transform: rotateX(-76deg);
}
.pk__neck {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
margin-top: -4px;
padding: 0 12px 8px;
}
.pk__stem {
width: 12px;
height: clamp(14px, 4vw, 18px);
background: linear-gradient(90deg, rgba(23, 20, 26, 0.8), rgba(23, 20, 26, 0.45) 50%, rgba(23, 20, 26, 0.8));
box-shadow: inset 0 1px 0 rgba(255, 247, 230, 0.25);
}
.pk__knob {
position: relative;
width: clamp(56px, 15vw, 68px);
height: clamp(36px, 10vw, 44px);
border: 0;
border-radius: 12px;
padding: 0;
cursor: grab;
touch-action: none;
transform-origin: 50% 0;
transform: translateY(calc(var(--pull, 0) * 1px)) scale(1, calc(1 + var(--pull, 0) / 220));
background: radial-gradient(120% 140% at 30% 0%, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0) 50%), #17141a;
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.4), inset 0 1px 0 rgba(255, 247, 230, 0.3), inset 0 -3px 0 rgba(0, 0, 0, 0.5);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pk__knob:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pk.is-drag .pk__knob {
transition: none;
cursor: grabbing;
}
.pk__grip {
position: absolute;
left: 8px;
right: 8px;
top: 50%;
height: 8px;
transform: translateY(-50%);
border-radius: 999px;
background: repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.8) 0 4px, rgba(255, 247, 230, 0.25) 4px 8px);
}
.pk__state {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 8px 0 4px;
}
.pk__dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid #17141a;
background: transparent;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pk.is-open .pk__dot {
background: #17141a;
}
.pk__stateLock, .pk__stateOpen {
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.6;
color: #17141a;
}
.pk:not(.is-open) .pk__stateOpen {
display: none;
}
.pk.is-open .pk__stateLock {
display: none;
}
.pk.is-demo .pk__knob {
animation-name: pkKnob;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pk.is-demo .pk__lid {
animation-name: pkLid;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pk.is-demo .pk__stateLock, .pk.is-demo .pk__stateOpen {
display: inline;
}
.pk.is-demo .pk__stateLock {
animation-name: pkLockCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pk.is-demo .pk__stateOpen {
animation-name: pkOpenCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pk.is-demo .pk__dot {
animation-name: pkDotCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes pkKnob {
0%, 5% {
transform: translateY(0) scale(1, 1);
}
14% {
transform: translateY(44px) scale(0.93, 1.26);
}
19% {
transform: translateY(44px) scale(0.93, 1.26);
}
23% {
transform: translateY(44px) scale(1, 1);
}
43%, 100% {
transform: translateY(0) scale(1, 1);
}
}
@keyframes pkLid {
0%, 26% {
transform: rotateX(0deg);
}
38%, 76% {
transform: rotateX(-76deg);
}
88%, 100% {
transform: rotateX(0deg);
}
}
@keyframes pkLockCut {
0%, 25% {
opacity: 1;
}
26%, 87% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes pkOpenCut {
0%, 25% {
opacity: 0;
}
26%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes pkDotCut {
0%, 25% {
background: transparent;
}
26%, 87% {
background: #17141a;
}
88%, 100% {
background: transparent;
}
}
@media (max-width: 340px) {
.pk {
gap: 0;
}
.pk__body {
padding: 4px 12px 4px;
}
.pk__mouth {
height: 36px;
}
.pk__state {
margin: 4px 0 0;
}
.pk__stem {
height: 8px;
}
.pk__knob {
height: 32px;
}
.pk__neck {
padding: 0 12px 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pk.is-demo .pk__knob {
animation: none !important;
}
.pk.is-demo .pk__lid {
animation: none !important;
}
.pk.is-demo .pk__stateLock {
animation: none !important;
}
.pk.is-demo .pk__stateOpen {
animation: none !important;
}
.pk.is-demo .pk__dot {
animation: none !important;
}
.pk__knob {
transition: none !important;
}
.pk__lid {
transition: none !important;
}
.pk__dot {
transition: none !important;
}
}(function () {
var root = document.getElementById('pk');
var knob = document.getElementById('knob');
var PULL_MAX = 44;
var THRESHOLD = 30;
function human() { root.classList.remove('is-demo'); }
function setPull(px) { root.style.setProperty('--pull', String(px)); }
function setOpen(open) {
knob.setAttribute('aria-expanded', open ? 'true' : 'false');
root.classList.toggle('is-open', open);
}
var startY = 0, dragging = false;
knob.addEventListener('pointerdown', function (e) {
human();
dragging = true;
startY = e.clientY;
root.classList.add('is-drag');
knob.setPointerCapture(e.pointerId);
e.preventDefault();
});
knob.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var dy = e.clientY - startY;
setPull(Math.min(PULL_MAX, Math.max(0, dy)));
});
knob.addEventListener('pointerup', function (e) {
if (!dragging) { return; }
dragging = false;
var moved = e.clientY - startY;
root.classList.remove('is-drag');
setPull(0);
if (moved >= THRESHOLD) { setOpen(true); }
else if (moved >= -6 && moved < 6) { setOpen(knob.getAttribute('aria-expanded') !== 'true'); }
if (knob.hasPointerCapture(e.pointerId)) { knob.releasePointerCapture(e.pointerId); }
});
knob.addEventListener('pointercancel', function () {
dragging = false;
root.classList.remove('is-drag');
setPull(0);
});
knob.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
human();
setOpen(knob.getAttribute('aria-expanded') !== 'true');
}
});
})();// PullKnob — vanilla 판(run/013/demo/06-skeuo-pull-knob) 의 React 포트.
// 노브를 아래로 당기면 잠깐 늘어났다가 잠금이 풀리며 덮개가 열리고 손을 놓으면 제자리로 돌아간다.
// 당김값 --pull(단위 없는 수) 하나가 이동과 늘어남을 함께 구동한다. 값·타이밍은 vanilla 와 같다.
import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./PullKnob.module.scss";
export type PullKnobProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--pull"?: string };
type Start = { y: number; active: boolean };
type PtrEvt = ReactPointerEvent<HTMLButtonElement>;
type KeyEvt = { key: string; preventDefault(): void };
const PULL_MAX = 44;
const THRESHOLD = 30;
const HINT = "아래로 당겨 보세요";
const ARIA_LABEL = "당겨 여는 잠금 노브";
const STATE_LOCK = "잠김";
const STATE_OPEN = "열림";
export default function PullKnob({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: PullKnobProps) {
const [open, setOpen] = useState(false);
const [demo, setDemo] = useState(true);
const [dragging, setDragging] = useState(false);
const [pull, setPull] = useState(0);
const startRef = useRef<Start | null>(null);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--pull": String(pull),
};
function onPointerDown(e: PtrEvt) {
setDemo(false);
setDragging(true);
startRef.current = { y: e.clientY, active: true };
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onPointerMove(e: PtrEvt) {
const st = startRef.current;
if (!st || !st.active) { return; }
const dy = e.clientY - st.y;
setPull(Math.min(PULL_MAX, Math.max(0, dy)));
}
function onPointerUp(e: PtrEvt) {
const st = startRef.current;
if (!st || !st.active) { return; }
st.active = false;
const moved = e.clientY - st.y;
setDragging(false);
setPull(0);
if (moved >= THRESHOLD) { setOpen(true); }
else if (moved >= -6 && moved < 6) { setOpen((v) => !v); }
}
function onKeyDown(e: KeyEvt) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setDemo(false);
setOpen((v) => !v);
}
}
const cls = [
styles.root,
demo ? styles.isDemo : "",
open ? styles.isOpen : "",
dragging ? styles.isDrag : "",
].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-hand" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 108v44a80 80 0 0 1-160 0v-12a20 20 0 0 1 20-20h20V92a20 20 0 0 1 40 0a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M188 80a27.8 27.8 0 0 0-13.36 3.4a28 28 0 0 0-46.64-11A28 28 0 0 0 80 92v20H68a28 28 0 0 0-28 28v12a88 88 0 0 0 176 0v-44a28 28 0 0 0-28-28m12 72a72 72 0 0 1-144 0v-12a12 12 0 0 1 12-12h12v24a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0V92a12 12 0 0 1 24 0v28a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0Z"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-hand" /></svg>
<span>{HINT}</span>
</p>
<div className={styles.body}>
<div className={styles.lidWrap}>
<div className={styles.mouth} />
<div className={styles.lid} id="lid"><i className={styles.lidFace} /></div>
</div>
<p className={styles.state}>
<i className={styles.dot} />
<span className={styles.stateLock}>{STATE_LOCK}</span>
<span className={styles.stateOpen}>{STATE_OPEN}</span>
</p>
<div className={styles.neck}>
<i className={styles.stem} />
<button
className={styles.knob}
type="button"
aria-expanded={open}
aria-controls="lid"
aria-label={ARIA_LABEL}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
>
<i className={styles.grip} />
</button>
</div>
</div>
</div>
</main>
);
}06 · 당겨 여는 풀 노브 — 측정 누적면적 20.842% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
당김 하나가 구동하는 이동과 늘어남
사용자 지정 속성 --pull 하나가 translateY 로 노브를 내리는 동시에 scale(1, calc(1 + var(--pull) / 220)) 로 위 원점(transform-origin: 50% 0)에서 세로로 늘립니다.
.pk__knob {
position: relative; width: clamp(56px, 15vw, 68px); height: clamp(36px, 10vw, 44px);
border: 0; border-radius: $r-control; padding: 0; cursor: grab; touch-action: none;
transform-origin: 50% 0; // 줄기에 붙어 있어 늘어남은 위 원점에서(비중심 원점 사유)
transform: translateY(calc(var(--pull, 0) * 1px)) scale(1, calc(1 + var(--pull, 0) / 220));
background:
radial-gradient(120% 140% at 30% 0%, rgba($stage-paper, .35), rgba($stage-paper, 0) 50%),
$subject-ink;
box-shadow: 0 6px 10px rgba($subject-ink, .4), inset 0 1px 0 rgba($subject-cream, .3), inset 0 -3px 0 rgba(0, 0, 0, .5);
transition: transform $dur-base $easing;
}
임계값과 탭을 가르는 경계
미리보기에서 당김이 가장 깊은 프레임은 화면의 18.543% 를 한 번에 바꿔 순간 면적이 아홉 개 중 가장 넓은데, 임계값 30픽셀을 넘겨야 잠금이 풀리고 ±6픽셀 안의 움직임은 탭으로 보아 열림을 토글합니다.
knob.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var dy = e.clientY - startY;
setPull(Math.min(PULL_MAX, Math.max(0, dy)));
});
knob.addEventListener('pointerup', function (e) {
if (!dragging) { return; }
dragging = false;
var moved = e.clientY - startY;
root.classList.remove('is-drag');
setPull(0);
if (moved >= THRESHOLD) { setOpen(true); }
else if (moved >= -6 && moved < 6) { setOpen(knob.getAttribute('aria-expanded') !== 'true'); }
if (knob.hasPointerCapture(e.pointerId)) { knob.releasePointerCapture(e.pointerId); }
});
07시소 틸트 스위치
가운데 축을 두고 양쪽 패드를 번갈아 눌러 A·B 를 고르는 선택기로, 선택은 기울어진 각도·가라앉은 패드·아래 글자 셋으로 읽힙니다.

<main class="stage">
<div class="ss is-demo" id="ss" data-side="a">
<p class="ss__hint">양쪽 끝을 눌러 A 또는 B를 고르세요</p>
<div class="ss__scene">
<div class="ss__ground" aria-hidden="true"></div>
<div class="ss__plinth" aria-hidden="true"></div>
<div class="ss__plank">
<button class="ss__pad ss__pad--a" id="padA" type="button" aria-pressed="true">
<b class="ss__key">A</b><span class="ss__sub">왼쪽</span>
</button>
<span class="ss__bolt" aria-hidden="true"></span>
<button class="ss__pad ss__pad--b" id="padB" type="button" aria-pressed="false">
<b class="ss__key">B</b><span class="ss__sub">오른쪽</span>
</button>
</div>
</div>
<p class="ss__out">
<span class="ss__outLabel">고른 쪽</span>
<span class="ss__outVal">
<b class="ss__letter ss__letter--a">A</b>
<b class="ss__letter ss__letter--b">B</b>
</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 시소 틸트 스위치 — 중앙 볼트가 축. 양쪽 패드(둘 다 진짜 button)를 누르면 판이 그쪽으로
// 기울고 눌린 쪽 패드가 움푹 들어간다. 선택은 셋으로 알린다 — 기울어진 각도, 가라앉은 패드,
// 아래 글월의 A 또는 B. 색만으로 상태를 알리는 수단은 없다(WCAG 1.4.1).
// 재질은 실물 플라스틱 판이 갖는 위 하이라이트·아래 그늘(linear-gradient + inset box-shadow)로.
// 무대(주황) 위 글자는 전부 먹 — 크림은 대비 3.11 로 AA 미달이라 못 쓴다.
// 격자 미리보기(.is-demo)는 좌우를 번갈아 누르는 장면을 2초에 한 바퀴 돌린다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-orange);
.ss { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; max-width: 400px; }
.ss__hint {
margin: 0; font-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 1.5;
color: $subject-ink;
}
.ss__scene { position: relative; width: min(336px, 92vw); height: clamp(104px, 30vw, 128px); }
.ss__ground {
position: absolute; left: 10%; right: 10%; bottom: 0; height: 10px;
border-radius: 50%; background: rgba($subject-ink, .16);
}
// 받침대 — 먹 플라스틱 기둥. 위쪽 반구(가상 요소)가 판이 얹혀 도는 축이다.
.ss__plinth {
position: absolute; left: 50%; bottom: 2px; width: clamp(52px, 16vw, 64px); height: clamp(34px, 10vw, 44px);
transform: translateX(-50%);
background: linear-gradient(180deg, rgba($subject-cream, .2), rgba($subject-cream, 0) 36%, rgba($stage-ink, .55)), $subject-ink;
border-radius: $r-sm $r-sm 0 0;
box-shadow: inset 0 2px 0 rgba($subject-cream, .14), 0 8px 14px rgba($stage-ink, .28);
}
.ss__plinth::before {
content: ""; position: absolute; left: 50%; top: -8px; width: 18px; height: 18px;
transform: translateX(-50%); border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba($subject-cream, .4), rgba($subject-cream, .06) 60%), $subject-ink;
}
// 판 — 축은 중앙 볼트가 받침 반구에 닿는 지점이라 transform-origin 을 아래쪽 가운데로 뒀다
// (붙는 지점이 있는 요소의 비중심 origin. 규칙 8 참고).
.ss__plank {
position: absolute; left: 0; right: 0; top: clamp(20px, 6.5vw, 26px); height: clamp(42px, 12vw, 52px);
display: flex; align-items: center; justify-content: space-between; box-sizing: border-box;
padding: 0 $sp-2;
background: linear-gradient(180deg, rgba($subject-cream, .55), rgba($subject-cream, 0) 32%, rgba($stage-ink, .18)), $color;
border: 1px solid rgba($stage-ink, .25); border-radius: $r-control;
box-shadow: 0 10px 18px rgba($stage-ink, .26), inset 0 1px 0 rgba($subject-cream, .7);
transform: rotate(-8deg);
transform-origin: 50% 84%;
transition: transform $duration $easing;
}
.ss[data-side="b"] .ss__plank { transform: rotate(8deg); }
.ss__bolt {
flex: 0 0 auto; width: 16px; height: 16px; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba($subject-cream, .55), rgba($subject-cream, .05) 62%), $subject-ink;
box-shadow: 0 0 0 3px rgba($subject-cream, .4), 0 2px 4px rgba($stage-ink, .5);
}
.ss__pad {
display: flex; flex-direction: column; align-items: center; justify-content: center;
width: clamp(76px, 23vw, 96px); height: clamp(32px, 9vw, 40px);
border: 1px solid rgba($stage-ink, .3); border-radius: $r-control; cursor: pointer;
background: linear-gradient(180deg, rgba($subject-cream, .65), rgba($subject-cream, 0) 44%, rgba($stage-ink, .14)), $color;
box-shadow: 0 6px 10px rgba($stage-ink, .3), inset 0 1px 0 rgba($subject-cream, .8);
font-family: inherit; color: $subject-ink;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.ss__key { font-weight: 800; font-size: clamp(15px, 4.6vw, 19px); line-height: 1.1; }
.ss__sub { font-weight: 700; font-size: clamp(9px, 2.6vw, 10px); line-height: 1.3; color: rgba($subject-ink, .72); }
.ss__pad:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
.ss__pad:active {
transform: translateY(2px);
box-shadow: 0 2px 4px rgba($stage-ink, .3), inset 0 2px 6px rgba($stage-ink, .28);
}
// 골라진(내려앉은) 쪽 패드 — 움푹 들어간 그림자로 눌림을 표시
.ss[data-side="a"] .ss__pad--a,
.ss[data-side="b"] .ss__pad--b {
transform: translateY(3px);
box-shadow: 0 2px 4px rgba($stage-ink, .22), inset 0 3px 8px rgba($stage-ink, .32);
}
.ss__out {
display: flex; align-items: center; gap: $sp-2; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 1.5; color: $subject-ink;
}
.ss__outVal { position: relative; width: 16px; height: 20px; }
.ss__letter {
position: absolute; inset: 0; font-weight: 800;
font-size: clamp(14px, 4vw, 17px); line-height: 20px; color: $subject-ink;
}
.ss__letter--b { visibility: hidden; }
.ss[data-side="b"] .ss__letter--b { visibility: visible; }
.ss[data-side="b"] .ss__letter--a { visibility: hidden; }
// ── 격자 미리보기: 좌우를 번갈아 누른다 ──────────────────────────────
// 왕복을 2초 안에 끝내고 마지막 20% 는 왼쪽 눌림 그대로 멈춘다(루프 복귀는 90% 안).
// 애니메이션은 낱개 속성으로만 — 단축을 쓰면 delay 가 리셋된다.
.ss.is-demo .ss__plank {
animation-name: ssTilt; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ss.is-demo .ss__pad--a {
animation-name: ssPressA; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ss.is-demo .ss__pad--b {
animation-name: ssPressB; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ss.is-demo .ss__letter--a { animation-name: ssShowA; animation-duration: $dur-loop; animation-iteration-count: infinite; }
.ss.is-demo .ss__letter--b { animation-name: ssShowB; animation-duration: $dur-loop; animation-iteration-count: infinite; }
@keyframes ssTilt {
0%, 10% { transform: rotate(-8deg); }
38%, 52% { transform: rotate(8deg); }
80%, 100% { transform: rotate(-8deg); }
}
@keyframes ssPressA {
0%, 12% { transform: translateY(3px); }
26%, 74% { transform: translateY(0); }
84%, 100% { transform: translateY(3px); }
}
@keyframes ssPressB {
0%, 26% { transform: translateY(0); }
38%, 62% { transform: translateY(3px); }
76%, 100% { transform: translateY(0); }
}
// 글월 교체는 visibility 컷 — 반투명 유령 프레임이 생기지 않는다
@keyframes ssShowA {
0%, 23% { visibility: visible; }
25%, 75% { visibility: hidden; }
77%, 100% { visibility: visible; }
}
@keyframes ssShowB {
0%, 23% { visibility: hidden; }
25%, 75% { visibility: visible; }
77%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.ss { gap: $sp-1; }
.ss__scene { height: 102px; }
.ss__plank { top: 20px; height: 38px; }
.ss__outVal { height: 18px; }
.ss__letter { line-height: 18px; }
}
// reduce 에서는 시소가 왼쪽으로 기운 한 장으로 선다 — 정적 경로가 화면을 채운다
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__plank,
.ss.is-demo .ss__pad--a,
.ss.is-demo .ss__pad--b,
.ss.is-demo .ss__letter--a,
.ss.is-demo .ss__letter--b { animation: none !important; }
.ss__plank, .ss__pad { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ss {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 400px;
}
.ss__hint {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.5;
color: #17141a;
}
.ss__scene {
position: relative;
width: min(336px, 92vw);
height: clamp(104px, 30vw, 128px);
}
.ss__ground {
position: absolute;
left: 10%;
right: 10%;
bottom: 0;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.16);
}
.ss__plinth {
position: absolute;
left: 50%;
bottom: 2px;
width: clamp(52px, 16vw, 64px);
height: clamp(34px, 10vw, 44px);
transform: translateX(-50%);
background: linear-gradient(180deg, rgba(255, 247, 230, 0.2), rgba(255, 247, 230, 0) 36%, rgba(23, 20, 26, 0.55)), #17141a;
border-radius: 8px 8px 0 0;
box-shadow: inset 0 2px 0 rgba(255, 247, 230, 0.14), 0 8px 14px rgba(23, 20, 26, 0.28);
}
.ss__plinth::before {
content: "";
position: absolute;
left: 50%;
top: -8px;
width: 18px;
height: 18px;
transform: translateX(-50%);
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(255, 247, 230, 0.4), rgba(255, 247, 230, 0.06) 60%), #17141a;
}
.ss__plank {
position: absolute;
left: 0;
right: 0;
top: clamp(20px, 6.5vw, 26px);
height: clamp(42px, 12vw, 52px);
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 0 8px;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.55), rgba(255, 247, 230, 0) 32%, rgba(23, 20, 26, 0.18)), #ffffff;
border: 1px solid rgba(23, 20, 26, 0.25);
border-radius: 12px;
box-shadow: 0 10px 18px rgba(23, 20, 26, 0.26), inset 0 1px 0 rgba(255, 247, 230, 0.7);
transform: rotate(-8deg);
transform-origin: 50% 84%;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ss[data-side=b] .ss__plank {
transform: rotate(8deg);
}
.ss__bolt {
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(255, 247, 230, 0.55), rgba(255, 247, 230, 0.05) 62%), #17141a;
box-shadow: 0 0 0 3px rgba(255, 247, 230, 0.4), 0 2px 4px rgba(23, 20, 26, 0.5);
}
.ss__pad {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: clamp(76px, 23vw, 96px);
height: clamp(32px, 9vw, 40px);
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 12px;
cursor: pointer;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.65), rgba(255, 247, 230, 0) 44%, rgba(23, 20, 26, 0.14)), #ffffff;
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.3), inset 0 1px 0 rgba(255, 247, 230, 0.8);
font-family: inherit;
color: #17141a;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ss__key {
font-weight: 800;
font-size: clamp(15px, 4.6vw, 19px);
line-height: 1.1;
}
.ss__sub {
font-weight: 700;
font-size: clamp(9px, 2.6vw, 10px);
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.ss__pad:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.ss__pad:active {
transform: translateY(2px);
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.3), inset 0 2px 6px rgba(23, 20, 26, 0.28);
}
.ss[data-side=a] .ss__pad--a,
.ss[data-side=b] .ss__pad--b {
transform: translateY(3px);
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.22), inset 0 3px 8px rgba(23, 20, 26, 0.32);
}
.ss__out {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.5;
color: #17141a;
}
.ss__outVal {
position: relative;
width: 16px;
height: 20px;
}
.ss__letter {
position: absolute;
inset: 0;
font-weight: 800;
font-size: clamp(14px, 4vw, 17px);
line-height: 20px;
color: #17141a;
}
.ss__letter--b {
visibility: hidden;
}
.ss[data-side=b] .ss__letter--b {
visibility: visible;
}
.ss[data-side=b] .ss__letter--a {
visibility: hidden;
}
.ss.is-demo .ss__plank {
animation-name: ssTilt;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__pad--a {
animation-name: ssPressA;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__pad--b {
animation-name: ssPressB;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__letter--a {
animation-name: ssShowA;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.ss.is-demo .ss__letter--b {
animation-name: ssShowB;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@keyframes ssTilt {
0%, 10% {
transform: rotate(-8deg);
}
38%, 52% {
transform: rotate(8deg);
}
80%, 100% {
transform: rotate(-8deg);
}
}
@keyframes ssPressA {
0%, 12% {
transform: translateY(3px);
}
26%, 74% {
transform: translateY(0);
}
84%, 100% {
transform: translateY(3px);
}
}
@keyframes ssPressB {
0%, 26% {
transform: translateY(0);
}
38%, 62% {
transform: translateY(3px);
}
76%, 100% {
transform: translateY(0);
}
}
@keyframes ssShowA {
0%, 23% {
visibility: visible;
}
25%, 75% {
visibility: hidden;
}
77%, 100% {
visibility: visible;
}
}
@keyframes ssShowB {
0%, 23% {
visibility: hidden;
}
25%, 75% {
visibility: visible;
}
77%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.ss {
gap: 4px;
}
.ss__scene {
height: 102px;
}
.ss__plank {
top: 20px;
height: 38px;
}
.ss__outVal {
height: 18px;
}
.ss__letter {
line-height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__plank,
.ss.is-demo .ss__pad--a,
.ss.is-demo .ss__pad--b,
.ss.is-demo .ss__letter--a,
.ss.is-demo .ss__letter--b {
animation: none !important;
}
.ss__plank, .ss__pad {
transition: none !important;
}
}var root = document.getElementById('ss');
var padA = document.getElementById('padA');
var padB = document.getElementById('padB');
// 각도·눌림·글월은 전부 data-side 하나로 갈린다. 화면 글자를 JS 가 건드리지 않는다.
function pick(side) {
root.classList.remove('is-demo');
root.setAttribute('data-side', side);
padA.setAttribute('aria-pressed', side === 'a' ? 'true' : 'false');
padB.setAttribute('aria-pressed', side === 'b' ? 'true' : 'false');
}
padA.addEventListener('click', function () { pick('a'); });
padB.addEventListener('click', function () { pick('b'); });
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });// SeesawSwitch — vanilla 판(run/013/demo/07-skeuo-seesaw-switch) 의 React 포트.
// 각도·눌림·글월은 전부 side 상태 하나로 갈린다. 값·키프레임은 vanilla 와 같다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SeesawSwitch.module.scss";
export type SeesawSwitchProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "양쪽 끝을 눌러 A 또는 B를 고르세요";
const SUB_A = "왼쪽";
const SUB_B = "오른쪽";
const OUT_LABEL = "고른 쪽";
export default function SeesawSwitch({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffffff",
}: SeesawSwitchProps) {
const [side, setSide] = useState<"a" | "b">("a");
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function pick(next: "a" | "b") {
setDemo(false);
setSide(next);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const pressedA = side === "a";
return (
<main className={styles.stage}>
<div className={cls} data-side={side} style={vars}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.scene}>
<div className={styles.ground} aria-hidden="true"></div>
<div className={styles.plinth} aria-hidden="true"></div>
<div className={styles.plank}>
<button
className={`${styles.pad} ${styles.padA}`}
type="button"
aria-pressed={pressedA}
onClick={() => pick("a")}
>
<b className={styles.key}>A</b>
<span className={styles.sub}>{SUB_A}</span>
</button>
<span className={styles.bolt} aria-hidden="true"></span>
<button
className={`${styles.pad} ${styles.padB}`}
type="button"
aria-pressed={!pressedA}
onClick={() => pick("b")}
>
<b className={styles.key}>B</b>
<span className={styles.sub}>{SUB_B}</span>
</button>
</div>
</div>
<p className={styles.out}>
<span className={styles.outLabel}>{OUT_LABEL}</span>
<span className={styles.outVal}>
<b className={`${styles.letter} ${styles.letterA}`}>A</b>
<b className={`${styles.letter} ${styles.letterB}`}>B</b>
</span>
</p>
</div>
</main>
);
}07 · 시소 틸트 스위치 — 측정 누적면적 19.144% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
판이 도는 축의 자리
판의 회전축은 transform-origin: 50% 84%, 곧 받침 반구에 닿는 지점이라 좌우 ±8도 회전이 선택 자체를 보여 줍니다.
.ss__plank {
position: absolute; left: 0; right: 0; top: clamp(20px, 6.5vw, 26px); height: clamp(42px, 12vw, 52px);
display: flex; align-items: center; justify-content: space-between; box-sizing: border-box;
padding: 0 $sp-2;
background: linear-gradient(180deg, rgba($subject-cream, .55), rgba($subject-cream, 0) 32%, rgba($stage-ink, .18)), $color;
border: 1px solid rgba($stage-ink, .25); border-radius: $r-control;
box-shadow: 0 10px 18px rgba($stage-ink, .26), inset 0 1px 0 rgba($subject-cream, .7);
transform: rotate(-8deg);
transform-origin: 50% 84%;
transition: transform $duration $easing;
}
data-side 하나로 갈라지는 화면
각도·패드 눌림·글자는 data-side 속성 하나에서 전부 갈라져, JS 가 화면 글자를 직접 건드리지 않으며, 누적면적 19.144% 의 넉넉한 동작으로 A/B 옵션 선택기를 채웁니다.
function pick(side) {
root.classList.remove('is-demo');
root.setAttribute('data-side', side);
padA.setAttribute('aria-pressed', side === 'a' ? 'true' : 'false');
padB.setAttribute('aria-pressed', side === 'b' ? 'true' : 'false');
}
padA.addEventListener('click', function () { pick('a'); });
padB.addEventListener('click', function () { pick('b'); });
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
08차단기 브레이커 스위치
배전반 모듈 전체가 하나의 role="switch" 버튼이라 손잡이를 젖히는 동작이 곧 클릭이고, 내리면 한 번 멈칫하다 무겁게 떨어지며 꺼짐 표시등이 켜집니다.

<main class="stage">
<div class="bkWrap is-demo" id="bkWrap" data-on="true">
<button class="bk" id="bk" type="button" role="switch" aria-checked="true" aria-label="전원 차단기">
<span class="bk__plate">
<span class="bk__brand">
<svg class="bk__spark" viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><g><path d="m96 240l16-80l-64-24L160 16l-16 80l64 24Z" opacity=".2"/><path d="M215.79 118.17a8 8 0 0 0-5-5.66L153.18 90.9l14.66-73.33a8 8 0 0 0-13.69-7l-112 120a8 8 0 0 0 3 13l57.63 21.61l-14.62 73.25a8 8 0 0 0 13.69 7l112-120a8 8 0 0 0 1.94-7.26M109.37 214l10.47-52.38a8 8 0 0 0-5-9.06L62 132.71l84.62-90.66l-10.46 52.38a8 8 0 0 0 5 9.06l52.8 19.8Z"/></g></svg>
<span>배전 차단기</span>
</span>
<span class="bk__slot">
<span class="bk__mark bk__mark--on"><i class="bk__led"></i>켜짐</span>
<span class="bk__mark bk__mark--off"><i class="bk__led"></i>꺼짐</span>
<span class="bk__lever"><span class="bk__grip" aria-hidden="true"></span></span>
</span>
<span class="bk__hazard" aria-hidden="true"></span>
</span>
<span class="bk__hand" aria-hidden="true">
<svg class="bk__handIco" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 116v36a80 80 0 0 1-80 80c-44.18 0-55.81-24-93.32-90a20 20 0 0 1 34.64-20L96 152V44a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M196 88a27.86 27.86 0 0 0-13.35 3.39A28 28 0 0 0 144 74.7V44a28 28 0 0 0-56 0v80l-3.82-6.13A28 28 0 0 0 35.73 146l4.67 8.23C74.81 214.89 89.05 240 136 240a88.1 88.1 0 0 0 88-88v-36a28 28 0 0 0-28-28m12 64a72.08 72.08 0 0 1-72 72c-37.63 0-47.84-18-81.68-77.68l-4.69-8.27V138A12 12 0 0 1 54 121.61a11.9 11.9 0 0 1 6-1.6a12 12 0 0 1 10.41 6a2 2 0 0 0 .14.23l18.67 30A8 8 0 0 0 104 152V44a12 12 0 0 1 24 0v68a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0v20a8 8 0 0 0 16 0v-4a12 12 0 0 1 24 0Z"/></g></svg>
</span>
</button>
<p class="bk__status">
<span class="bk__state bk__state--on">전원이 켜졌습니다</span>
<span class="bk__state bk__state--off">전원이 꺼졌습니다</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 차단기 브레이커 스위치 — 배반판 모듈 전체가 하나의 button(role="switch"). 손잡이를 아래로
// 젖히면(=클릭하면) 무게에 눌려 한 번 멈칫하다가 떨어져 내려간다. 그 멈칫함은 steps(1,end)
// 한 단계 이징으로 담는다 — 손이 다 눌리고도 손잡이가 잠깐 버티다가 스르록 떨어진다.
// 켜짐·꺼짐은 표시등(색) + 손잡이 각도(위치) + 아래 글월(글자) 셋으로 알리므로 색만으로는
// 알리지 않는다. 격자 미리보기(.is-demo)는 손이 내려와 두 번 누르는 장면을 2초에 한 바퀴.
// 페이페이 흉내가 아니라 재질 — 판은 먹 플라스틱, 손잡이는 $color 캡, 홈은 깎인 그림자.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-paper);
.bkWrap { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.bk {
position: relative; display: block; margin: 0; padding: 0; border: 0; background: none;
font-family: inherit; cursor: pointer;
}
.bk:focus-visible { outline: 2px solid $subject-blue; outline-offset: 4px; }
.bk__plate {
display: flex; flex-direction: column; box-sizing: border-box;
width: clamp(200px, 54vw, 224px); padding: $sp-3 0 $sp-2;
background: linear-gradient(180deg, rgba($subject-cream, .1), rgba($subject-cream, .02) 40%, rgba($stage-ink, .4)), $subject-ink;
border: 1px solid rgba($subject-cream, .12); border-radius: $r-card;
box-shadow: 0 14px 24px rgba(23, 20, 26, .32), inset 0 1px 0 rgba($subject-cream, .16);
}
.bk__brand {
display: flex; align-items: center; justify-content: center; gap: $sp-1;
font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4;
letter-spacing: .06em; color: $subject-cream;
}
.bk__spark { width: 12px; height: 12px; flex: 0 0 auto; color: $stage-orange; }
// 손잡이 슬롯 — 판보다 한 단계 깊게 파인 구멍
.bk__slot {
position: relative; height: clamp(96px, 26vw, 116px); margin: $sp-2 $sp-4 0;
background: rgba($stage-ink, .85); border-radius: $r-control;
box-shadow: inset 0 4px 10px rgba($stage-ink, .9), inset 0 -1px 0 rgba($subject-cream, .08);
}
// 켜짐·꺼짐 표시 — 슬롯 왼쪽에 세로로. 표시등은 색, 글자는 글자로 각자 역할
.bk__mark {
position: absolute; left: $sp-2; z-index: 1;
display: flex; align-items: center; gap: $sp-1;
font-weight: 700; font-size: clamp(9px, 2.6vw, 10px); line-height: 1.3; color: $subject-cream;
}
.bk__mark--on { top: $sp-2; }
.bk__mark--off { bottom: $sp-2; }
.bk__led {
position: relative; width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
background: rgba($subject-cream, .14); box-shadow: inset 0 1px 2px rgba($stage-ink, .8);
}
// 켜진 쪽 빛 점 — 주황 점 + 번짐을 가상 요소로 분리해 visibility 컷으로만 켠다.
// 상태는 여기에만 색을 쓴다(각도·글자와 함께 세 가지 신호 중 하나)
.bk__led::after {
content: ""; position: absolute; inset: 0; border-radius: 50%;
background: $stage-orange;
box-shadow: 0 0 8px rgba($stage-orange, .8), inset 0 -1px 2px rgba($stage-ink, .35);
visibility: hidden;
}
.bkWrap[data-on="true"] .bk__mark--on .bk__led::after,
.bkWrap[data-on="false"] .bk__mark--off .bk__led::after { visibility: visible; }
// 손잡이 — 가운데 핀을 축으로 ±26 도. 실제 조작은 steps(1,end) 전환 한 번으로 떨어뜨린다
.bk__lever {
position: absolute; left: 50%; top: 50%; width: clamp(34px, 9vw, 40px); height: clamp(76px, 20vw, 88px);
background: linear-gradient(90deg, rgba($stage-ink, .32), rgba($subject-cream, .5) 28%, rgba($subject-cream, .16) 55%, rgba($stage-ink, .38)), $color;
border: 1px solid rgba($stage-ink, .45); border-radius: $r-sm;
box-shadow: 0 8px 14px rgba($stage-ink, .55), inset 0 1px 0 rgba($subject-cream, .85);
transform: translate(-50%, -50%) rotate(-26deg);
transform-origin: 50% 50%;
transition: transform $duration steps(1,end);
}
.bkWrap[data-on="false"] .bk__lever { transform: translate(-50%, -50%) rotate(26deg); }
// 잡는 홈 — 손가락이 걸리는 가로줄
.bk__grip {
position: absolute; left: $sp-1; right: $sp-1; top: $sp-2; height: 34%;
border-radius: $r-xs;
background: repeating-linear-gradient(0deg, rgba($stage-ink, .38) 0 3px, rgba($subject-cream, .28) 3px 7px);
box-shadow: inset 0 1px 3px rgba($stage-ink, .5);
}
// 축 핀 — 손잡이 한가운데 박힌 금속못
.bk__lever::after {
content: ""; position: absolute; left: 50%; top: 50%; width: 12px; height: 12px;
transform: translate(-50%, -50%); border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba($subject-cream, .7), rgba($subject-cream, .08) 62%), $subject-ink;
box-shadow: 0 0 0 2px rgba($subject-cream, .35), 0 1px 3px rgba($stage-ink, .7);
}
// 경고 줄무늬 — 노랑과 먹의 사선 반복
.bk__hazard {
height: 14px; margin: $sp-2 $sp-4 0; border-radius: $r-pill;
background: repeating-linear-gradient(-45deg, $stage-yellow 0 12px, rgba($stage-ink, .92) 12px 24px);
border: 1px solid rgba($subject-cream, .14);
box-shadow: inset 0 -2px 4px rgba($stage-ink, .55), inset 0 1px 0 rgba($subject-cream, .3);
}
// 누르는 손 — 세로 반전한 Phosphor 손가락. 손끝이 손잡이에 닿는다
.bk__hand {
position: absolute; left: 50%; top: clamp(46px, 13vw, 54px); z-index: 2;
width: 32px; height: 32px; margin-left: -16px; pointer-events: none;
color: $subject-cream; filter: drop-shadow(0 2px 3px rgba($stage-ink, .6));
transform: translateY(-20px);
}
.bk__handIco { width: 100%; height: 100%; transform: scaleY(-1); }
.bk__status {
position: relative; margin: 0; width: 200px; height: 20px;
font-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 20px; color: $subject-ink;
}
.bk__state { position: absolute; inset: 0; text-align: center; }
.bk__state--off { visibility: hidden; }
.bkWrap[data-on="false"] .bk__state--off { visibility: visible; }
.bkWrap[data-on="false"] .bk__state--on { visibility: hidden; }
// ── 격자 미리보기: 손이 내려와 두 번 누른다 ─────────────────────────
// 손이 완전히 눌리는 22% 와 64% 에서 잠깐 멈칫하다가(멈칫 = steps 구간) 27% · 70% 에
// 손잡이가 떨어진다. 복귀는 84% 안에 끝나고 마지막 16% 는 켜짐 그대로 멈춘다.
.bkWrap.is-demo .bk__lever {
animation-name: bkLever; animation-duration: $dur-loop;
animation-timing-function: steps(1,end); animation-iteration-count: infinite;
}
.bkWrap.is-demo .bk__hand {
animation-name: bkHand; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.bkWrap.is-demo .bk__plate {
animation-name: bkThud; animation-duration: $dur-loop;
animation-timing-function: $ease-out; animation-iteration-count: infinite;
}
.bkWrap.is-demo .bk__mark--on .bk__led::after,
.bkWrap.is-demo .bk__state--on { animation-name: bkShowOn; animation-duration: $dur-loop; animation-iteration-count: infinite; }
.bkWrap.is-demo .bk__mark--off .bk__led::after,
.bkWrap.is-demo .bk__state--off { animation-name: bkShowOff; animation-duration: $dur-loop; animation-iteration-count: infinite; }
// steps(1,end) 는 구간 끝에서야 값을 바꾼다 — 컷 지점을 중간 키프레임(27% · 70%)으로 둬야
// 2초 내내 첫 값만 보이는 정지화가 되지 않는다. 27% 와 70% 는 12fps 샘플 시점(25.0 · 29.17 ·
// 66.67 · 70.83%) 사이의 빈칸이라 손잡이·표시등·글월이 같은 간격 안에서 함께 뒤집힌다.
@keyframes bkLever {
0%, 25% { transform: translate(-50%, -50%) rotate(-26deg); }
27%, 68% { transform: translate(-50%, -50%) rotate(26deg); }
70%, 100% { transform: translate(-50%, -50%) rotate(-26deg); }
}
@keyframes bkHand {
0% { transform: translateY(-20px); }
12% { transform: translateY(0); }
22% { transform: translateY(16px); }
30%, 52% { transform: translateY(0); }
64% { transform: translateY(16px); }
72% { transform: translateY(0); }
84%, 100% { transform: translateY(-20px); }
}
// 떨어질 때 판이 한 번 가라앉는다 — 무게
@keyframes bkThud {
0%, 25% { transform: translateY(0); }
29% { transform: translateY(2px); }
33%, 70% { transform: translateY(0); }
74% { transform: translateY(2px); }
78%, 100% { transform: translateY(0); }
}
// 표시등과 글월은 visibility 컷 — 반투명 유령 프레임이 없다(짝 키프레임의 한가운데서 뒤집힌다)
@keyframes bkShowOn {
0%, 26% { visibility: visible; }
28%, 69% { visibility: hidden; }
71%, 100% { visibility: visible; }
}
@keyframes bkShowOff {
0%, 26% { visibility: hidden; }
28%, 69% { visibility: visible; }
71%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.bkWrap { gap: $sp-1; }
.bk__plate { padding-top: $sp-2; padding-bottom: $sp-1; }
.bk__slot { height: 88px; margin-top: $sp-1; }
.bk__hazard { height: 12px; margin-top: $sp-1; }
.bk__hand { top: 42px; }
.bk__status { height: 16px; line-height: 16px; }
}
// reduce 에서는 켜짐(손잡이 위) 한 장으로 선다 — 정적 경로가 화면을 채운다
@media (prefers-reduced-motion: reduce) {
.bkWrap.is-demo .bk__lever,
.bkWrap.is-demo .bk__hand,
.bkWrap.is-demo .bk__plate,
.bkWrap.is-demo .bk__mark--on .bk__led::after,
.bkWrap.is-demo .bk__mark--off .bk__led::after,
.bkWrap.is-demo .bk__state--on,
.bkWrap.is-demo .bk__state--off { animation: none !important; }
.bk__lever { transition: 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);
}
.bkWrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bk {
position: relative;
display: block;
margin: 0;
padding: 0;
border: 0;
background: none;
font-family: inherit;
cursor: pointer;
}
.bk:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
}
.bk__plate {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: clamp(200px, 54vw, 224px);
padding: 12px 0 8px;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.1), rgba(255, 247, 230, 0.02) 40%, rgba(23, 20, 26, 0.4)), #17141a;
border: 1px solid rgba(255, 247, 230, 0.12);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 1px 0 rgba(255, 247, 230, 0.16);
}
.bk__brand {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
letter-spacing: 0.06em;
color: #fff7e6;
}
.bk__spark {
width: 12px;
height: 12px;
flex: 0 0 auto;
color: #ff4d1f;
}
.bk__slot {
position: relative;
height: clamp(96px, 26vw, 116px);
margin: 8px 16px 0;
background: rgba(23, 20, 26, 0.85);
border-radius: 12px;
box-shadow: inset 0 4px 10px rgba(23, 20, 26, 0.9), inset 0 -1px 0 rgba(255, 247, 230, 0.08);
}
.bk__mark {
position: absolute;
left: 8px;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 10px);
line-height: 1.3;
color: #fff7e6;
}
.bk__mark--on {
top: 8px;
}
.bk__mark--off {
bottom: 8px;
}
.bk__led {
position: relative;
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
background: rgba(255, 247, 230, 0.14);
box-shadow: inset 0 1px 2px rgba(23, 20, 26, 0.8);
}
.bk__led::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: #ff4d1f;
box-shadow: 0 0 8px rgba(255, 77, 31, 0.8), inset 0 -1px 2px rgba(23, 20, 26, 0.35);
visibility: hidden;
}
.bkWrap[data-on=true] .bk__mark--on .bk__led::after,
.bkWrap[data-on=false] .bk__mark--off .bk__led::after {
visibility: visible;
}
.bk__lever {
position: absolute;
left: 50%;
top: 50%;
width: clamp(34px, 9vw, 40px);
height: clamp(76px, 20vw, 88px);
background: linear-gradient(90deg, rgba(23, 20, 26, 0.32), rgba(255, 247, 230, 0.5) 28%, rgba(255, 247, 230, 0.16) 55%, rgba(23, 20, 26, 0.38)), #ffffff;
border: 1px solid rgba(23, 20, 26, 0.45);
border-radius: 8px;
box-shadow: 0 8px 14px rgba(23, 20, 26, 0.55), inset 0 1px 0 rgba(255, 247, 230, 0.85);
transform: translate(-50%, -50%) rotate(-26deg);
transform-origin: 50% 50%;
transition: transform 220ms steps(1, end);
}
.bkWrap[data-on=false] .bk__lever {
transform: translate(-50%, -50%) rotate(26deg);
}
.bk__grip {
position: absolute;
left: 4px;
right: 4px;
top: 8px;
height: 34%;
border-radius: 4px;
background: repeating-linear-gradient(0deg, rgba(23, 20, 26, 0.38) 0 3px, rgba(255, 247, 230, 0.28) 3px 7px);
box-shadow: inset 0 1px 3px rgba(23, 20, 26, 0.5);
}
.bk__lever::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(255, 247, 230, 0.7), rgba(255, 247, 230, 0.08) 62%), #17141a;
box-shadow: 0 0 0 2px rgba(255, 247, 230, 0.35), 0 1px 3px rgba(23, 20, 26, 0.7);
}
.bk__hazard {
height: 14px;
margin: 8px 16px 0;
border-radius: 999px;
background: repeating-linear-gradient(-45deg, #ffd23f 0 12px, rgba(23, 20, 26, 0.92) 12px 24px);
border: 1px solid rgba(255, 247, 230, 0.14);
box-shadow: inset 0 -2px 4px rgba(23, 20, 26, 0.55), inset 0 1px 0 rgba(255, 247, 230, 0.3);
}
.bk__hand {
position: absolute;
left: 50%;
top: clamp(46px, 13vw, 54px);
z-index: 2;
width: 32px;
height: 32px;
margin-left: -16px;
pointer-events: none;
color: #fff7e6;
filter: drop-shadow(0 2px 3px rgba(23, 20, 26, 0.6));
transform: translateY(-20px);
}
.bk__handIco {
width: 100%;
height: 100%;
transform: scaleY(-1);
}
.bk__status {
position: relative;
margin: 0;
width: 200px;
height: 20px;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
color: #17141a;
}
.bk__state {
position: absolute;
inset: 0;
text-align: center;
}
.bk__state--off {
visibility: hidden;
}
.bkWrap[data-on=false] .bk__state--off {
visibility: visible;
}
.bkWrap[data-on=false] .bk__state--on {
visibility: hidden;
}
.bkWrap.is-demo .bk__lever {
animation-name: bkLever;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bkWrap.is-demo .bk__hand {
animation-name: bkHand;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.bkWrap.is-demo .bk__plate {
animation-name: bkThud;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.bkWrap.is-demo .bk__mark--on .bk__led::after,
.bkWrap.is-demo .bk__state--on {
animation-name: bkShowOn;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.bkWrap.is-demo .bk__mark--off .bk__led::after,
.bkWrap.is-demo .bk__state--off {
animation-name: bkShowOff;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@keyframes bkLever {
0%, 25% {
transform: translate(-50%, -50%) rotate(-26deg);
}
27%, 68% {
transform: translate(-50%, -50%) rotate(26deg);
}
70%, 100% {
transform: translate(-50%, -50%) rotate(-26deg);
}
}
@keyframes bkHand {
0% {
transform: translateY(-20px);
}
12% {
transform: translateY(0);
}
22% {
transform: translateY(16px);
}
30%, 52% {
transform: translateY(0);
}
64% {
transform: translateY(16px);
}
72% {
transform: translateY(0);
}
84%, 100% {
transform: translateY(-20px);
}
}
@keyframes bkThud {
0%, 25% {
transform: translateY(0);
}
29% {
transform: translateY(2px);
}
33%, 70% {
transform: translateY(0);
}
74% {
transform: translateY(2px);
}
78%, 100% {
transform: translateY(0);
}
}
@keyframes bkShowOn {
0%, 26% {
visibility: visible;
}
28%, 69% {
visibility: hidden;
}
71%, 100% {
visibility: visible;
}
}
@keyframes bkShowOff {
0%, 26% {
visibility: hidden;
}
28%, 69% {
visibility: visible;
}
71%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.bkWrap {
gap: 4px;
}
.bk__plate {
padding-top: 8px;
padding-bottom: 4px;
}
.bk__slot {
height: 88px;
margin-top: 4px;
}
.bk__hazard {
height: 12px;
margin-top: 4px;
}
.bk__hand {
top: 42px;
}
.bk__status {
height: 16px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.bkWrap.is-demo .bk__lever,
.bkWrap.is-demo .bk__hand,
.bkWrap.is-demo .bk__plate,
.bkWrap.is-demo .bk__mark--on .bk__led::after,
.bkWrap.is-demo .bk__mark--off .bk__led::after,
.bkWrap.is-demo .bk__state--on,
.bkWrap.is-demo .bk__state--off {
animation: none !important;
}
.bk__lever {
transition: none !important;
}
}var wrap = document.getElementById('bkWrap');
var sw = document.getElementById('bk');
// 손잡이·표시등·글월은 전부 data-on 하나로 갈린다. 화면 글자를 JS 가 건드리지 않는다.
sw.addEventListener('click', function () {
wrap.classList.remove('is-demo');
var on = sw.getAttribute('aria-checked') !== 'true';
sw.setAttribute('aria-checked', on ? 'true' : 'false');
wrap.setAttribute('data-on', on ? 'true' : 'false');
});
wrap.addEventListener('pointerdown', function () { wrap.classList.remove('is-demo'); });// BreakerSwitch — vanilla 판(run/013/demo/08-skeuo-breaker-switch) 의 React 포트.
// 손잡이·표시등·글월은 전부 on 상태 하나로 갈린다. 값·키프레임은 vanilla 와 같다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BreakerSwitch.module.scss";
export type BreakerSwitchProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BRAND = "배전 차단기";
const MARK_ON = "켜짐";
const MARK_OFF = "꺼짐";
const STATE_ON = "전원이 켜졌습니다";
const STATE_OFF = "전원이 꺼졌습니다";
const ARIA_LABEL = "전원 차단기";
export default function BreakerSwitch({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffffff",
}: BreakerSwitchProps) {
const [on, setOn] = useState(true);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle() {
setDemo(false);
setOn(!on);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} data-on={on ? "true" : "false"} style={vars}>
<button
className={styles.switch}
type="button"
role="switch"
aria-checked={on}
aria-label={ARIA_LABEL}
onClick={toggle}
>
<span className={styles.plate}>
<span className={styles.brand}>
<svg className={styles.spark} viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><g><path d="m96 240l16-80l-64-24L160 16l-16 80l64 24Z" opacity=".2"/><path d="M215.79 118.17a8 8 0 0 0-5-5.66L153.18 90.9l14.66-73.33a8 8 0 0 0-13.69-7l-112 120a8 8 0 0 0 3 13l57.63 21.61l-14.62 73.25a8 8 0 0 0 13.69 7l112-120a8 8 0 0 0 1.94-7.26M109.37 214l10.47-52.38a8 8 0 0 0-5-9.06L62 132.71l84.62-90.66l-10.46 52.38a8 8 0 0 0 5 9.06l52.8 19.8Z"/></g></svg>
<span>{BRAND}</span>
</span>
<span className={styles.slot}>
<span className={`${styles.mark} ${styles.markOn}`}><i className={styles.led}></i>{MARK_ON}</span>
<span className={`${styles.mark} ${styles.markOff}`}><i className={styles.led}></i>{MARK_OFF}</span>
<span className={styles.lever}><span className={styles.grip} aria-hidden="true"></span></span>
</span>
<span className={styles.hazard} aria-hidden="true"></span>
</span>
<span className={styles.hand} aria-hidden="true">
<svg className={styles.handIco} viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 116v36a80 80 0 0 1-80 80c-44.18 0-55.81-24-93.32-90a20 20 0 0 1 34.64-20L96 152V44a20 20 0 0 1 40 0v56a20 20 0 0 1 40 0v16a20 20 0 0 1 40 0" opacity=".2"/><path d="M196 88a27.86 27.86 0 0 0-13.35 3.39A28 28 0 0 0 144 74.7V44a28 28 0 0 0-56 0v80l-3.82-6.13A28 28 0 0 0 35.73 146l4.67 8.23C74.81 214.89 89.05 240 136 240a88.1 88.1 0 0 0 88-88v-36a28 28 0 0 0-28-28m12 64a72.08 72.08 0 0 1-72 72c-37.63 0-47.84-18-81.68-77.68l-4.69-8.27V138A12 12 0 0 1 54 121.61a11.9 11.9 0 0 1 6-1.6a12 12 0 0 1 10.41 6a2 2 0 0 0 .14.23l18.67 30A8 8 0 0 0 104 152V44a12 12 0 0 1 24 0v68a8 8 0 0 0 16 0v-12a12 12 0 0 1 24 0v20a8 8 0 0 0 16 0v-4a12 12 0 0 1 24 0Z"/></g></svg>
</span>
</button>
<p className={styles.status}>
<span className={`${styles.state} ${styles.stateOn}`}>{STATE_ON}</span>
<span className={`${styles.state} ${styles.stateOff}`}>{STATE_OFF}</span>
</p>
</div>
</main>
);
}08 · 차단기 브레이커 스위치 — 측정 누적면적 7.954% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
steps 한 단계로 짓는 무게
손잡이의 실제 전환은 transition 의 타이밍을 steps(1,end) 한 단계로 두어 떨어뜨리는데, 연속 이징으로 바꾸면 잠깐 버티다 뚝 떨어지는 기계의 무게가 스르륵 미끄러지는 움직임으로 풀려 버립니다.
.bk__lever {
position: absolute; left: 50%; top: 50%; width: clamp(34px, 9vw, 40px); height: clamp(76px, 20vw, 88px);
background: linear-gradient(90deg, rgba($stage-ink, .32), rgba($subject-cream, .5) 28%, rgba($subject-cream, .16) 55%, rgba($stage-ink, .38)), $color;
border: 1px solid rgba($stage-ink, .45); border-radius: $r-sm;
box-shadow: 0 8px 14px rgba($stage-ink, .55), inset 0 1px 0 rgba($subject-cream, .85);
transform: translate(-50%, -50%) rotate(-26deg);
transform-origin: 50% 50%;
transition: transform $duration steps(1,end);
}
컷 지점을 가운데 둔 시간표
미리보기에서 손은 22%·64% 에 손잡이를 끝까지 밀고, 각도는 27%·70% 컷에서 ±26도를 뒤집으며, 판이 2픽셀 가라앉는 bkThud 가 무게를 더합니다(움직인 프레임 16/23).
@keyframes bkLever {
0%, 25% { transform: translate(-50%, -50%) rotate(-26deg); }
27%, 68% { transform: translate(-50%, -50%) rotate(26deg); }
70%, 100% { transform: translate(-50%, -50%) rotate(-26deg); }
}
@keyframes bkHand {
0% { transform: translateY(-20px); }
12% { transform: translateY(0); }
22% { transform: translateY(16px); }
30%, 52% { transform: translateY(0); }
64% { transform: translateY(16px); }
72% { transform: translateY(0); }
84%, 100% { transform: translateY(-20px); }
}
09카세트 이젝트 버튼
이젝트 키를 누르면 테이프가 튀어나와 한 번 흔들리며 멈추고, 다시 누르면 들어가는, 레트로 뮤직 플레이어의 넣고 빼기 인터랙션입니다.

<main class="stage">
<div class="cs is-demo" id="cs" data-out="false">
<div class="cs__deck">
<div class="cs__bay">
<div class="cs__tape">
<div class="cs__label"><b>MIX 90</b><span>SIDE A</span></div>
<span class="cs__reel cs__reel--l" aria-hidden="true"></span>
<span class="cs__reel cs__reel--r" aria-hidden="true"></span>
<span class="cs__mouth" aria-hidden="true"></span>
</div>
</div>
<div class="cs__panel">
<span class="cs__vents" aria-hidden="true"></span>
<button class="cs__key" id="key" type="button" aria-pressed="false">
<svg class="cs__ejectIco" viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><g><path d="M216 168v24a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8v-24a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8M48.23 128h159.54c7.16 0 10.89-8.27 6-13.37l-73.74-77.5a16.76 16.76 0 0 0-24.14 0l-73.74 77.5c-4.81 5.1-1.08 13.37 6.08 13.37" opacity=".2"/><path d="M208 152H48a16 16 0 0 0-16 16v24a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-24a16 16 0 0 0-16-16m0 40H48v-24h160ZM48.24 136h159.52a16.18 16.18 0 0 0 14.93-9.76a15.59 15.59 0 0 0-3.1-17.12l-73.73-77.51a24.76 24.76 0 0 0-35.72 0l-73.73 77.51a15.59 15.59 0 0 0-3.1 17.12A16.18 16.18 0 0 0 48.24 136m73.49-93.36a8.77 8.77 0 0 1 12.54 0L207.85 120H48.14Z"/></g></svg>
<span>이젝트</span>
</button>
</div>
</div>
<p class="cs__status">
<span class="cs__state cs__state--in">테이프를 넣었습니다</span>
<span class="cs__state cs__state--out">테이프를 꺼냈습니다</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 카세트 이젝트 버튼 — 이젝트 키(진짜 button, aria-pressed)를 누르면 테이프가 튀어나와
// 한 번 흔들리며 멈추고, 다시 누르면 들어간다. 튀어나오는 힘은 cubic-bezier(.2,1.8,.3,1)
// 의 과돌(overshoot)이 만든다 — 첫 젖힘이 목표를 지나쳤다가 제자리로 되돌아온다.
// 뽑혀 있는지는 테이프의 위치(모양) + 키 눌림 + 아래 글월(글자)로 알린다.
// 격자 미리보기(.is-demo)는 꺼냄·넣음을 한 번씩 보여주는 장면을 2초에 한 바퀴.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-ink);
.cs {
--pop: -46px; // 테이프가 튀어나와 멈추는 높이. 좁은 화면에서는 더 얕게
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
padding-top: $sp-8; width: 100%; max-width: 360px; box-sizing: border-box;
}
.cs__deck {
width: clamp(252px, 64vw, 292px); box-sizing: border-box;
padding-bottom: $sp-2;
background: linear-gradient(180deg, rgba($subject-cream, .1), rgba($subject-cream, .02) 46%, rgba($stage-ink, .35)), $subject-ink;
border: 1px solid rgba($subject-cream, .12); border-radius: $r-card;
box-shadow: 0 14px 24px rgba(23, 20, 26, .32), inset 0 1px 0 rgba($subject-cream, .16);
}
// 테이프가 앉는 자리 — 패널보다 한 단계 깊게 파인 바이
.cs__bay {
position: relative; height: clamp(92px, 26vw, 112px); margin: $sp-3 $sp-3 0;
background: rgba($stage-ink, .85); border-radius: $r-sm;
box-shadow: inset 0 4px 10px rgba($stage-ink, .9), inset 0 -1px 0 rgba($subject-cream, .08);
}
.cs__tape {
position: absolute; left: 50%; bottom: $sp-2;
width: clamp(150px, 42vw, 174px); height: clamp(84px, 23vw, 96px); box-sizing: border-box;
background: linear-gradient(180deg, rgba($subject-cream, .35), rgba($subject-cream, 0) 30%, rgba($stage-ink, .2)), $color;
border: 1px solid rgba($stage-ink, .35); border-radius: $r-sm;
box-shadow: 0 10px 16px rgba($stage-ink, .5), inset 0 1px 0 rgba($subject-cream, .9);
transform: translateX(-50%);
// 들어감(0) 에서 뽑힘(var(--pop)) — 과돌 이징이 흔들림을 만든다
transition: transform $duration cubic-bezier(.2,1.8,.3,1);
}
.cs[data-out="true"] .cs__tape { transform: translateX(-50%) translateY(var(--pop)); }
.cs__label {
margin: $sp-2 auto 0; width: 62%; text-align: center;
background: rgba($subject-cream, .55); border-radius: $r-xs;
padding: $sp-1 0; color: $subject-ink;
box-shadow: inset 0 -1px 2px rgba($stage-ink, .18);
}
.cs__label b { display: block; font-weight: 800; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.2; }
.cs__label span { display: block; font-weight: 700; font-size: clamp(8px, 2.4vw, 9px); line-height: 1.3; color: rgba($subject-ink, .72); }
.cs__reel {
position: absolute; top: 44%; width: clamp(24px, 7vw, 28px); height: clamp(24px, 7vw, 28px);
border-radius: 50%; background: rgba($subject-cream, .5);
box-shadow: inset 0 0 0 4px rgba($stage-ink, .8), inset 0 2px 3px rgba($stage-ink, .5);
}
.cs__reel::after {
content: ""; position: absolute; left: 50%; top: 50%; width: 8px; height: 8px;
transform: translate(-50%, -50%); border-radius: 50%; background: $subject-ink;
}
.cs__reel--l { left: 16%; }
.cs__reel--r { right: 16%; }
.cs__mouth {
position: absolute; left: 50%; bottom: $sp-1; width: 56%; height: $sp-2;
transform: translateX(-50%); border-radius: $r-xs;
background: rgba($stage-ink, .85); box-shadow: inset 0 2px 3px rgba($stage-ink, .9);
}
.cs__panel {
display: flex; align-items: center; justify-content: space-between;
margin: $sp-2 $sp-3 0;
}
.cs__vents {
width: clamp(56px, 16vw, 68px); height: 14px; border-radius: $r-pill;
background: repeating-linear-gradient(90deg, rgba($subject-cream, .22) 0 4px, rgba($subject-cream, 0) 4px 10px);
box-shadow: inset 0 1px 2px rgba($stage-ink, .8);
}
.cs__key {
display: inline-flex; align-items: center; gap: $sp-1; cursor: pointer;
padding: $sp-1 $sp-3; border-radius: $r-sm;
background: linear-gradient(180deg, rgba($subject-cream, .7), rgba($subject-cream, 0) 46%, rgba($stage-ink, .16)), $color;
border: 1px solid rgba($stage-ink, .4);
box-shadow: 0 6px 10px rgba($stage-ink, .55), inset 0 1px 0 rgba($subject-cream, .9);
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4; color: $subject-ink;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.cs__ejectIco { width: 14px; height: 14px; flex: 0 0 auto; }
.cs__key:focus-visible { outline: 2px solid $subject-cream; outline-offset: 3px; }
.cs__key:active { transform: translateY(2px); box-shadow: 0 2px 4px rgba($stage-ink, .5), inset 0 2px 6px rgba($stage-ink, .3); }
// 테이프가 나와 있으면 키도 눌려 있다
.cs[data-out="true"] .cs__key {
transform: translateY(2px);
box-shadow: 0 2px 4px rgba($stage-ink, .5), inset 0 2px 6px rgba($stage-ink, .3);
}
.cs__status {
position: relative; margin: 0; width: 220px; height: 20px;
font-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 20px; color: $subject-cream;
}
.cs__state { position: absolute; inset: 0; text-align: center; }
.cs__state--out { visibility: hidden; }
.cs[data-out="true"] .cs__state--out { visibility: visible; }
.cs[data-out="true"] .cs__state--in { visibility: hidden; }
// ── 격자 미리보기: 꺼냄 한 번, 넣음 한 번 ─────────────────────────────
// 6% 에 키가 눌리고 20% 까지 튀어올라 42% 부터 멈춰 선다(한 번 흔들림은 20 부터 36 까지).
// 72% 에 다시 눌려 86% 에 완전히 들어가고 마지막 14% 는 들어감 그대로 멈춘다(복귀 90% 안).
.cs.is-demo .cs__tape {
animation-name: csPop; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.cs.is-demo .cs__key {
animation-name: csKey; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.cs.is-demo .cs__state--in { animation-name: csShowIn; animation-duration: $dur-loop; animation-iteration-count: infinite; }
.cs.is-demo .cs__state--out { animation-name: csShowOut; animation-duration: $dur-loop; animation-iteration-count: infinite; }
@keyframes csPop {
0%, 6% { transform: translateX(-50%) translateY(0); }
20% { transform: translateX(-50%) translateY(calc(var(--pop) - 12px)); }
28% { transform: translateX(-50%) translateY(calc(var(--pop) + 6px)); }
36% { transform: translateX(-50%) translateY(calc(var(--pop) - 4px)); }
42%, 72% { transform: translateX(-50%) translateY(var(--pop)); }
86%, 100% { transform: translateX(-50%) translateY(0); }
}
@keyframes csKey {
0%, 4% { transform: translateY(0); }
8%, 16% { transform: translateY(2px); }
24%, 68% { transform: translateY(0); }
72%, 80% { transform: translateY(2px); }
86%, 100% { transform: translateY(0); }
}
// 글월 교체는 visibility 컷 — 반투명 유령 프레임이 없다. 키가 완전히 눌린 뒤 기구가 물리는
// 10% 에 꺼냄으로, 테이프가 자리에 닿는 85% 에 넣음으로 돌아온다. 둘 다 12fps 샘플 시점
// (8.33 · 12.5 · 83.33 · 87.5%) 사이의 빈칸에서 뒤집힌다.
@keyframes csShowIn {
0%, 9% { visibility: visible; }
11%, 77% { visibility: hidden; }
79%, 100% { visibility: visible; }
}
@keyframes csShowOut {
0%, 9% { visibility: hidden; }
11%, 77% { visibility: visible; }
79%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.cs { --pop: -26px; padding-top: $sp-4; gap: $sp-1; }
.cs__bay { height: 84px; margin-top: $sp-2; }
.cs__tape { height: 74px; }
.cs__panel { margin-top: $sp-1; }
.cs__status { height: 16px; line-height: 16px; }
}
// reduce 에서는 테이프가 들어간 한 장으로 선다 — 정적 경로가 화면을 채운다
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__tape,
.cs.is-demo .cs__key,
.cs.is-demo .cs__state--in,
.cs.is-demo .cs__state--out { animation: none !important; }
.cs__tape, .cs__key { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cs {
--pop: -46px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 32px;
width: 100%;
max-width: 360px;
box-sizing: border-box;
}
.cs__deck {
width: clamp(252px, 64vw, 292px);
box-sizing: border-box;
padding-bottom: 8px;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.1), rgba(255, 247, 230, 0.02) 46%, rgba(23, 20, 26, 0.35)), #17141a;
border: 1px solid rgba(255, 247, 230, 0.12);
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32), inset 0 1px 0 rgba(255, 247, 230, 0.16);
}
.cs__bay {
position: relative;
height: clamp(92px, 26vw, 112px);
margin: 12px 12px 0;
background: rgba(23, 20, 26, 0.85);
border-radius: 8px;
box-shadow: inset 0 4px 10px rgba(23, 20, 26, 0.9), inset 0 -1px 0 rgba(255, 247, 230, 0.08);
}
.cs__tape {
position: absolute;
left: 50%;
bottom: 8px;
width: clamp(150px, 42vw, 174px);
height: clamp(84px, 23vw, 96px);
box-sizing: border-box;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.35), rgba(255, 247, 230, 0) 30%, rgba(23, 20, 26, 0.2)), #ffffff;
border: 1px solid rgba(23, 20, 26, 0.35);
border-radius: 8px;
box-shadow: 0 10px 16px rgba(23, 20, 26, 0.5), inset 0 1px 0 rgba(255, 247, 230, 0.9);
transform: translateX(-50%);
transition: transform 220ms cubic-bezier(0.2, 1.8, 0.3, 1);
}
.cs[data-out=true] .cs__tape {
transform: translateX(-50%) translateY(var(--pop));
}
.cs__label {
margin: 8px auto 0;
width: 62%;
text-align: center;
background: rgba(255, 247, 230, 0.55);
border-radius: 4px;
padding: 4px 0;
color: #17141a;
box-shadow: inset 0 -1px 2px rgba(23, 20, 26, 0.18);
}
.cs__label b {
display: block;
font-weight: 800;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.2;
}
.cs__label span {
display: block;
font-weight: 700;
font-size: clamp(8px, 2.4vw, 9px);
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.cs__reel {
position: absolute;
top: 44%;
width: clamp(24px, 7vw, 28px);
height: clamp(24px, 7vw, 28px);
border-radius: 50%;
background: rgba(255, 247, 230, 0.5);
box-shadow: inset 0 0 0 4px rgba(23, 20, 26, 0.8), inset 0 2px 3px rgba(23, 20, 26, 0.5);
}
.cs__reel::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 8px;
height: 8px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: #17141a;
}
.cs__reel--l {
left: 16%;
}
.cs__reel--r {
right: 16%;
}
.cs__mouth {
position: absolute;
left: 50%;
bottom: 4px;
width: 56%;
height: 8px;
transform: translateX(-50%);
border-radius: 4px;
background: rgba(23, 20, 26, 0.85);
box-shadow: inset 0 2px 3px rgba(23, 20, 26, 0.9);
}
.cs__panel {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 12px 0;
}
.cs__vents {
width: clamp(56px, 16vw, 68px);
height: 14px;
border-radius: 999px;
background: repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.22) 0 4px, rgba(255, 247, 230, 0) 4px 10px);
box-shadow: inset 0 1px 2px rgba(23, 20, 26, 0.8);
}
.cs__key {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px 12px;
border-radius: 8px;
background: linear-gradient(180deg, rgba(255, 247, 230, 0.7), rgba(255, 247, 230, 0) 46%, rgba(23, 20, 26, 0.16)), #ffffff;
border: 1px solid rgba(23, 20, 26, 0.4);
box-shadow: 0 6px 10px rgba(23, 20, 26, 0.55), inset 0 1px 0 rgba(255, 247, 230, 0.9);
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
color: #17141a;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cs__ejectIco {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.cs__key:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.cs__key:active {
transform: translateY(2px);
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.5), inset 0 2px 6px rgba(23, 20, 26, 0.3);
}
.cs[data-out=true] .cs__key {
transform: translateY(2px);
box-shadow: 0 2px 4px rgba(23, 20, 26, 0.5), inset 0 2px 6px rgba(23, 20, 26, 0.3);
}
.cs__status {
position: relative;
margin: 0;
width: 220px;
height: 20px;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 20px;
color: #fff7e6;
}
.cs__state {
position: absolute;
inset: 0;
text-align: center;
}
.cs__state--out {
visibility: hidden;
}
.cs[data-out=true] .cs__state--out {
visibility: visible;
}
.cs[data-out=true] .cs__state--in {
visibility: hidden;
}
.cs.is-demo .cs__tape {
animation-name: csPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.cs.is-demo .cs__key {
animation-name: csKey;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.cs.is-demo .cs__state--in {
animation-name: csShowIn;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.cs.is-demo .cs__state--out {
animation-name: csShowOut;
animation-duration: 2s;
animation-iteration-count: infinite;
}
@keyframes csPop {
0%, 6% {
transform: translateX(-50%) translateY(0);
}
20% {
transform: translateX(-50%) translateY(calc(var(--pop) - 12px));
}
28% {
transform: translateX(-50%) translateY(calc(var(--pop) + 6px));
}
36% {
transform: translateX(-50%) translateY(calc(var(--pop) - 4px));
}
42%, 72% {
transform: translateX(-50%) translateY(var(--pop));
}
86%, 100% {
transform: translateX(-50%) translateY(0);
}
}
@keyframes csKey {
0%, 4% {
transform: translateY(0);
}
8%, 16% {
transform: translateY(2px);
}
24%, 68% {
transform: translateY(0);
}
72%, 80% {
transform: translateY(2px);
}
86%, 100% {
transform: translateY(0);
}
}
@keyframes csShowIn {
0%, 9% {
visibility: visible;
}
11%, 77% {
visibility: hidden;
}
79%, 100% {
visibility: visible;
}
}
@keyframes csShowOut {
0%, 9% {
visibility: hidden;
}
11%, 77% {
visibility: visible;
}
79%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.cs {
--pop: -26px;
padding-top: 16px;
gap: 4px;
}
.cs__bay {
height: 84px;
margin-top: 8px;
}
.cs__tape {
height: 74px;
}
.cs__panel {
margin-top: 4px;
}
.cs__status {
height: 16px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__tape,
.cs.is-demo .cs__key,
.cs.is-demo .cs__state--in,
.cs.is-demo .cs__state--out {
animation: none !important;
}
.cs__tape, .cs__key {
transition: none !important;
}
}var root = document.getElementById('cs');
var key = document.getElementById('key');
// 테이프·키·글월은 전부 data-out 하나로 갈린다. 화면 글자를 JS 가 건드리지 않는다.
key.addEventListener('click', function () {
root.classList.remove('is-demo');
var out = root.getAttribute('data-out') !== 'true';
root.setAttribute('data-out', out ? 'true' : 'false');
key.setAttribute('aria-pressed', out ? 'true' : 'false');
});
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });// CassetteEject — vanilla 판(run/013/demo/09-skeuo-cassette-eject) 의 React 포트.
// 테이프·키·글월은 전부 out 상태 하나로 갈린다. 값·키프레임은 vanilla 와 같다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CassetteEject.module.scss";
export type CassetteEjectProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const LABEL_TITLE = "MIX 90";
const LABEL_SIDE = "SIDE A";
const KEY_TEXT = "이젝트";
const STATE_IN = "테이프를 넣었습니다";
const STATE_OUT = "테이프를 꺼냈습니다";
export default function CassetteEject({
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffffff",
}: CassetteEjectProps) {
const [out, setOut] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle() {
setDemo(false);
setOut(!out);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} data-out={out ? "true" : "false"} style={vars}>
<div className={styles.deck}>
<div className={styles.bay}>
<div className={styles.tape}>
<div className={styles.label}><b>{LABEL_TITLE}</b><span>{LABEL_SIDE}</span></div>
<span className={`${styles.reel} ${styles.reelL}`} aria-hidden="true"></span>
<span className={`${styles.reel} ${styles.reelR}`} aria-hidden="true"></span>
<span className={styles.mouth} aria-hidden="true"></span>
</div>
</div>
<div className={styles.panel}>
<span className={styles.vents} aria-hidden="true"></span>
<button className={styles.key} type="button" aria-pressed={out} onClick={toggle}>
<svg className={styles.ejectIco} viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><g><path d="M216 168v24a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8v-24a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8M48.23 128h159.54c7.16 0 10.89-8.27 6-13.37l-73.74-77.5a16.76 16.76 0 0 0-24.14 0l-73.74 77.5c-4.81 5.1-1.08 13.37 6.08 13.37" opacity=".2"/><path d="M208 152H48a16 16 0 0 0-16 16v24a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-24a16 16 0 0 0-16-16m0 40H48v-24h160ZM48.24 136h159.52a16.18 16.18 0 0 0 14.93-9.76a15.59 15.59 0 0 0-3.1-17.12l-73.73-77.51a24.76 24.76 0 0 0-35.72 0l-73.73 77.51a15.59 15.59 0 0 0-3.1 17.12A16.18 16.18 0 0 0 48.24 136m73.49-93.36a8.77 8.77 0 0 1 12.54 0L207.85 120H48.14Z"/></g></svg>
<span>{KEY_TEXT}</span>
</button>
</div>
</div>
<p className={styles.status}>
<span className={`${styles.state} ${styles.stateIn}`}>{STATE_IN}</span>
<span className={`${styles.state} ${styles.stateOut}`}>{STATE_OUT}</span>
</p>
</div>
</main>
);
}09 · 카세트 이젝트 버튼 — 측정 누적면적 19.301% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
과돌 이징이 만드는 흔들림
튀어나오는 힘은 cubic-bezier(.2, 1.8, .3, 1) 의 과돌에서 나와, 첫 튕김이 목표 높이 --pop(-46px) 을 지나쳤다가 되돌아오며, 좁은 화면(340px 이하)에서는 --pop 이 -26px 로 낮아집니다.
.cs__tape {
position: absolute; left: 50%; bottom: $sp-2;
width: clamp(150px, 42vw, 174px); height: clamp(84px, 23vw, 96px); box-sizing: border-box;
background: linear-gradient(180deg, rgba($subject-cream, .35), rgba($subject-cream, 0) 30%, rgba($stage-ink, .2)), $color;
border: 1px solid rgba($stage-ink, .35); border-radius: $r-sm;
box-shadow: 0 10px 16px rgba($stage-ink, .5), inset 0 1px 0 rgba($subject-cream, .9);
transform: translateX(-50%);
// 들어감(0) 에서 뽑힘(var(--pop)) — 과돌 이징이 흔들림을 만든다
transition: transform $duration cubic-bezier(.2,1.8,.3,1);
}
꺼냄과 넣음의 시간표
미리보기에서 테이프는 20%·28%·36% 세 번의 흔들림을 거쳐 42% 에 멈추고 86% 에 완전히 들어가는데, 강도 평균 113.7 은 아홉 개 중 가장 센 값입니다.
@keyframes csPop {
0%, 6% { transform: translateX(-50%) translateY(0); }
20% { transform: translateX(-50%) translateY(calc(var(--pop) - 12px)); }
28% { transform: translateX(-50%) translateY(calc(var(--pop) + 6px)); }
36% { transform: translateX(-50%) translateY(calc(var(--pop) - 4px)); }
42%, 72% { transform: translateX(-50%) translateY(var(--pop)); }
86%, 100% { transform: translateX(-50%) translateY(0); }
}
어디서 깨지는가 — 코드를 옮기면 죽는 움직임
원근은 직계 부모에 걸려야 산다
05 의 눌림을 만드는 선언은 transform: translateZ(-120px) 한 줄인데, 이 줄은 관찰자까지의 거리를 정하는 perspective 가 없으면 화면에서 아무 일도 하지 않습니다. 소스에서 perspective: 600px 는 버튼을 직접 담은 .bb__well 에, 06 의 덮개가 도는 perspective: 520px 는 .pk__lidWrap 에 걸려 있습니다. CSS 의 perspective 는 직계 자식에게만 적용되므로, 코드를 베끼다 버튼을 한 겹 더 싸거나 원근을 더 위 조상으로 옮기는 순간 눌림이 통째로 사라집니다. 한 단계를 더 끼우려면 그 상자에 transform-style: preserve-3d 를 이어 주어야 하는데, 실제로 .is-demo 를 뗀 05 를 눌러 확인한 값도 이 길을 그대로 밟습니다 — 버튼의 변환행렬이 matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -120, 1) 로 Z 축 -120 을 잡았다가 다시 누르면 none 으로 돌아옵니다(run/013/_probe/interact_04_06.json). 근거는 MDN 의 perspective 문서입니다.
드래그가 스크롤과 싸우는 자리
03·04·06 처럼 손가락이 따라다니는 조작 요소에는 touch-action: none 이 한 줄씩 들어 있는데, 이 줄이 없으면 터치 드래그를 시작하는 순간 브라우저가 스크롤을 차지하려 들어 pointermove 가 몇 프레임 만에 끊기고 포인터 캡처도 이 싸움을 대신 끝내 주지는 않습니다(MDN Pointer events). 토글 스위치 css 예제를 남의 레이아웃에 옮겨 심을 때는 이렇게 딸린 한 줄까지 함께 가져가야 움직임이 살고, 06 을 실제로 당겨 보면 노브가 44픽셀 내려오며 세로로 1.2배 늘어난 뒤 제자리로 돌아가는 것까지 같은 파일에 잡혀 있습니다. 대비도 .is-demo 를 뗀 상태에서 글자 36자리를 전부 다시 잰 결과 한 자리도 AA(4.5:1) 밑이 없습니다(측정 스크립트 run/013/_probe_contrast/contrast.py).
| 잰 곳 | 글자 | 배경 | 대비 | 판정 |
|---|---|---|---|---|
| 01 상태 단어 꺼짐 | #ded7c9 | #252226 | 11.03:1 | AA |
| 03 눈금 숫자 0 | #17141a | #ff4d1f | 5.5:1 | AA |
| 04 켜진 눈금 숫자 5 | #17141a | #ffffff | 18.24:1 | AA |
| 05 경고등 글자 | #17141a | #fff7e6 | 17.11:1 | AA |
| 07 패드 아래 글자 | #58565a | #ffffff | 7.27:1 | AA |
가장 낮은 자리는 03·07 의 주황 무대(#ff4d1f) 위 먹(#17141a) 글자 5.5:1 로, 두 판의 글자를 옅은 색 없이 온전히 먹으로 찍은 덕분에 기준을 넘었습니다. 아홉 개의 SCSS 원본과 컴파일된 CSS, 항목마다 붙인 React 판까지 담은 압축 파일의 비밀번호는 8a45qw2n 입니다.
변형 세 가지와 접근성
SCSS 맨 위 변수 세 줄로 톤 바꾸기
아홉 개의 style.scss 는 머리에 같은 변수 세 줄($duration·$easing·$color)을 두고 아래 선언이 전부 그 셋만 참조합니다. 01 은 이징 칸에 $ease-pop 의 값 cubic-bezier(.2, 1.4, .4, 1) 을 직접 적어 두었고, 05·06 은 셋째 칸을 각각 $error·$subject-blue 로 씁니다.
| 이름 | 바꾸는 값 | 느낌 |
|---|---|---|
| duration | $dur-quick 220ms → 120ms |
레버·로커가 기계 부품처럼 딱 끊기게 떨어진다 |
| easing | $ease-spring → $ease-pop |
이젝트·뱅 버튼이 튀어나오는 힘이 한 단계 세진다 |
| color | $stage-paper → $stage-yellow |
플라스틱 몸체가 크림에서 산업용 옐로로 바뀐다 |
움직임을 줄였을 때 남는 한 장
prefers-reduced-motion: reduce 에서는 아홉 루프가 모두 꺼지고 각자 다른 정지 장면을 남깁니다. 01 은 왼쪽으로 기운 레버와 꺼진 LED, 02 는 윗면이 떠 있는 기본 로커, 03 은 0% 바늘과 실제 값 글자, 04 는 가운데 값 5 의 캡, 05 는 올라온 버튼, 06 은 닫힌 덮개, 07 은 왼쪽으로 기운 판, 08 은 켜짐(손잡이 위), 09 는 들어간 테이프입니다. reduce 블록의 선언마다 !important 를 단 까닭은 .is-demo 가 붙은 선택자의 특이도가 더 높아서이고, 기준은 WCAG 2.2 상호작용 애니메이션 이해 문서입니다.
색 없이도 읽히는 장치
아홉 개 모두 색을 뺀 화면에서 상태가 읽힙니다. 01·02 는 LED 점과 ON·OFF 글자에 상태 단어를 role="status" 로 덧붙였고, 03·04 는 값을 숫자와 aria-valuenow 로 함께 내보내고, 03 은 aria-valuetext 가 '0퍼센트' 꼴로 소리 내어 읽힙니다. 05 는 대기·가동 글자에 aria-pressed, 06 은 잠김·열림 글자에 aria-expanded, 07 은 기울기·눌림·A/B 글자에 aria-pressed, 08·09 는 손잡이 각도와 테이프 위치에 상태 문장을 곁들였습니다. 스위치와 슬라이더의 역할 정의는 MDN switch role과 MDN slider role을 그대로 따랐습니다.
FAQ
css 버튼 디자인 예제를 그대로 복사해 써도 되나요
그래도 됩니다. 압축 파일에는 항목마다 SCSS 원본, 컴파일된 CSS, HTML, React 판(컴포넌트와 module.scss)이 들어 있어 그대로 붙여 넣으면 돌아갑니다. SCSS 원본을 쓸 때는 파일 머리의 @use 경로만 프로젝트에 맞게 고치면 되고, 아이콘만 있는 자리를 채우는 짝은 아이콘·플로팅 버튼 모음입니다.
폼 제출에는 어떻게 연결하나요
이 컨트롤의 상태는 그림을 가르는 속성 하나에만 담깁니다. 01·02·08 은 aria-checked, 03·04 는 aria-valuenow, 05·07·09 는 aria-pressed, 06 은 aria-expanded 이니 제출 시점에 이 값을 읽어 숨은 input 에 옮기면 되고, JS 없이 폼만 쓰겠다면 <input type="checkbox"> 를 라벨로 감싸고 체크 상태를 그림으로 옮기는 쪽으로 갈아 끼우면 됩니다. 서식 전체를 같은 결로 꾸밀 때는 입력창 디자인 9종이 이어집니다.
다이얼 회전 범위를 270도보다 넓게 쓰려면
세 곳이 함께 움직여야 합니다. JS 의 MIN·MAX 상수(-135·135)가 바늘과 값의 범위이고, 눈금 링의 repeating-conic-gradient(27도 간격, 눈금 11개)와 진행 호의 conic-gradient(270도 부채꼴)가 눈에 보이는 범위입니다. 이 설계는 아래쪽 90도 구간을 비워 두었으므로 360도에 가깝게 늘리면 노브가 아래쪽 숫자를 가리게 됩니다. 범위를 늘릴 때는 값 판 쪽으로 눈금 숫자를 옮기는 일까지 함께 계획하는 것이 안전합니다.








