01Trượt cơ bản
Khi checkbox nhận checked, núm tròn trượt qua translateX đến sát mép phải track, còn track lật giữa hai lớp nền chồng nhau chỉ qua opacity để đổi màu.
Dùng choSwitch tùy chọn trong màn hình cài đặt, thay thế checkbox trong form
translateX:checked0.25초

<main class="stage">
<label class="switch is-demo">
<input type="checkbox" class="switch__input" role="switch">
<span class="switch__track">
<span class="switch__bg switch__bg--off"></span>
<span class="switch__bg switch__bg--on"></span>
<span class="switch__knob"></span>
</span>
<span class="switch__label">Thông báo</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.switch {
position: relative;
display: inline-flex; align-items: center; gap: clamp(10px, 3vw, 14px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.switch__input {
position: absolute; inset: 0; width: 100%; height: 100%;
margin: 0; opacity: 0; cursor: pointer;
}
.switch__track {
position: relative; overflow: hidden;
width: clamp(44px, 12vw, 52px); height: clamp(24px, 6.5vw, 28px);
border-radius: $r-pill;
}
.switch__bg { position: absolute; inset: 0; }
.switch__bg--off { background: rgba(255, 255, 255, .18); opacity: 1; }
.switch__bg--on { background: $color; opacity: 0; }
.switch__knob {
position: absolute; top: 3px; left: 3px; z-index: 1;
width: clamp(18px, 5vw, 22px); height: clamp(18px, 5vw, 22px);
border-radius: 50%;
background: $subject-cream;
box-shadow: $shadow-press;
transform: translateX(0);
transition: transform $duration cubic-bezier(0.34, 1.56, 0.64, 1);
}
.switch__label { color: $subject-cream; font-weight: 600; font-size: clamp(14px, 4vw, 17px); line-height: 1; font-family: inherit; }
// ── 실제 동작: :checked 로 진짜 토글된다 ──────────────────
.switch__input:checked ~ .switch__track .switch__bg--off { opacity: 0; transition: opacity $duration $easing; }
.switch__input:checked ~ .switch__track .switch__bg--on { opacity: 1; transition: opacity $duration $easing; }
.switch__input:checked ~ .switch__track .switch__knob { transform: translateX(clamp(20px, 6vw, 24px)); }
.switch__input:focus-visible ~ .switch__track {
outline: 2px solid $subject-cream; outline-offset: 3px;
}
// ── 데모 루프: 클릭 전까지 스스로 켜졌다 꺼진다 ──────────────
// 한 겹: 노브는 도착 지점을 지나쳤다 되돌아오며 찌그러지고(스프링 오버슛+squash),
// 트랙은 상태가 바뀌는 순간 내부 그림자가 깊어졌다 얕아진다(바닥에 앉는 깊이).
.switch.is-demo .switch__bg--off { animation: bg-off-loop $dur-loop $easing infinite; }
.switch.is-demo .switch__bg--on { animation: bg-on-loop $dur-loop $easing infinite; }
.switch.is-demo .switch__knob { animation: knob-spring-loop $dur-loop $easing infinite; }
.switch.is-demo .switch__track { animation: shadow-depth-loop $dur-loop $easing infinite; }
@keyframes bg-off-loop { 0%, 15% { opacity: 1; } 40%, 60% { opacity: 0; } 85%, 100% { opacity: 1; } }
@keyframes bg-on-loop { 0%, 15% { opacity: 0; } 40%, 60% { opacity: 1; } 85%, 100% { opacity: 0; } }
@keyframes knob-loop {
0%, 15% { transform: translateX(0); }
40%, 60% { transform: translateX(24px); }
85%, 100% { transform: translateX(0); }
}
@keyframes knob-spring-loop {
0%, 15% { transform: translateX(0) scaleX(1) scaleY(1); }
38% { transform: translateX(27px) scaleX(1.25) scaleY(.85); } // 오버슈트 + 도착 순간 squash
42% { transform: translateX(23.5px) scaleX(1.08) scaleY(.94); } // 되튕기며 회복
46%, 60% { transform: translateX(24px) scaleX(1) scaleY(1); } // 안착
84% { transform: translateX(-2px) scaleX(.92) scaleY(1.06); } // 출발 반동
85%, 100% { transform: translateX(0) scaleX(1) scaleY(1); }
}
@keyframes shadow-depth-loop {
0%, 15% { box-shadow: inset 0 1px 3px rgba(0, 0, 0, .15); }
38% { box-shadow: inset 0 3px 8px rgba(0, 0, 0, .28); } // 노브가 내려앉는 순간 깊어짐
40%, 60% { box-shadow: inset 0 3px 8px rgba(0, 0, 0, .28); }
85%, 100% { box-shadow: inset 0 1px 3px rgba(0, 0, 0, .15); }
}
// ── 접근성: 움직임 줄이기 설정이면 전환만 남기고 데모 루프는 끈다 ──────
@media (prefers-reduced-motion: reduce) {
.switch.is-demo .switch__bg--off,
.switch.is-demo .switch__bg--on,
.switch.is-demo .switch__knob,
.switch.is-demo .switch__track { animation: none !important; }
.switch__knob, .switch__bg--off, .switch__bg--on { transition: none; }
}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);
}
.switch {
position: relative;
display: inline-flex;
align-items: center;
gap: clamp(10px, 3vw, 14px);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.switch__input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.switch__track {
position: relative;
overflow: hidden;
width: clamp(44px, 12vw, 52px);
height: clamp(24px, 6.5vw, 28px);
border-radius: 999px;
}
.switch__bg {
position: absolute;
inset: 0;
}
.switch__bg--off {
background: rgba(255, 255, 255, 0.18);
opacity: 1;
}
.switch__bg--on {
background: #2f6df6;
opacity: 0;
}
.switch__knob {
position: absolute;
top: 3px;
left: 3px;
z-index: 1;
width: clamp(18px, 5vw, 22px);
height: clamp(18px, 5vw, 22px);
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.switch__label {
color: #fff7e6;
font-weight: 600;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
}
.switch__input:checked ~ .switch__track .switch__bg--off {
opacity: 0;
transition: opacity 300ms ease-out;
}
.switch__input:checked ~ .switch__track .switch__bg--on {
opacity: 1;
transition: opacity 300ms ease-out;
}
.switch__input:checked ~ .switch__track .switch__knob {
transform: translateX(clamp(20px, 6vw, 24px));
}
.switch__input:focus-visible ~ .switch__track {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.switch.is-demo .switch__bg--off {
animation: bg-off-loop 2s ease-out infinite;
}
.switch.is-demo .switch__bg--on {
animation: bg-on-loop 2s ease-out infinite;
}
.switch.is-demo .switch__knob {
animation: knob-spring-loop 2s ease-out infinite;
}
.switch.is-demo .switch__track {
animation: shadow-depth-loop 2s ease-out infinite;
}
@keyframes bg-off-loop {
0%, 15% {
opacity: 1;
}
40%, 60% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes bg-on-loop {
0%, 15% {
opacity: 0;
}
40%, 60% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes knob-loop {
0%, 15% {
transform: translateX(0);
}
40%, 60% {
transform: translateX(24px);
}
85%, 100% {
transform: translateX(0);
}
}
@keyframes knob-spring-loop {
0%, 15% {
transform: translateX(0) scaleX(1) scaleY(1);
}
38% {
transform: translateX(27px) scaleX(1.25) scaleY(0.85);
}
42% {
transform: translateX(23.5px) scaleX(1.08) scaleY(0.94);
}
46%, 60% {
transform: translateX(24px) scaleX(1) scaleY(1);
}
84% {
transform: translateX(-2px) scaleX(0.92) scaleY(1.06);
}
85%, 100% {
transform: translateX(0) scaleX(1) scaleY(1);
}
}
@keyframes shadow-depth-loop {
0%, 15% {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.15);
}
38% {
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.28);
}
40%, 60% {
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.28);
}
85%, 100% {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.15);
}
}
@media (prefers-reduced-motion: reduce) {
.switch.is-demo .switch__bg--off,
.switch.is-demo .switch__bg--on,
.switch.is-demo .switch__knob,
.switch.is-demo .switch__track {
animation: none !important;
}
.switch__knob, .switch__bg--off, .switch__bg--on {
transition: none;
}
}// 실제 클릭이 오면 데모 루프(.is-demo)를 떼고 :checked 로만 움직이게 한다.
// aria-checked 는 role="switch" 를 쓴 네이티브 체크박스라 브라우저가 checked 에서 자동으로 읽지만,
// 오래된 스크린리더까지 챙기려면 change 때마다 명시적으로 맞춰 둔다.
document.querySelectorAll(".switch").forEach(function (s) {
var input = s.querySelector(".switch__input");
var sync = function () { input.setAttribute("aria-checked", String(input.checked)); };
input.addEventListener("pointerdown", function () { s.classList.remove("is-demo"); });
input.addEventListener("change", sync);
sync();
});import { useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./ToggleSwitch.module.scss";
export type ToggleSwitchProps = {
label?: string;
/** 전환 시간 (기본 300ms) */
duration?: number;
/** CSS easing (기본 스프링 오버슛) */
easing?: string;
/** on 상태 트랙 색 */
color?: string;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ToggleSwitch({
label = "알림",
duration = 300,
easing = "cubic-bezier(0.34, 1.56, 0.64, 1)",
color = "#2f6df6",
defaultChecked = false,
onChange,
}: ToggleSwitchProps) {
const [checked, setChecked] = useState(defaultChecked);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<label className={styles.switch} style={vars}>
<input
type="checkbox"
role="switch"
aria-checked={checked}
className={styles.switch__input}
checked={checked}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setChecked(e.target.checked);
onChange?.(e.target.checked);
}}
/>
<span className={styles.switch__track}>
<span className={styles["switch__bg--off"]} />
<span className={styles["switch__bg--on"]} />
<span className={styles.switch__knob} />
</span>
<span className={styles.switch__label}>{label}</span>
</label>
);
}01 Trượt cơ bản — Diện tích đo được 0.917% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 CSS Toggle Switches — dán 1 dòng là chạy
Bài viết chứa demo này
9 CSS Toggle Switches — dán 1 dòng là chạyCSS toggle switch là control chỉ dùng một input checkbox để bật tắt thật, còn track và núm là lớp vỏ CSS phủ lên trên. C Xem tất cả demo trong bài này


