09カセットイジェクトボタン
イジェクトキーを押すとテープが飛び出して一度揺れて止まり、もう一度押すと収まります。飛び出しているかどうかは aria-pressed で読めます。
使いどころレトロなミュージックプレイヤー、メディアの挿入・取り出しインタラクション
aria-pressedcubic-bezier(.2,1.8,.3,1)translateYkeyframes

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

