02Vertical glass-gloss toggle pair
A glossy knob slides up and down inside an upright groove, and because the two switches sit in opposite states you can read 0 and 1 side by side while the shadow widens as the knob settles.
Best forTwo equipment-panel channels
linear-gradienttranslateY:checked

<main class="stage">
<div class="ch is-demo" id="ch">
<div class="ch__unit ch__unit--a">
<input class="ch__input" id="chA" type="checkbox" role="switch" aria-label="Channel 1">
<div class="ch__scale" aria-hidden="true">
<span class="ch__num ch__num--zero">0</span>
<span class="ch__num ch__num--one">1</span>
</div>
<div class="ch__track">
<span class="ch__detent ch__detent--top" aria-hidden="true"></span>
<span class="ch__detent ch__detent--bottom" aria-hidden="true"></span>
<span class="ch__knob" aria-hidden="true"></span>
</div>
</div>
<div class="ch__unit ch__unit--b">
<input class="ch__input" id="chB" type="checkbox" role="switch" aria-label="Channel 2" checked>
<div class="ch__scale" aria-hidden="true">
<span class="ch__num ch__num--zero">0</span>
<span class="ch__num ch__num--one">1</span>
</div>
<div class="ch__track">
<span class="ch__detent ch__detent--top" aria-hidden="true"></span>
<span class="ch__detent ch__detent--bottom" aria-hidden="true"></span>
<span class="ch__knob" aria-hidden="true"></span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유리 광택 세로 토글 두 개 — 세로로 선 어두운 홈을 유리 노브가 위아래로 미끄러진다.
// 두 채널은 서로 반대 상태로 놓여 0 과 1 을 나란히 견준다. 상태는 위치·그림자·숫자 강조 세 가지로 읽힌다.
// 노브가 내려앉을수록 그림자가 넓게 번진다(box-shadow 두 벌을 상태마다 갈아 낀다).
// 딱 끊기는 성격에 살짝 오버슈트 — $ease-pop 이 트랜지션 한 번으로 만든다.
// 입력은 체크박스에 role="switch"(aria-checked 는 native 노출). CSS 는 :checked 로만 상태를 만든다.
// 미리보기(.is-demo)는 두 노브가 엇갈려 왕복하고 숫자 강조는 steps(1, end) 로 컷된다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-yellow);
.ch { display: flex; align-items: center; justify-content: center; gap: $sp-5; width: 100%; }
.ch__unit { position: relative; display: flex; gap: $sp-2; }
// 입력은 눈에 보이지 않지만 단위 전체를 덮는다(클림 영역이 홈보다 넓다)
.ch__input { position: absolute; inset: 0; z-index: 10; margin: 0; opacity: 0; cursor: pointer; }
// ── 숫자 눈금(0 은 상단 높이, 1 은 하단 높이) ────────────────
.ch__scale { position: relative; width: 14px; height: 160px; }
.ch__num {
position: absolute; left: 0;
font-weight: 700; font-size: 14px; line-height: 1;
color: rgba($subject-ink, .72);
}
.ch__num--zero { top: 25px; color: $subject-ink; }
.ch__num--one { bottom: 25px; }
// 현재 값만 진하게 — 체크되면 강조가 0 에서 1 로 옮겨 간다
.ch__input:checked ~ .ch__scale .ch__num--zero { color: rgba($subject-ink, .72); }
.ch__input:checked ~ .ch__scale .ch__num--one { color: $subject-ink; }
// ── 홈(트랙) ─────────────────────────────────────────────────
.ch__track {
--travel: 96px; // 노브가 반대쪽 끝까지 가는 거리(세로 160 − 노브 46 − 위아래 여백 18)
position: relative; width: 64px; height: 160px; border-radius: $r-pill;
background: linear-gradient(180deg, rgba($subject-ink, .92), rgba($subject-ink, .8));
box-shadow: inset 0 3px 6px rgba(0, 0, 0, .6), inset 0 -1px 0 rgba($color, .25), 0 2px 0 rgba($stage-paper, .7);
}
.ch__input:focus-visible ~ .ch__track { outline: 2px solid $subject-ink; outline-offset: 3px; }
// 위·아래 정지점(디텐트) — 노브가 닿는 높이의 짧은 광택 줄
.ch__detent {
position: absolute; left: 26px; width: 12px; height: 2px; border-radius: 0;
background: rgba($color, .5);
}
.ch__detent--top { top: 31px; }
.ch__detent--bottom { bottom: 31px; }
// ── 유리 노브 ────────────────────────────────────────────────
.ch__knob {
position: absolute; left: 9px; top: 9px; width: 46px; height: 46px; border-radius: 50%;
background: linear-gradient(180deg, rgba($stage-paper, .95), rgba($color, .6) 45%, rgba($stage-paper, .35) 78%, rgba($subject-ink, .22));
box-shadow: 0 3px 5px rgba($stage-ink, .4), inset 0 -3px 6px rgba($stage-ink, .28), inset 0 1px 0 rgba($stage-paper, .9);
transform: translateY(0);
transition: transform $duration $easing, box-shadow $duration $easing;
}
// 좌상단 타원 스펙큘러 — 유리가 갖는 한 점의 하이라이트
.ch__knob::before {
content: ""; position: absolute; left: 16%; top: 12%; width: 44%; height: 32%;
border-radius: 50%;
background: radial-gradient(closest-side, rgba($stage-paper, .95), rgba($stage-paper, 0));
}
// 내려앉은 상태 — 그림자가 멀리 넓게 번진다(위에서 뜬 상태의 좁은 그림자와 갈린다)
.ch__input:checked ~ .ch__track .ch__knob {
transform: translateY(var(--travel));
box-shadow: 0 9px 14px rgba($stage-ink, .34), inset 0 -3px 6px rgba($stage-ink, .28), inset 0 1px 0 rgba($stage-paper, .9);
}
// ── 미리보기 루프(2초 한 바퀴) ────────────────────────────────
// 두 노브는 같은 키프레임 각도로 엇갈리게 왕복한다(왼쪽은 내려갔다, 오른쪽은 올라갔다).
.ch.is-demo .ch__knob {
animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ch.is-demo .ch__unit--a .ch__knob { animation-name: chSlideDown; }
.ch.is-demo .ch__unit--b .ch__knob { animation-name: chSlideUp; }
@keyframes chSlideDown {
0%, 8% { transform: translateY(0); }
36% { transform: translateY(calc(var(--travel) + 4px)); }
42% { transform: translateY(calc(var(--travel) - 3px)); }
46%, 60% { transform: translateY(var(--travel)); }
86% { transform: translateY(-4px); }
90% { transform: translateY(2px); }
94%, 100% { transform: translateY(0); }
}
@keyframes chSlideUp {
0%, 8% { transform: translateY(var(--travel)); }
36% { transform: translateY(-4px); }
42% { transform: translateY(3px); }
46%, 60% { transform: translateY(0); }
86% { transform: translateY(calc(var(--travel) + 4px)); }
90% { transform: translateY(calc(var(--travel) - 3px)); }
94%, 100% { transform: translateY(var(--travel)); }
}
// 숫자 강조 컷 — 도착 순간(46%)과 복귀 순간(90%)에 한 프레임으로 갈린다
.ch.is-demo .ch__num {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
.ch.is-demo .ch__unit--a .ch__num--zero { animation-name: chEmphA; }
.ch.is-demo .ch__unit--a .ch__num--one { animation-name: chEmphB; }
.ch.is-demo .ch__unit--b .ch__num--zero { animation-name: chEmphB; }
.ch.is-demo .ch__unit--b .ch__num--one { animation-name: chEmphA; }
@keyframes chEmphA {
0% { color: $subject-ink; }
46% { color: rgba($subject-ink, .72); }
90% { color: $subject-ink; }
100% { color: $subject-ink; }
}
@keyframes chEmphB {
0% { color: rgba($subject-ink, .72); }
46% { color: $subject-ink; }
90% { color: rgba($subject-ink, .72); }
100% { color: rgba($subject-ink, .72); }
}
@media (max-width: 340px) {
.ch { gap: $sp-3; }
.ch__unit { gap: $sp-1; }
.ch__scale { width: 12px; height: 120px; }
.ch__num { font-size: 12px; }
.ch__num--zero { top: 18px; }
.ch__num--one { bottom: 18px; }
.ch__track { --travel: 72px; width: 48px; height: 120px; }
.ch__detent { left: 18px; }
.ch__detent--top { top: 23px; }
.ch__detent--bottom { bottom: 23px; }
.ch__knob { left: 7px; top: 7px; width: 34px; height: 34px; }
}
@media (prefers-reduced-motion: reduce) {
.ch .ch__knob { transition: none !important; }
.ch.is-demo .ch__knob { animation: none !important; }
.ch.is-demo .ch__unit--a .ch__knob { transform: translateY(0) !important; }
.ch.is-demo .ch__unit--b .ch__knob { transform: translateY(var(--travel)) !important; }
.ch.is-demo .ch__num { animation: 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);
}
.ch {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
width: 100%;
}
.ch__unit {
position: relative;
display: flex;
gap: 8px;
}
.ch__input {
position: absolute;
inset: 0;
z-index: 10;
margin: 0;
opacity: 0;
cursor: pointer;
}
.ch__scale {
position: relative;
width: 14px;
height: 160px;
}
.ch__num {
position: absolute;
left: 0;
font-weight: 700;
font-size: 14px;
line-height: 1;
color: rgba(23, 20, 26, 0.72);
}
.ch__num--zero {
top: 25px;
color: #17141a;
}
.ch__num--one {
bottom: 25px;
}
.ch__input:checked ~ .ch__scale .ch__num--zero {
color: rgba(23, 20, 26, 0.72);
}
.ch__input:checked ~ .ch__scale .ch__num--one {
color: #17141a;
}
.ch__track {
--travel: 96px;
position: relative;
width: 64px;
height: 160px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.92), rgba(23, 20, 26, 0.8));
box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.6), inset 0 -1px 0 rgba(255, 247, 230, 0.25), 0 2px 0 rgba(255, 255, 255, 0.7);
}
.ch__input:focus-visible ~ .ch__track {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.ch__detent {
position: absolute;
left: 26px;
width: 12px;
height: 2px;
border-radius: 0;
background: rgba(255, 247, 230, 0.5);
}
.ch__detent--top {
top: 31px;
}
.ch__detent--bottom {
bottom: 31px;
}
.ch__knob {
position: absolute;
left: 9px;
top: 9px;
width: 46px;
height: 46px;
border-radius: 50%;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(255, 247, 230, 0.6) 45%, rgba(255, 255, 255, 0.35) 78%, rgba(23, 20, 26, 0.22));
box-shadow: 0 3px 5px rgba(23, 20, 26, 0.4), inset 0 -3px 6px rgba(23, 20, 26, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.9);
transform: translateY(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), box-shadow 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ch__knob::before {
content: "";
position: absolute;
left: 16%;
top: 12%;
width: 44%;
height: 32%;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 255, 255, 0.95), rgba(255, 255, 255, 0));
}
.ch__input:checked ~ .ch__track .ch__knob {
transform: translateY(var(--travel));
box-shadow: 0 9px 14px rgba(23, 20, 26, 0.34), inset 0 -3px 6px rgba(23, 20, 26, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.ch.is-demo .ch__knob {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ch.is-demo .ch__unit--a .ch__knob {
animation-name: chSlideDown;
}
.ch.is-demo .ch__unit--b .ch__knob {
animation-name: chSlideUp;
}
@keyframes chSlideDown {
0%, 8% {
transform: translateY(0);
}
36% {
transform: translateY(calc(var(--travel) + 4px));
}
42% {
transform: translateY(calc(var(--travel) - 3px));
}
46%, 60% {
transform: translateY(var(--travel));
}
86% {
transform: translateY(-4px);
}
90% {
transform: translateY(2px);
}
94%, 100% {
transform: translateY(0);
}
}
@keyframes chSlideUp {
0%, 8% {
transform: translateY(var(--travel));
}
36% {
transform: translateY(-4px);
}
42% {
transform: translateY(3px);
}
46%, 60% {
transform: translateY(0);
}
86% {
transform: translateY(calc(var(--travel) + 4px));
}
90% {
transform: translateY(calc(var(--travel) - 3px));
}
94%, 100% {
transform: translateY(var(--travel));
}
}
.ch.is-demo .ch__num {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ch.is-demo .ch__unit--a .ch__num--zero {
animation-name: chEmphA;
}
.ch.is-demo .ch__unit--a .ch__num--one {
animation-name: chEmphB;
}
.ch.is-demo .ch__unit--b .ch__num--zero {
animation-name: chEmphB;
}
.ch.is-demo .ch__unit--b .ch__num--one {
animation-name: chEmphA;
}
@keyframes chEmphA {
0% {
color: #17141a;
}
46% {
color: rgba(23, 20, 26, 0.72);
}
90% {
color: #17141a;
}
100% {
color: #17141a;
}
}
@keyframes chEmphB {
0% {
color: rgba(23, 20, 26, 0.72);
}
46% {
color: #17141a;
}
90% {
color: rgba(23, 20, 26, 0.72);
}
100% {
color: rgba(23, 20, 26, 0.72);
}
}
@media (max-width: 340px) {
.ch {
gap: 12px;
}
.ch__unit {
gap: 4px;
}
.ch__scale {
width: 12px;
height: 120px;
}
.ch__num {
font-size: 12px;
}
.ch__num--zero {
top: 18px;
}
.ch__num--one {
bottom: 18px;
}
.ch__track {
--travel: 72px;
width: 48px;
height: 120px;
}
.ch__detent {
left: 18px;
}
.ch__detent--top {
top: 23px;
}
.ch__detent--bottom {
bottom: 23px;
}
.ch__knob {
left: 7px;
top: 7px;
width: 34px;
height: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
.ch .ch__knob {
transition: none !important;
}
.ch.is-demo .ch__knob {
animation: none !important;
}
.ch.is-demo .ch__unit--a .ch__knob {
transform: translateY(0) !important;
}
.ch.is-demo .ch__unit--b .ch__knob {
transform: translateY(var(--travel)) !important;
}
.ch.is-demo .ch__num {
animation: none !important;
}
}var chRoot = document.getElementById('ch');
// 켜고 끄는 것은 체크박스 스스로 한다(role="switch" 입력은 aria-checked 를 native 로 노출한다).
// 스크립트는 손이 닿으면 미리보기 루프를 멈추는 역할만 한다.
chRoot.addEventListener('pointerdown', function () { chRoot.classList.remove('is-demo'); });// GlassTogglePair — vanilla 판(run/076/demo/02-real-glass-toggle-pair/index.html) 의 React 포트.
// 세로로 선 어두운 홈을 유리 노브가 위아래로 미끄러진다. 두 채널은 서로 반대 상태로 놓여 0 과 1 을 견준다.
// 값·키프레임은 vanilla 와 같다(다시 타이핑하지 않는다). 손이 닿으면 미리보기 루프를 뗀다.
import { useState } from "react";
import styles from "./GlassTogglePair.module.scss";
export type GlassTogglePairProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = { [k: string]: any };
const CH_A_LABEL = "채널 1";
const CH_B_LABEL = "채널 2";
export default function GlassTogglePair({
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)", // = $ease-pop
color = "#fff7e6",
}: GlassTogglePairProps) {
const [demo, setDemo] = useState(true);
const [a, setA] = useState(false);
const [b, setB] = useState(true);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
function wake() {
setDemo(false);
}
function flipA() {
setDemo(false);
setA((prev) => !prev);
}
function flipB() {
setDemo(false);
setB((prev) => !prev);
}
return (
<main className={styles.stage}>
<div className={cls} style={vars} onPointerDown={wake}>
<div className={[styles.unit, styles.unitA].join(" ")}>
<input
className={styles.input}
type="checkbox"
role="switch"
aria-label={CH_A_LABEL}
checked={a}
onChange={flipA}
/>
<div className={styles.scale} aria-hidden="true">
<span className={[styles.num, styles.numZero].join(" ")}>0</span>
<span className={[styles.num, styles.numOne].join(" ")}>1</span>
</div>
<div className={styles.track}>
<span className={[styles.detent, styles.detentTop].join(" ")} aria-hidden="true"></span>
<span className={[styles.detent, styles.detentBottom].join(" ")} aria-hidden="true"></span>
<span className={styles.knob} aria-hidden="true"></span>
</div>
</div>
<div className={[styles.unit, styles.unitB].join(" ")}>
<input
className={styles.input}
type="checkbox"
role="switch"
aria-label={CH_B_LABEL}
checked={b}
onChange={flipB}
/>
<div className={styles.scale} aria-hidden="true">
<span className={[styles.num, styles.numZero].join(" ")}>0</span>
<span className={[styles.num, styles.numOne].join(" ")}>1</span>
</div>
<div className={styles.track}>
<span className={[styles.detent, styles.detentTop].join(" ")} aria-hidden="true"></span>
<span className={[styles.detent, styles.detentBottom].join(" ")} aria-hidden="true"></span>
<span className={styles.knob} aria-hidden="true"></span>
</div>
</div>
</div>
</main>
);
}02 Vertical glass-gloss toggle pair — Measured area 10.045% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · CSS Toggle Switch: 9 Realistic Switches to Paste
The post this demo lives in
CSS Toggle Switch: 9 Realistic Switches to PasteA css toggle switch is the small on-off control visitors flip without thinking, and here are nine built with no library, See all demos in this post

