Switch
An opaque white pill at rest that swells out of its track and turns to glass under your finger.
Installation
npx shadcn@latest add https://liqui.design/r/switch.jsonInstalling more than one? Register the namespace once in components.json and
drop the URLs:
{
"registries": {
"@liqui-design": "https://liqui.design/r/{name}.json"
}
}npx shadcn@latest add @liqui-design/switchUsage
import { Switch, SwitchLabel } from '@/components/ui/switch';<SwitchLabel>
Wi-Fi
<Switch checked={wifi} onCheckedChange={setWifi} />
</SwitchLabel>SwitchLabel is a plain <label> row with justify-between, so the text sits
on the left and the switch is pushed to the right — the settings-list shape.
Like CheckboxLabel, it dims itself through has-[[data-disabled]] rather than
being told about the switch's state.
Notes
This one is not a glass surface
Every other component here is a LiquiGlass box: one
shared displacement map, one shared filter, one set of dials that has to suit a
drawer and a scrollbar thumb at the same time. This is not built out of that
and does not import it. Only the --lq-* tokens are shared, through CSS, so it
still themes with everything else.
It needs three things the shared surface does not have and should not grow:
| Shared surface | Switch | |
|---|---|---|
| Bezel | Convex — magnifies its middle | Lip — a band at the rim, flat middle |
| Refraction | A fixed prop | Animated every frame, 0.4× → 0.9× |
| Specular | A white rim, composited in the DOM | The backdrop at 6× saturation, masked to a hairline |
Only the band refracts
The thumb's surface is convex at the very rim, eases into a shallow concave bowl, and then goes flat. The band is 41% of the thumb's radius; the middle 59% is a genuinely neutral window that moves nothing at all.
That flat middle is the point. The two halves of the band refract in opposite directions — the crest pulls the backdrop inward the way any lens edge does, the bowl pushes it back out — and what makes that read as glass rather than as smear is having an undistorted window to see it against. A lens that distorts everywhere just looks broken.
The outermost ring of pixels samples from nearly a third of the way across the thumb, which is why the track's edge appears to wrap around the glass instead of merely blurring past it.
The filter declares no region
<filter id={filterId} colorInterpolationFilters="sRGB">No x, no width, no filterUnits. The SVG default is the bounding box plus
10%, and the bowl needs that margin: it samples outward, past the thumb's own
edge. Pin the region to the element and Chromium clamps those samples at the
boundary, turning the bowl into a smeared ring.
A hairline, not an arc
The specular is one axis of light falling off as cos² around the outward
normal, laid on a rim about a pixel wide just inside the edge.
It is tempting to model it as a lit surface instead, and it is wrong. A flat patch of glass faces the viewer, and a viewer-facing normal still scores well against a light tilted toward the camera — so the whole middle of the thumb picks up a fifth of full highlight, the shallow bowl adds a broad lobe on top, and once that becomes the mask for the saturation pass below, the lens is delivered wearing a vivid film.
The rim is coloured, and that is the point
The specular is composited inside the filter, not stacked as a DOM layer, so it can be masked over something other than white:
bend → saturate 6× → keep only what the rim mask covers → blend over the bend
→ white light on topA white rim reads as plastic. A rim that is the backdrop's own colour pushed until it sings reads as a wet edge.
The thumb is drawn at 0.65
Its layout box is half again as big as it ever looks. That is not a trick to
avoid a re-layout — it is why the map has resolution left when the thumb swells
to 0.9, and why the refraction grows with it: backdrop-filter is applied in
the element's own coordinates and scales with everything else.
At 0.9 the thumb is taller than its track, and breaks out of it top and
bottom. Nothing clips it — the root sets no overflow and no isolation,
because the thumb's backdrop has to reach the page rather than stop at the
track.
The white fill is the element's own background, which paints above the backdrop. That one value is the whole reveal: opaque and the thumb is a white pill, a tenth and it is a lens.
Drag
The thumb follows your finger with no spring in between — a spring there reads as lag rather than life — and runs past either end on a rubber band, so the track has a floor and a ceiling you can feel.
A drag and a tap both end in a click, and Base UI toggles on click. A drag
that lands on the far side wants exactly that toggle, so it is let through; a
drag that returns to the side it started on wants nothing to happen, so the
click is swallowed on the way up. The state is never written twice, and a
controlled switch still gets to refuse the change.
Motion
Three springs, integrated per frame and written straight to the DOM:
| Spring (mass 1) | ζ | Drives | |
|---|---|---|---|
| Travel | k 1000, c 80 | 1.27 | Thumb position |
| Press | k 2000, c 80 | 0.89 | Refraction, scale, fill, shadow |
| Tint | k 1000, c 80 | 1.27 | Track colour |
The track colour gets its own spring because it is not the thumb's position: it flips the moment a drag crosses the middle and then travels on its own, rather than fading through a half-tinted track on the way across.
CSS could not do any of it. Travel has to be interruptible mid-flight by a
drag, and press drives an SVG attribute — feDisplacementMap scale — that CSS
cannot animate at all. prefers-reduced-motion snaps all three.
Turning the lens off
<Switch lens={false} />There is one place refraction is not worth its cost: a switch sitting on
another glass surface — inside a popover, a drawer, a media-player panel.
backdrop-filter samples what is painted behind the element, and behind a
switch on a frosted panel is the panel's own tint: a flat wash with nothing in
it to bend.
Safari and Firefox take this path on their own — backdrop-filter: url(#…) is
Chromium-only. Either way the thumb stays a white pill with a cast shadow, and
the press keeps its swell but gives up the reveal.
Sizing
const TRACK_H = 30;That is the only number to change. Everything else — track width, thumb box, radius, travel, band width, peak displacement — is a ratio off it, and the maps regenerate to match.
The ratios are not invented, and they are not iOS's. The track is 2.39× as long as it is tall and the thumb is a capsule far wider than the track is tall, because that is what gives the lens enough glass to be worth looking through — which is why 30 buys a 72px track where the old flat switch was 52px wide.
30 puts the resting thumb at 27px: what Slider's knob
comes out at, and what this component was before it had a lens. The two should
read as the same size object. Their tracks differ because a switch contains its
thumb and a rail does not.
Two colours are exposed: --lq-switch-off for the track, --lq-accent for the
checked fill.