Glass preferences
The viewer's Liquid Glass setting: a Clarity slider (clear ↔ tinted) and a Reduce transparency switch that write --glass-clarity for the whole page, persisted, with a head script that applies it before paint.
Installation
pnpm dlx shadcn@latest add https://glasscn.app/r/glass-preferences.jsonOr with the namespace set up: shadcn add @glasscn-app/glass-preferences
Props
| Prop | Type | Default |
|---|---|---|
| className | stringThe panel is a plain grid — put it in a Popover, a Sheet or a settings Card. | — |
| useGlassPreferences() | { preferences, customised, setPreferences, reset }The same state without the UI: preferences is { clarity: 0–1, reduceTransparency }; setPreferences takes a partial; reset() returns to the theme. | — |
| <GlassPreferencesScript /> | from glass-preferences-scriptRender once in the root layout's <head> so a reload paints with the viewer's clarity (no flash). | — |
Clarity is iOS 27's Liquid Glass slider for your app: 0 ultra clear, 0.5 the theme as designed, 1 fully tinted. It scales every frost — material and liquid — at once through --glass-clarity.
It writes one style, :where(:root, [data-glass-scope]) { --glass-clarity: … !important }, so it reaches every ThemeScope and beats theme values, while the OS settings (prefers-reduced-transparency, prefers-contrast) still win. Nothing is written until the viewer changes something.
Reduce transparency floors clarity at 0.9 — the same frosts the prefers-reduced-transparency media query gives — and sets data-glass-reduce-transparency on <html> for your own styles. It matters most in Safari, which has no such media query.
Persisted in localStorage under glass-preferences; every instance and every open tab stays in step. Add suppressHydrationWarning to <html> (next-themes asks for it too).
Web only: on iOS 26+ the system's own Liquid Glass clarity and Reduce Transparency already apply to native glass.
Source
"use client"
import * as React from "react"
import { cn } from "cn"
import { Label } from "@/components/glass/label"
import { Slider } from "@/components/glass/slider"
import { Switch } from "@/components/glass/switch"
import {
DEFAULT_GLASS_PREFERENCES,
GLASS_PREFERENCES_EVENT,
GLASS_PREFERENCES_KEY,
applyGlassPreferences,
effectiveClarity,
parseGlassPreferences,
type GlassPreferencesValue,
} from "@/components/glass/glass-preferences-script"
const subscribe = (onChange: () => void) => {
window.addEventListener("storage", onChange)
window.addEventListener(GLASS_PREFERENCES_EVENT, onChange)
return () => {
window.removeEventListener("storage", onChange)
window.removeEventListener(GLASS_PREFERENCES_EVENT, onChange)
}
}
const read = () => {
try {
return localStorage.getItem(GLASS_PREFERENCES_KEY)
} catch {
return null
}
}
/**
* The viewer's glass preferences, persisted in localStorage and shared by every instance
* (and tab). `customised` is false until the viewer changes something — until then the
* theme's own clarity applies. `reset()` returns to it.
*/
function useGlassPreferences() {
const raw = React.useSyncExternalStore(subscribe, read, () => null)
const stored = React.useMemo(() => parseGlassPreferences(raw), [raw])
// the head script applies before paint; this covers apps without it, and other tabs
React.useEffect(() => applyGlassPreferences(stored), [stored])
const setPreferences = React.useCallback((next: Partial<GlassPreferencesValue> | null) => {
const value = next ? { ...DEFAULT_GLASS_PREFERENCES, ...parseGlassPreferences(read()), ...next } : null
try {
if (value) localStorage.setItem(GLASS_PREFERENCES_KEY, JSON.stringify(value))
else localStorage.removeItem(GLASS_PREFERENCES_KEY)
} catch {}
applyGlassPreferences(value)
window.dispatchEvent(new Event(GLASS_PREFERENCES_EVENT))
}, [])
const reset = React.useCallback(() => setPreferences(null), [setPreferences])
return { preferences: stored ?? DEFAULT_GLASS_PREFERENCES, customised: stored !== null, setPreferences, reset }
}
/**
* Clarity (Clear ↔ Tinted) and Reduce transparency for whoever is looking — iOS 27's Liquid
* Glass setting for your app. Writes `--glass-clarity` for the whole page, persisted. Put it
* in a popover, a settings page or a sheet; add <GlassPreferencesScript /> to <head> so a
* reload paints with it.
*/
function GlassPreferences({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
const { preferences, customised, setPreferences, reset } = useGlassPreferences()
const clarity = effectiveClarity(preferences)
return (
<div data-slot="glass-preferences" data-customised={customised || undefined} className={cn("grid gap-5", className)} {...props}>
<div data-slot="glass-preferences-clarity" className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<Label asChild>
<span>Clarity</span>
</Label>
<span className="numeric-glass text-xs text-muted-foreground">{Math.round(clarity * 100)}%</span>
</div>
<Slider
value={[clarity]}
min={0}
max={1}
step={0.01}
disabled={preferences.reduceTransparency}
aria-label="Clarity"
onValueChange={([v]) => setPreferences({ clarity: v })}
/>
<div className="flex justify-between text-xs text-muted-foreground" aria-hidden>
<span>Clear</span>
<span>Tinted</span>
</div>
</div>
<div data-slot="glass-preferences-reduce-transparency" className="flex items-center justify-between gap-3">
<div className="grid gap-1">
<Label htmlFor={`${id}-reduce`}>Reduce transparency</Label>
<p id={`${id}-reduce-hint`} className="text-xs text-muted-foreground">
Frost every pane almost solid.
</p>
</div>
<Switch
id={`${id}-reduce`}
aria-describedby={`${id}-reduce-hint`}
checked={preferences.reduceTransparency}
onCheckedChange={(on) => setPreferences({ reduceTransparency: on })}
/>
</div>
{customised ? (
<button
type="button"
data-slot="glass-preferences-reset"
onClick={reset}
className="hit-area glass-focus relative justify-self-start rounded-badge text-xs text-muted-foreground underline-offset-4 transition-colors duration-(--glass-duration) ease-glass hover:text-foreground hover:underline"
>
Reset to the theme
</button>
) : null}
</div>
)
}
export { GlassPreferences, useGlassPreferences }
/**
* The viewer's glass preferences — clarity and reduce transparency — outside React:
* storage, the CSS they become, and an inline script that applies them before first
* paint. Server-safe (no "use client"), so a root layout can render
* <GlassPreferencesScript /> in <head>. The controls live in glass-preferences.tsx.
*/
export interface GlassPreferencesValue {
/** 0 ultra clear → 1 fully tinted, like iOS 27's Liquid Glass slider. 0.5 is the theme as designed. */
clarity: number
/** Frost every pane almost opaque (clarity ≥ 0.9), as the OS setting does. */
reduceTransparency: boolean
}
export const GLASS_PREFERENCES_KEY = "glass-preferences"
/** Fired on window after a change in this tab (other tabs hear the storage event). */
export const GLASS_PREFERENCES_EVENT = "glass-preferences"
const STYLE_ID = "glass-preferences"
const ATTR = "data-glass-reduce-transparency"
export const DEFAULT_GLASS_PREFERENCES: GlassPreferencesValue = { clarity: 0.5, reduceTransparency: false }
/** The clarity reduce transparency floors to: maps the default frosts to ≈ the prefers-reduced-transparency values. */
export const REDUCED_TRANSPARENCY_CLARITY = 0.9
const clamp = (n: number) => Math.round(Math.min(1, Math.max(0, n)) * 100) / 100
/** A stored value back into preferences; null when missing or malformed. */
export function parseGlassPreferences(raw: string | null): GlassPreferencesValue | null {
if (!raw) return null
try {
const v = JSON.parse(raw) as Partial<GlassPreferencesValue>
const clarity = Number(v.clarity)
if (!Number.isFinite(clarity)) return null
return { clarity: clamp(clarity), reduceTransparency: v.reduceTransparency === true }
} catch {
return null
}
}
/** The clarity that reaches the panes. */
export const effectiveClarity = (p: GlassPreferencesValue) => (p.reduceTransparency ? Math.max(REDUCED_TRANSPARENCY_CLARITY, clamp(p.clarity)) : clamp(p.clarity))
/**
* `!important` beats themes, ThemeScopes and site themes (a viewer preference outranks the author's
* default); `:where()` keeps specificity at zero so the OS accessibility overrides — also !important,
* on `:root, [data-glass-scope]` — still win.
*/
export const glassPreferencesCss = (p: GlassPreferencesValue) => `:where(:root,[data-glass-scope]){--glass-clarity:${effectiveClarity(p)}!important}`
/** Writes (or, with null, removes) the preference style and the reduce-transparency attribute. */
export function applyGlassPreferences(p: GlassPreferencesValue | null, doc: Document = document) {
const root = doc.documentElement
let el = doc.getElementById(STYLE_ID) as HTMLStyleElement | null
if (!p) {
el?.remove()
root.removeAttribute(ATTR)
return
}
if (!el) {
el = doc.createElement("style")
el.id = STYLE_ID
doc.head.appendChild(el)
}
const css = glassPreferencesCss(p)
if (el.textContent !== css) el.textContent = css
root.toggleAttribute(ATTR, p.reduceTransparency)
}
/** The same as applyGlassPreferences(stored), as an inline script for <head> — no flash of the default clarity. */
export const glassPreferencesScript = `try{var p=JSON.parse(localStorage.getItem(${JSON.stringify(GLASS_PREFERENCES_KEY)})||"null"),c=p&&Number(p.clarity);if(p&&isFinite(c)){c=Math.round(Math.min(1,Math.max(0,c))*100)/100;if(p.reduceTransparency===true){c=Math.max(${REDUCED_TRANSPARENCY_CLARITY},c);document.documentElement.setAttribute("${ATTR}","")}var s=document.createElement("style");s.id="${STYLE_ID}";s.textContent=":where(:root,[data-glass-scope]){--glass-clarity:"+c+"!important}";document.head.appendChild(s)}}catch(e){}`
/** Put once in the root layout's <head> (with suppressHydrationWarning on <html>, as next-themes asks). */
function GlassPreferencesScript() {
return <script data-slot="glass-preferences-script" dangerouslySetInnerHTML={{ __html: glassPreferencesScript }} />
}
export { GlassPreferencesScript }