Liqui Design

Switch

An opaque white pill at rest that swells out of its track and turns to glass under your finger.

Loading…
Press and hold a switch, or drag the thumb across. It swells past the track, its white fill drops to a tenth, and the refraction more than doubles — so the lens deepens as it is uncovered.

Installation

npx shadcn@latest add https://liqui.design/r/switch.json

Installing more than one? Register the namespace once in components.json and drop the URLs:

components.json
{
  "registries": {
    "@liqui-design": "https://liqui.design/r/{name}.json"
  }
}
npx shadcn@latest add @liqui-design/switch

Usage

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 surfaceSwitch
BezelConvex — magnifies its middleLip — a band at the rim, flat middle
RefractionA fixed propAnimated every frame, 0.4× → 0.9×
SpecularA white rim, composited in the DOMThe 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 top

A 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
Travelk 1000, c 801.27Thumb position
Pressk 2000, c 800.89Refraction, scale, fill, shadow
Tintk 1000, c 801.27Track 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.

On this page