Liquid Glass for the web: the rim refraction, light and motion of iOS 27 glass on any rounded element. React bindings and pill tabs. MIT.
| Card, |
| 6 | cn, |
| 7 | Label, |
| 8 | SegmentedControl, |
| 9 | Slider, |
| 10 | Switch, |
| 11 | Tabs, |
| 12 | TabsContent, |
| 13 | TabsList, |
| 14 | TabsTrigger, |
| 15 | } from "@colineapp/ui"; |
| 16 | import { LiquidGlass, type LiquidGlassHandle } from "@colineapp/liquid-glass/react"; |
| 17 | import { CodeBlock } from "./code"; |
| 18 | import { reactSnippet, vanillaSnippet, type PlaygroundOptions } from "./snippet"; |
| 19 | import { BACKDROPS, Stage, useDraggable, type BackdropKind } from "./stage"; |
| 20 |
| 21 | type Shape = "capsule" | "card" | "circle"; |
| 22 | type Floating = "off" | "menu" | "dialog"; |
| 23 |
| 24 | const SHAPES: Record<Shape, { className: string }> = { |
| 25 | capsule: { className: "h-[72px] w-[260px] rounded-full" }, |
| 26 | card: { className: "h-[180px] w-[280px] rounded-[32px]" }, |
| 27 | circle: { className: "size-[164px] rounded-full" }, |
| 28 | }; |
| 29 |
| 30 | const HUES = [ |
| 31 | { value: "none", color: null, label: "None" }, |
| 32 | { value: "blue", color: "#3d6bff", label: "Blue" }, |
| 33 | { value: "violet", color: "#8b5cf6", label: "Violet" }, |
| 34 | { value: "pink", color: "#ff4fa3", label: "Pink" }, |
| 35 | { value: "green", color: "#2fb36d", label: "Green" }, |
| 36 | { value: "amber", color: "#f0a020", label: "Amber" }, |
| 37 | ] as const; |
| 38 | type Hue = (typeof HUES)[number]["value"]; |
| 39 |
| 40 | const DEFAULTS = { |
| 41 | backdrop: "landscape" as BackdropKind, |
| 42 | shape: "capsule" as Shape, |
| 43 | variant: "regular" as "regular" | "clear", |
| 44 | floating: "off" as Floating, |
| 45 | interactive: true, |
| 46 | swell: true, |
| 47 | refraction: true, |
| 48 | samples: false, |
| 49 | maxFall: 0.8, |
| 50 | bezel: 17, |
| 51 | thickness: 24, |
| 52 | edge: 0.75, |
| 53 | glint: 0.4, |
| 54 | hue: "none" as Hue, |
| 55 | hueStrength: 0.5, |
| 56 | magnify: 1, |
| 57 | }; |
| 58 | type State = typeof DEFAULTS; |
| 59 |
| 60 | function Control({ label, value, children }: { label: string; value?: string; children: React.ReactNode }) { |
| 61 | return ( |
| 62 | <div className="grid gap-2"> |
| 63 | <div className="flex items-baseline justify-between gap-2"> |
| 64 | <Label>{label}</Label> |
| 65 | {value !== undefined ? <span className="text-xs text-muted-foreground tabular-nums">{value}</span> : null} |
| 66 | </div> |
| 67 | {children} |
| 68 | </div> |
| 69 | ); |
| 70 | } |
| 71 |
| 72 | function Range({ |
| 73 | label, |
| 74 | value, |
| 75 | min, |
| 76 | max, |
| 77 | step, |
| 78 | format = (n) => String(n), |
| 79 | onChange, |
| 80 | }: { |
| 81 | label: string; |
| 82 | value: number; |
| 83 | min: number; |
| 84 | max: number; |
| 85 | step: number; |
| 86 | format?: (value: number) => string; |
| 87 | onChange: (value: number) => void; |
| 88 | }) { |
| 89 | return ( |
| 90 | <Control label={label} value={format(value)}> |
| 91 | <Slider aria-label={label} value={[value]} min={min} max={max} step={step} onValueChange={([next]) => next !== undefined && onChange(next)} /> |
| 92 | </Control> |
| 93 | ); |
| 94 | } |
| 95 |
| 96 | function Toggle({ label, description, checked, onChange }: { label: string; description: string; checked: boolean; onChange: (checked: boolean) => void }) { |
| 97 | const id = React.useId(); |
| 98 | return ( |
| 99 | <div className="flex items-start justify-between gap-4"> |
| 100 | <div className="grid gap-0.5"> |
| 101 | <Label htmlFor={id}>{label}</Label> |
| 102 | <p className="text-xs text-muted-foreground">{description}</p> |
| 103 | </div> |
| 104 | <Switch id={id} checked={checked} onCheckedChange={onChange} size="sm" /> |
| 105 | </div> |
| 106 | ); |
| 107 | } |
| 108 |
| 109 | function Sample({ state, glass }: { state: State; glass: React.RefObject<LiquidGlassHandle | null> }) { |
| 110 | const drag = useDraggable({ initial: { x: 0.5, y: 0.42 } }); |
| 111 | const hue = HUES.find((item) => item.value === state.hue)?.color ?? null; |
| 112 | // Geometry rebuilds the rim, so it follows the sliders a beat behind. |
| 113 | const geometry = React.useDeferredValue( |
| 114 | React.useMemo( |
| 115 | () => ({ maxFall: state.maxFall, bezel: state.bezel, thickness: state.thickness, edge: state.edge, glint: state.glint }), |
| 116 | [state.maxFall, state.bezel, state.thickness, state.edge, state.glint], |
| 117 | ), |
| 118 | ); |
| 119 | return ( |
| 120 | <div |
| 121 | ref={drag.ref} |
| 122 | style={drag.style} |
| 123 | onPointerDown={drag.onPointerDown} |
| 124 | onPointerMove={drag.onPointerMove} |
| 125 | onPointerUp={drag.onPointerUp} |
| 126 | onPointerCancel={drag.onPointerCancel} |
| 127 | className={cn( |
| 128 | "absolute flex cursor-grab items-center justify-center text-foreground select-none", |
| 129 | drag.dragging && "cursor-grabbing", |
| 130 | SHAPES[state.shape].className, |
| 131 | )} |
| 132 | > |
| 133 | <LiquidGlass |
| 134 | glassRef={glass} |
| 135 | appearance="class" |
| 136 | variant={state.variant} |
| 137 | floating={state.floating === "off" ? false : state.floating === "dialog" ? "dialog" : true} |
| 138 | interactive={state.interactive} |
| 139 | swell={state.swell} |
| 140 | refraction={state.refraction} |
| 141 | samples={state.samples ? 4 : 1} |
| 142 | hue={hue} |
| 143 | hueStrength={state.hueStrength} |
| 144 | geometry={geometry} |
| 145 | /> |
| 146 | {state.shape === "capsule" ? ( |
| 147 | <span className="flex items-center gap-2 text-[15px] font-medium"> |
| 148 | <IconArrowsMove className="size-4 opacity-60" /> |
| 149 | Drag me around |
| 150 | </span> |
| 151 | ) : null} |
| 152 | {state.shape === "card" ? ( |
| 153 | <div className="grid w-full gap-1 self-start p-6"> |
| 154 | <span className="text-lg font-semibold tracking-tight">Liquid Glass</span> |
| 155 | <span className="text-sm text-foreground/70">Drag it over the picture and watch the rim bend what's behind it.</span> |
| 156 | </div> |
| 157 | ) : null} |
| 158 | </div> |
| 159 | ); |
| 160 | } |
| 161 |
| 162 | export function Playground() { |
| 163 | const [state, setState] = React.useState<State>(DEFAULTS); |
| 164 | const set = <Key extends keyof State>(key: Key) => (value: State[Key]) => setState((current) => ({ ...current, [key]: value })); |
| 165 | const glass = React.useRef<LiquidGlassHandle | null>(null); |
| 166 |
| 167 | React.useEffect(() => { |
| 168 | glass.current?.magnify(state.magnify); |
| 169 | }, [state.magnify, state.refraction, state.samples]); |
| 170 |
| 171 | const hue = HUES.find((item) => item.value === state.hue)?.color ?? null; |
| 172 | const options: PlaygroundOptions = { |
| 173 | shape: state.shape, |
| 174 | variant: state.variant, |
| 175 | floating: state.floating === "off" ? false : state.floating === "dialog" ? "dialog" : true, |
| 176 | interactive: state.interactive, |
| 177 | swell: state.swell, |
| 178 | refraction: state.refraction, |
| 179 | samples: state.samples ? 4 : 1, |
| 180 | hue, |
| 181 | hueStrength: hue ? state.hueStrength : undefined, |
| 182 | geometry: { maxFall: state.maxFall, bezel: state.bezel, thickness: state.thickness, edge: state.edge, glint: state.glint }, |
| 183 | }; |
| 184 | const magnifyLine = state.magnify !== 1 ? `\n\n// With glassRef={glass}:\nglass.current?.magnify(${state.magnify.toFixed(2)});` : ""; |
| 185 |
| 186 | return ( |
| 187 | <div className="grid gap-4"> |
| 188 | <Card> |
| 189 | <div className="grid lg:grid-cols-[minmax(0,1fr)_340px]"> |
| 190 | <div className="grid min-w-0 content-start gap-3 px-4 lg:pr-5"> |
| 191 | <div className="flex flex-wrap items-center justify-between gap-2"> |
| 192 | <SegmentedControl label="Backdrop" size="sm" value={state.backdrop} onValueChange={set("backdrop")} options={BACKDROPS} /> |
| 193 | <Button variant="ghost" size="sm" onClick={() => void glass.current?.materialize({ duration: 600 })}> |
| 194 | Replay materialize |
| 195 | </Button> |
| 196 | </div> |
| 197 | <Stage backdrop={state.backdrop} className="h-[420px] sm:h-[480px] lg:h-[600px]"> |
| 198 | <Sample state={state} glass={glass} /> |
| 199 | </Stage> |
| 200 | </div> |
| 201 | <div className="mx-4 mt-5 grid content-start gap-5 border-t border-border pt-5 lg:mx-0 lg:mt-0 lg:max-h-[648px] lg:overflow-y-auto lg:border-t-0 lg:border-l lg:pt-0 lg:pr-4 lg:pl-5"> |
| 202 | <Control label="Shape"> |
| 203 | <SegmentedControl |
| 204 | label="Shape" |
| 205 | size="sm" |
| 206 | value={state.shape} |
| 207 | onValueChange={set("shape")} |
| 208 | options={[ |
| 209 | { value: "capsule", label: "Capsule" }, |
| 210 | { value: "card", label: "Card" }, |
| 211 | { value: "circle", label: "Circle" }, |
| 212 | ]} |
| 213 | /> |
| 214 | </Control> |
| 215 | <Control label="Material"> |
| 216 | <SegmentedControl |
| 217 | label="Material" |
| 218 | size="sm" |
| 219 | value={state.variant} |
| 220 | onValueChange={set("variant")} |
| 221 | options={[ |
| 222 | { value: "regular", label: "Regular" }, |
| 223 | { value: "clear", label: "Clear" }, |
| 224 | ]} |
| 225 | /> |
| 226 | </Control> |
| 227 | <Control label="Floating"> |
| 228 | <SegmentedControl |
| 229 | label="Floating" |
| 230 | size="sm" |
| 231 | value={state.floating} |
| 232 | onValueChange={set("floating")} |
| 233 | options={[ |
| 234 | { value: "off", label: "Off" }, |
| 235 | { value: "menu", label: "Menu" }, |
| 236 | { value: "dialog", label: "Dialog" }, |
| 237 | ]} |
| 238 | /> |
| 239 | </Control> |
| 240 | <Control label="Tint"> |
| 241 | <SegmentedControl |
| 242 | label="Tint" |
| 243 | size="sm" |
| 244 | value={state.hue} |
| 245 | onValueChange={set("hue")} |
| 246 | options={HUES.map((item) => ({ |
| 247 | value: item.value, |
| 248 | label: item.color ? ( |
| 249 | <span aria-label={item.label} title={item.label} className="block size-3.5 rounded-full" style={{ background: item.color }} /> |
| 250 | ) : ( |
| 251 | "None" |
| 252 | ), |
| 253 | }))} |
| 254 | /> |
| 255 | </Control> |
| 256 | {hue ? <Range label="Tint strength" value={state.hueStrength} min={0.1} max={1} step={0.05} format={(n) => n.toFixed(2)} onChange={set("hueStrength")} /> : null} |
| 257 | <div className="grid gap-4"> |
| 258 | <Toggle label="Interactive" description="The pointer lights the glass; a press blooms." checked={state.interactive} onChange={set("interactive")} /> |
| 259 | <Toggle label="Swell" description="Pressed, it swells and springs back." checked={state.swell} onChange={set("swell")} /> |
| 260 | <Toggle label="Refraction" description="Bend the page at the rim (Chromium)." checked={state.refraction} onChange={set("refraction")} /> |
| 261 | <Toggle label="4× samples" description="Antialias the bend while it moves." checked={state.samples} onChange={set("samples")} /> |
| 262 | </div> |
| 263 | <Range label="Rim bend" value={state.maxFall} min={0.05} max={0.95} step={0.05} format={(n) => n.toFixed(2)} onChange={set("maxFall")} /> |
| 264 | <Range label="Rim width" value={state.bezel} min={4} max={40} step={1} format={(n) => `${n}px`} onChange={set("bezel")} /> |
| 265 | <Range label="Depth" value={state.thickness} min={4} max={64} step={1} format={(n) => `${n}px`} onChange={set("thickness")} /> |
| 266 | <Range label="Dark edge" value={state.edge} min={0} max={2} step={0.05} format={(n) => `${n.toFixed(2)}px`} onChange={set("edge")} /> |
| 267 | <Range label="Glints" value={state.glint} min={0} max={1} step={0.05} format={(n) => n.toFixed(2)} onChange={set("glint")} /> |
| 268 | <Range label="Magnify" value={state.magnify} min={1} max={1.4} step={0.01} format={(n) => `${n.toFixed(2)}×`} onChange={set("magnify")} /> |
| 269 | <Button variant="outline" size="sm" onClick={() => setState(DEFAULTS)}> |
| 270 | <IconRefresh className="size-4" /> |
| 271 | Reset |
| 272 | </Button> |
| 273 | </div> |
| 274 | </div> |
| 275 | </Card> |
| 276 | <Tabs defaultValue="react"> |
| 277 | <TabsList> |
| 278 | <TabsTrigger value="react">React</TabsTrigger> |
| 279 | <TabsTrigger value="vanilla">TypeScript</TabsTrigger> |
| 280 | </TabsList> |
| 281 | <TabsContent value="react"> |
| 282 | <CodeBlock title="These options, in React" code={reactSnippet(options) + magnifyLine} /> |
| 283 | </TabsContent> |
| 284 | <TabsContent value="vanilla"> |
| 285 | <CodeBlock title="These options, without React" code={vanillaSnippet(options) + (state.magnify !== 1 ? `\nglass.magnify(${state.magnify.toFixed(2)});` : "")} /> |
| 286 | </TabsContent> |
| 287 | </Tabs> |
| 288 | </div> |
| 289 | ); |
| 290 | } |
| 291 |