Glass that bends light rather than just blurring it. Every surface ray-traces its own Snell's-law refraction map through a curved bevel. Lenses magnify and split colour at the rim. Every touch swells, stretches and settles with a jelly wobble, tuned by eye against iOS 26 screen recordings.
npm i @zakisheriff/liquid-glassimport { useState } from 'react';
import { GlassSwitch } from '@zakisheriff/liquid-glass';
import '@zakisheriff/liquid-glass/styles.css';
export function Settings() {
const [on, setOn] = useState(true);
return <GlassSwitch checked={on} onChange={setOn} aria-label="Wi-Fi" />;
}Works with Next.js (the bundle is marked "use client"), Vite and CRA.
| Component | Import | What it does |
|---|---|---|
| Button | GlassButton |
Pill or circle. Swells, leans toward a drag, wobbles on release. |
| Toggle Switch | GlassSwitch |
The knob lifts into a clear lens that refracts the track. Tap, or drag with rubber-banding. |
| Slider | GlassSlider |
Thin track with a lens thumb, or a Control Center block. |
| Segmented Control | GlassSegmented |
The control swells and the selection becomes a draggable lens. |
| Tab Bar | GlassNav |
Floating tab bar. The selection inflates into a lens you can drag across tabs. |
| Menu | GlassMenu |
Pull-down menu that grows out of its glass button and folds back into it. |
| Search and Text Field | GlassInput |
Text or search field with icon, trailing accessory and clear button. |
| Card | GlassCard |
Refracting panel, frosted or clear. |
| Sheet | GlassSheet |
Bottom sheet. Drag or flick to dismiss; with detents, half height to full screen. |
Every example below assumes useState from React and the stylesheet imported once. Icons such as <PlusIcon /> stand for any React node, like an SVG icon component. intensity (1–10) scales refraction strength on every component.
A pill, or a circle when it has an icon and no label. It swells under the finger, leans toward a drag and wobbles as it settles.
Variants
import { GlassButton } from '@zakisheriff/liquid-glass';
<GlassButton>Glass</GlassButton>
<GlassButton variant="clear">Clear</GlassButton>
<GlassButton variant="tinted">Tinted</GlassButton>
<GlassButton variant="filled">Filled</GlassButton>
<GlassButton variant="tinted-filled" tint="#af52de">Tinted filled</GlassButton>Any colour
<GlassButton variant="tinted" tint="#c2255c">Burgundy label</GlassButton>
<GlassButton variant="filled" tint="#af52de">Purple fill</GlassButton>Destructive
<GlassButton destructive>Delete</GlassButton>
<GlassButton variant="filled" destructive>Delete</GlassButton>Icons
{/* Icon with a label */}
<GlassButton icon={<PlusIcon />}>New note</GlassButton>
{/* Icon only: becomes a circle, so give it an accessible name */}
<GlassButton icon={<PlusIcon />} aria-label="Add" />Sizes
<GlassButton size="sm">Small</GlassButton> {/* 36px */}
<GlassButton size="md">Medium</GlassButton> {/* 44px, default */}
<GlassButton size="lg">Large</GlassButton> {/* 52px */}As a link
{/* Renders an <a>: opens in a new tab with cmd/ctrl-click, shows its URL on hover */}
<GlassButton href="/docs">Documentation</GlassButton>
<GlassButton href="https://github.com/you/repo" target="_blank">GitHub</GlassButton>Handling clicks
<GlassButton variant="filled" onClick={() => save()} disabled={saving}>
Save
</GlassButton>| Prop | Type | Default | Description |
|---|---|---|---|
variant |
'glass' | 'clear' | 'tinted' | 'tinted-filled' | 'filled' |
'glass' |
Regular glass, see-through glass, glass with a coloured label, a soft wash of the colour with a matching label, or a solid colour. |
tint |
string |
accent blue |
Any CSS colour. Colours the label for tinted and the fill for filled. |
destructive |
boolean |
false |
Red label, or a red fill with filled, for delete and other irreversible actions. |
size |
'sm' | 'md' | 'lg' |
'md' |
Height: 36, 44 or 52 px. |
icon |
ReactNode |
Leading icon. With no children the button becomes a circle. | |
shimmer |
boolean |
true |
Light bloom under the finger while pressed. |
thickness |
number (1–4) |
1 |
Rim bevel scale. Wider bevels bend more of the backdrop. |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
Also accepts every <button> prop (onClick, disabled, type, aria-*, …).
An iOS toggle. On press the knob lifts into a clear lens that refracts the track. Tap it, or drag the knob with rubber-banding at the ends.
Controlled
import { useState } from 'react';
import { GlassSwitch } from '@zakisheriff/liquid-glass';
function WifiSetting() {
const [on, setOn] = useState(true);
return <GlassSwitch checked={on} onChange={setOn} aria-label="Wi-Fi" />;
}Uncontrolled
<GlassSwitch defaultChecked onChange={(on) => console.log(on)} aria-label="Bluetooth" />Custom on colour
<GlassSwitch checked={focus} onChange={setFocus} tint="#ff9f0a" aria-label="Focus" />In a settings list, with a visible label
<label className="setting">
<span>Live Activities</span>
<GlassSwitch checked={live} onChange={setLive} />
</label>Disabled
<GlassSwitch checked disabled aria-label="Managed by your organisation" />| Prop | Type | Default | Description |
|---|---|---|---|
checked |
boolean |
On or off, when you control the state. | |
defaultChecked |
boolean |
Starting state, when the switch manages its own state. | |
onChange |
(checked: boolean) => void |
Called with the new state after a tap or a drag. | |
tint |
string |
system green |
On-state colour. Any CSS colour. |
disabled |
boolean |
false |
Dims the switch and ignores input. |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
Also accepts other <button> props (aria-label, id, className, …).
The switch is a role="switch" button: Space and Enter toggle it. Give it an aria-label, or wrap it in a <label> with visible text.
A thin iOS track whose thumb lifts into a lens that magnifies the rail, or a Control Center block that fills and stretches under the finger.
Track with a lens thumb
import { GlassSlider } from '@zakisheriff/liquid-glass';
const [volume, setVolume] = useState(40);
<GlassSlider value={volume} onChange={setVolume} aria-label="Volume" />Control Center block
<GlassSlider
showThumb={false}
value={brightness}
onChange={setBrightness}
icon={<SunIcon />}
label="Display"
/>Range, steps and colour
{/* Snaps to 5 °C steps between 10 and 30 */}
<GlassSlider min={10} max={30} step={5} value={temp} onChange={setTemp} tint="#ff9f0a" aria-label="Temperature" />| Prop | Type | Default | Description |
|---|---|---|---|
value |
number |
0 |
Current value. |
onChange |
(value: number) => void |
Called while dragging, with the full-precision value. | |
min |
number |
0 |
Lowest value. |
max |
number |
100 |
Highest value. |
step |
number | 'any' |
'any' |
Continuous by default. A number snaps to fixed increments. |
showThumb |
boolean |
true |
true: thin track with a lens thumb. false: Control Center block. |
icon |
ReactNode |
Icon inside the block slider. | |
label |
string |
Label inside the block slider. Its number is rounded for display. | |
tint |
string |
accent blue (white for blocks) |
Fill colour. |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
Also accepts other <input type="range"> props (aria-label, disabled, name, …).
Arrow keys, Page Up/Down, Home and End work as on a native range input.
A row of choices. On press the whole control swells and the selection becomes a lens you can drag between segments.
Text segments
import { GlassSegmented } from '@zakisheriff/liquid-glass';
const [mode, setMode] = useState(0);
<GlassSegmented options={['Chat', 'Work', 'Study']} value={mode} onChange={setMode} aria-label="Mode" />With icons
<GlassSegmented
options={[{ label: 'Light', icon: <SunIcon /> }, { label: 'Dark', icon: <MoonIcon /> }]}
value={theme}
onChange={setTheme}
aria-label="Appearance"
/>Compact
<GlassSegmented size="sm" options={['Day', 'Week', 'Month']} value={range} onChange={setRange} />| Prop | Type | Default | Description |
|---|---|---|---|
options |
Array<string | { label, icon? }> |
Segment labels, or label and icon pairs. | |
value |
number |
Index of the selected segment. | |
onChange |
(index: number) => void |
Called with the index of the newly selected segment. | |
size |
'sm' | 'md' |
'md' |
Height: 28 or 36 px. |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
aria-label |
string |
Accessible name for the group. | |
className, style |
Applied to the control. |
Arrow keys, Home and End move the selection.
A floating iOS 26 tab bar. The selection inflates into a lens you can drag across the tabs. Icons with labels, or icons only.
Icons and Labels
import { GlassNav } from '@zakisheriff/liquid-glass';
const [tab, setTab] = useState(0);
<GlassNav
activeIndex={tab}
onChange={setTab}
aria-label="Sections"
items={[
{ label: 'Home', icon: <HomeIcon /> },
{ label: 'Saved', icon: <BookmarkIcon /> },
{ label: 'Library', icon: <GridIcon /> },
{ label: 'Search', icon: <SearchIcon /> },
]}
/>Icons Only
{/* Labels become each tab's accessible name */}
<GlassNav showLabels={false} activeIndex={tab} onChange={setTab} items={tabs} />Tabs plus a separate search button
<div style={{ display: 'flex', gap: 12 }}>
<GlassNav activeIndex={tab} onChange={setTab} items={tabs} />
<GlassButton icon={<SearchIcon />} aria-label="Search" size="lg" />
</div>| Prop | Type | Default | Description |
|---|---|---|---|
items |
Array<{ label: string; icon?: ReactNode }> |
The tabs. Icons sit above labels. | |
activeIndex |
number |
0 |
Index of the selected tab. |
onChange |
(index: number) => void |
Called when a tab is tapped, or when the lens is dragged onto one. | |
showLabels |
boolean |
true |
false shows icons only. |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
aria-label |
string |
Accessible name for the tab list. | |
className, style |
Applied to the bar. |
Arrow keys, Home and End move between tabs. Position the bar yourself, for example position: fixed; bottom: 24px.
A pull-down menu that grows out of its glass button and folds back into it with a jiggle. Slide a finger across the items to pick one.
Actions
import { GlassMenu } from '@zakisheriff/liquid-glass';
<GlassMenu
icon={<EllipsisIcon />}
aria-label="More"
items={[
{ label: 'Share', icon: <ShareIcon />, onSelect: share },
{ label: 'Duplicate', icon: <CopyIcon />, onSelect: duplicate },
{ label: 'Delete', icon: <TrashIcon />, destructive: true, onSelect: remove },
]}
/>Choices with a checkmark
<GlassMenu
icon={<SortIcon />}
aria-label="Sort by"
items={['Date', 'Name', 'Size'].map((sort) => ({
label: sort,
checked: sort === current,
onSelect: () => setCurrent(sort),
}))}
/>Setting checked (true or false) on each item makes them menuitemradio with aria-checked, so screen readers announce which one is selected.
Placement and size
{/* Grows from the left edge of a small trigger, 260px wide */}
<GlassMenu icon={<EllipsisIcon />} aria-label="More" align="start" size="sm" width={260} items={items} />| Prop | Type | Default | Description |
|---|---|---|---|
icon |
ReactNode |
Icon on the trigger button, such as an ellipsis. | |
items |
Array<{ label, icon?, checked?, destructive?, disabled?, onSelect? }> |
Menu rows. checked adds a trailing checkmark; destructive makes the label red. |
|
aria-label |
string |
Accessible name for the trigger. Required. | |
align |
'start' | 'end' |
'end' |
Edge of the trigger the menu grows from. |
size |
'sm' | 'md' | 'lg' |
'md' |
Trigger size, as for GlassButton. |
width |
number |
230 |
Menu width in px. |
check |
ReactNode |
Replaces the trailing checkmark icon. | |
className |
string |
Applied to the menu wrapper. |
Escape or a tap outside closes the menu. Arrow keys move between items.
A glass text field with a leading icon, an optional trailing accessory and a clear button.
Search
import { GlassInput } from '@zakisheriff/liquid-glass';
const [query, setQuery] = useState('');
<GlassInput
type="search"
placeholder="Search"
value={query}
onChange={(event) => setQuery(event.target.value)}
trailing={<MicIcon />}
aria-label="Search"
/>Text field with a label
<GlassInput label="Name" placeholder="Your name" value={name} onChange={(e) => setName(e.target.value)} />Custom icon and clear behaviour
<GlassInput
icon={<MailIcon />}
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onClear={() => setEmail('')}
tint="#30d158"
/>| Prop | Type | Default | Description |
|---|---|---|---|
type |
string |
'text' |
Any input type. 'search' adds a magnifier icon. |
value |
string |
Current text. | |
onChange |
(event) => void |
Standard input change event. | |
placeholder |
string |
Hint shown while empty. | |
label |
string |
Small label above the field. | |
icon |
ReactNode |
Leading icon. | |
trailing |
ReactNode |
Trailing accessory, such as a microphone. | |
onClear |
() => void |
Called by the clear button. Without it, clearing sends an empty onChange. |
|
tint |
string |
Caret and focus glow colour. | |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
Also accepts every other <input> prop (name, autoFocus, disabled, …), and ref reaches the input.
A refracting glass panel for grouping content and controls: frosted, or clear.
Regular and clear
import { GlassCard } from '@zakisheriff/liquid-glass';
<GlassCard>Frosted panel</GlassCard>
<GlassCard variant="clear">See-through panel</GlassCard>Spacing, corners and tint
<GlassCard padding="20px" radius="32px" tint="rgb(255 55 95 / 0.18)">
<h3>Now playing</h3>
<GlassSlider value={progress} onChange={seek} aria-label="Position" />
</GlassCard>| Prop | Type | Default | Description |
|---|---|---|---|
variant |
'regular' | 'clear' |
'regular' |
Frosted or see-through glass. |
padding |
string |
'24px' |
Inner padding, any CSS length. |
radius |
string |
'28px' |
Corner radius, any CSS length. |
tint |
string |
CSS colour washed over the glass. | |
thickness |
number (1–4) |
1 |
Rim bevel scale. |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
Also accepts every <div> prop (className, style, onClick, …).
A floating bottom sheet with a spring entrance. Drag or flick it down to dismiss. With detents it opens at half height and drags up to full screen.
Fits its content
import { GlassSheet, GlassButton } from '@zakisheriff/liquid-glass';
const [open, setOpen] = useState(false);
<GlassButton onClick={() => setOpen(true)}>Details</GlassButton>
<GlassSheet open={open} onClose={() => setOpen(false)} aria-label="Details">
<p>Anything goes here.</p>
</GlassSheet>Half height, then full screen
<GlassSheet open={open} onClose={() => setOpen(false)} detents={['medium', 'large']} aria-label="Comments">
{comments}
</GlassSheet>Inside a container
{/* Stays inside a positioned element (e.g. a phone mockup) without locking page scroll */}
const [frame, setFrame] = useState<HTMLDivElement | null>(null);
<div ref={setFrame} style={{ position: 'relative', height: 600, overflow: 'hidden' }}>
<GlassSheet open={open} onClose={() => setOpen(false)} container={frame}>…</GlassSheet>
</div>| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
Whether the sheet is shown. | |
onClose |
() => void |
Called when the scrim is tapped, Escape is pressed or the sheet is swiped down. | |
detents |
Array<'medium' | 'large'> |
Resting heights. ['medium', 'large'] opens at half height. Unset: fits its content. |
|
container |
HTMLElement | null |
viewport |
Positioned element to keep the sheet inside. |
tint |
string |
CSS colour washed over the glass. | |
aria-label |
string |
Accessible name for the dialog. | |
intensity |
number (1–10) |
5 |
Refraction strength, from subtle to heavy. |
About 31 KB of JavaScript and 5.5 KB of CSS gzipped, with no runtime dependencies (React and React DOM are peers).
- Refraction maps are computed once per glass size, cached and shared, then drawn by the browser. Nothing is recalculated while you scroll.
- Springs run on one shared animation frame loop and stop when they settle.
- Lens copies only copy and lay out content while a control is pressed, and stop entirely off screen.
- Refraction pauses while the page scrolls and for glass off screen; blur and the rim carry the look in motion.
- On slower devices resting surfaces keep blur and rim only, and lenses still bend while held. The quality is picked from the device and the first scrolls;
setGlassQuality('high' | 'low' | 'auto')pins it. - The demo page, with 99 glass elements, holds about 11 MB of JavaScript memory and renders at 60 fps in Chrome.
The GPU cost is in what moves under the glass. Keep large refracting panels over static or slow content, prefer a few large glass surfaces over many small overlapping ones, and lower intensity (or use GlassCard variant="clear") for low-end devices.
Tokens follow prefers-color-scheme. To force a theme, set data-theme="light" or data-theme="dark" on any ancestor. Override any --lg-* variable to restyle, e.g. --lg-accent, --lg-fill or --lg-blur.
import { useRefraction, usePress } from '@zakisheriff/liquid-glass';
function Droplet() {
const ref = useRef<HTMLDivElement>(null);
useRefraction(ref, { kind: 'lens', zoom: 1.3, dispersion: 1 });
usePress(ref);
return <div ref={ref} className="lg-glass" style={{ width: 240, height: 160, borderRadius: 999 }} />;
}Lenses take stretch: 1 for the iOS rim, where light under the edge smears out to it. createSpring, SPRINGS, haptic() and setGlassQuality() are exported too.
- Chrome / Edge / Arc: live refraction of anything behind the glass through
backdrop-filter: url(#svg). Tab and segmented lenses refract a tinted live copy of the bar, so everything inside the lens takes the active colour. - Safari: lenses (switch, slider, tab bar, segmented) refract a live copy of the content beneath them, bending and stretching it into the rim. Bars, buttons and cards over arbitrary page content get frosting, rims and motion.
- Firefox: uses the same path as Safari; not yet tested.
- Respects
prefers-reduced-motionandprefers-reduced-transparency.
npm install
npm run site # Next.js landing page at localhost:3000, renders straight from src/
npm run build # library → dist/MIT © Zaki Sheriff. Not affiliated with Apple Inc.
