06引いて開けるプルノブ
ノブを下に引くと一瞬伸びてからロックが外れて蓋が開き、指を離すと元の位置へ戻ります。開いたことは aria-expanded で伝えます。
使いどころ機械の蓋を開けるコントロール、金庫のロック解除ウィジェット
aria-expandedpointerupscaletransition

<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 オリジナル · SCSS と React は zip に同梱 · CSS スイッチボタン 9選 — レバー・ダイヤル・イジェクト、本物の感触
このデモが属する記事
CSS スイッチボタン 9選 — レバー・ダイヤル・イジェクト、本物の感触css スイッチボタンとは、画面上のオン・オフをレバー・ロッカー・ダイヤルの本物の感触で見せるボタンのことです。9つともライブラリなしのSCSSと短いJSだけで組んであり、押せばその場で傾き、回り、飛び出し、スクリーンリーダーにも状態が伝わ この記事のデモをすべて見る

