Stretchy Toggle
The knob stretches into a pill as it travels, then settles round on the other side while the track fills with colour.
Make an accessible React toggle switch in CSS. Use a real <button role="switch" aria-checked> with a visible text label "Email notifications" next to it. The track is 48×28px; the 22px knob sits at translateX(3px) when off and translateX(23px) when on, moving over 260ms with cubic-bezier(0.16, 1, 0.3, 1). While pressed (:active) the knob widens by scaling scaleX(1.25) from the side it is leaving, so it looks like it stretches. The track fades from a neutral surface to the accent colour over 200ms. Show a visible focus ring on :focus-visible. Clicking the label also toggles it. Under prefers-reduced-motion, move the knob instantly and skip the stretch.
Source
'use client'
import { useId, useState } from 'react'
import s from './demo.module.css'
export default function ToggleSwitch({ labels }: { labels: Record<string, string> }) {
const [on, setOn] = useState(true)
const id = useId()
return (
<div className={s.row}>
<label htmlFor={id} className={s.label}>
{labels.label}
</label>
<button id={id} type="button" role="switch" aria-checked={on} className={s.track} onClick={() => setOn(!on)}>
<span className={s.knob} />
</button>
</div>
)
}
.row {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 18px;
border: 1px solid var(--stage-rule);
border-radius: 14px;
background: var(--stage-surface);
}
.label { color: var(--stage-ink); font: 500 var(--text-base) / 1.2 var(--font-body); white-space: nowrap; cursor: pointer; }
.track {
position: relative;
flex: none;
width: 48px;
height: 28px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--stage-rule);
cursor: pointer;
transition: background-color 200ms var(--ease-out);
}
.track[aria-checked='true'] { background: var(--stage-accent); }
.track:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.knob {
position: absolute;
top: 3px;
left: 0;
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--stage-ink);
transform: translateX(3px);
transform-origin: left;
transition: transform 260ms var(--ease-out);
}
.track[aria-checked='true'] .knob { transform: translateX(23px); transform-origin: right; }
.track:active .knob { transform: translateX(3px) scaleX(1.25); }
.track[aria-checked='true']:active .knob { transform: translateX(23px) scaleX(1.25); }
@media (prefers-reduced-motion: reduce) {
.knob, .track { transition: none; }
.track:active .knob { transform: translateX(3px); }
.track[aria-checked='true']:active .knob { transform: translateX(23px); }
}