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.
| IconItalic, |
| 7 | IconLink, |
| 8 | IconList, |
| 9 | IconSearch, |
| 10 | IconStrikethrough, |
| 11 | } from "@tabler/icons-react"; |
| 12 | import { cn, Kbd, motion } from "@colineapp/ui"; |
| 13 | import { LENS_GEOMETRY } from "@colineapp/liquid-glass"; |
| 14 | import { LiquidGlass, useLiquidGlassTabs, type LiquidGlassHandle } from "@colineapp/liquid-glass/react"; |
| 15 | import { useDraggable } from "./stage"; |
| 16 |
| 17 | /* The package's surfaces, built the way its README says to. Every glass |
| 18 | surface here is a positioned, rounded element with <LiquidGlass /> first |
| 19 | and nothing painted over it. */ |
| 20 |
| 21 | const EASE = [0.22, 1, 0.36, 1] as const; |
| 22 |
| 23 | /** |
| 24 | * Grows a floating surface's shadow in and out with its glass. 0.1.0's |
| 25 | * materialize() and dematerialize() leave the shadow as it is, so a closing |
| 26 | * menu kept a dark outline until it unmounted; the next release does this |
| 27 | * itself. |
| 28 | */ |
| 29 | function growShadow(glass: LiquidGlassHandle | null, direction: "in" | "out", duration: number) { |
| 30 | const surface = glass?.element.parentElement; |
| 31 | if (!surface) return; |
| 32 | const shadow = getComputedStyle(surface).boxShadow; |
| 33 | if (!shadow || shadow === "none") return; |
| 34 | const keyframes = [{ boxShadow: "none" }, { boxShadow: shadow }]; |
| 35 | surface.animate(direction === "in" ? keyframes : keyframes.reverse(), { |
| 36 | duration, |
| 37 | easing: "cubic-bezier(0.22, 1, 0.36, 1)", |
| 38 | fill: direction === "in" ? "backwards" : "forwards", |
| 39 | }); |
| 40 | } |
| 41 |
| 42 | // ── Pill tabs ─────────────────────────────────────────── |
| 43 |
| 44 | export function GlassTabs({ |
| 45 | options, |
| 46 | value, |
| 47 | onChange, |
| 48 | className, |
| 49 | label, |
| 50 | }: { |
| 51 | options: string[]; |
| 52 | value: string; |
| 53 | onChange: (value: string) => void; |
| 54 | className?: string; |
| 55 | label: string; |
| 56 | }) { |
| 57 | const track = React.useRef<HTMLDivElement>(null); |
| 58 | useLiquidGlassTabs(track, { appearance: "class" }); |
| 59 | const index = options.indexOf(value); |
| 60 | return ( |
| 61 | <div |
| 62 | ref={track} |
| 63 | role="tablist" |
| 64 | aria-label={label} |
| 65 | className={cn("relative flex rounded-full p-1", className)} |
| 66 | onKeyDown={(event) => { |
| 67 | const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; |
| 68 | if (!step) return; |
| 69 | event.preventDefault(); |
| 70 | const next = options[(index + step + options.length) % options.length]; |
| 71 | if (next === undefined) return; |
| 72 | onChange(next); |
| 73 | track.current?.querySelector<HTMLElement>(`[data-tab="${next}"]`)?.focus(); |
| 74 | }} |
| 75 | > |
| 76 | {options.map((option) => ( |
| 77 | <button |
| 78 | key={option} |
| 79 | type="button" |
| 80 | role="tab" |
| 81 | data-tab={option} |
| 82 | aria-selected={option === value} |
| 83 | tabIndex={option === value ? 0 : -1} |
| 84 | onClick={() => onChange(option)} |
| 85 | className={cn( |
| 86 | "rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap text-foreground transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-4", |
| 87 | option === value ? "" : "text-foreground/60 hover:text-foreground/85", |
| 88 | )} |
| 89 | > |
| 90 | {option} |
| 91 | </button> |
| 92 | ))} |
| 93 | </div> |
| 94 | ); |
| 95 | } |
| 96 |
| 97 | // ── Search field ──────────────────────────────────────── |
| 98 |
| 99 | export function GlassSearch({ className, placeholder = "Search" }: { className?: string; placeholder?: string }) { |
| 100 | const [value, setValue] = React.useState(""); |
| 101 | return ( |
| 102 | <label className={cn("relative flex h-11 cursor-text items-center gap-2.5 rounded-full px-4 text-foreground", className)}> |
| 103 | <LiquidGlass appearance="class" interactive swell /> |
| 104 | <IconSearch className="size-4 shrink-0 text-foreground/60" /> |
| 105 | <input |
| 106 | value={value} |
| 107 | onChange={(event) => setValue(event.target.value)} |
| 108 | placeholder={placeholder} |
| 109 | aria-label={placeholder} |
| 110 | className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-foreground/55" |
| 111 | /> |
| 112 | <Kbd className="hidden sm:inline-flex">⌘K</Kbd> |
| 113 | </label> |
| 114 | ); |
| 115 | } |
| 116 |
| 117 | // ── Toolbar ───────────────────────────────────────────── |
| 118 |
| 119 | const TOOLS = [ |
| 120 | { id: "bold", label: "Bold", icon: IconBold }, |
| 121 | { id: "italic", label: "Italic", icon: IconItalic }, |
| 122 | { id: "strike", label: "Strikethrough", icon: IconStrikethrough }, |
| 123 | { id: "link", label: "Link", icon: IconLink }, |
| 124 | { id: "list", label: "List", icon: IconList }, |
| 125 | ] as const; |
| 126 |
| 127 | export function GlassToolbar({ className }: { className?: string }) { |
| 128 | const [on, setOn] = React.useState<Set<string>>(() => new Set(["bold"])); |
| 129 | return ( |
| 130 | <div role="toolbar" aria-label="Formatting" className={cn("relative flex items-center gap-0.5 rounded-full p-1.5", className)}> |
| 131 | <LiquidGlass appearance="class" interactive /> |
| 132 | {TOOLS.map(({ id, label, icon: Icon }) => { |
| 133 | const active = on.has(id); |
| 134 | return ( |
| 135 | <button |
| 136 | key={id} |
| 137 | type="button" |
| 138 | aria-label={label} |
| 139 | aria-pressed={active} |
| 140 | onClick={() => |
| 141 | setOn((current) => { |
| 142 | const next = new Set(current); |
| 143 | if (next.has(id)) next.delete(id); |
| 144 | else next.add(id); |
| 145 | return next; |
| 146 | }) |
| 147 | } |
| 148 | className={cn( |
| 149 | "grid size-9 place-items-center rounded-full text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring", |
| 150 | active ? "liquid-glass-well" : "text-foreground/70 hover:text-foreground", |
| 151 | )} |
| 152 | > |
| 153 | <Icon className="size-[18px]" stroke={2} /> |
| 154 | </button> |
| 155 | ); |
| 156 | })} |
| 157 | </div> |
| 158 | ); |
| 159 | } |
| 160 |
| 161 | // ── Menu ──────────────────────────────────────────────── |
| 162 |
| 163 | const SORTS = ["Date added", "Name", "Size", "Kind"]; |
| 164 |
| 165 | /** Glass menu: the contents fade and zoom, the glass materializes, and the surface only moves. */ |
| 166 | export function GlassMenu({ className }: { className?: string }) { |
| 167 | const [open, setOpen] = React.useState(false); |
| 168 | const [sort, setSort] = React.useState(SORTS[0]!); |
| 169 | const [active, setActive] = React.useState(-1); |
| 170 | const root = React.useRef<HTMLDivElement>(null); |
| 171 | const glass = React.useRef<LiquidGlassHandle | null>(null); |
| 172 | const [leaving, setLeaving] = React.useState(false); |
| 173 |
| 174 | const close = React.useCallback(() => { |
| 175 | setLeaving(true); |
| 176 | growShadow(glass.current, "out", 160); |
| 177 | void glass.current?.dematerialize({ duration: 160, scale: 0.96 }).then(() => { |
| 178 | setOpen(false); |
| 179 | setLeaving(false); |
| 180 | setActive(-1); |
| 181 | }); |
| 182 | }, []); |
| 183 |
| 184 | React.useLayoutEffect(() => { |
| 185 | if (!open || leaving) return; |
| 186 | void glass.current?.materialize({ duration: 240, scale: 0.96 }); |
| 187 | growShadow(glass.current, "in", 240); |
| 188 | }, [open, leaving]); |
| 189 |
| 190 | React.useEffect(() => { |
| 191 | if (!open) return; |
| 192 | const onDown = (event: PointerEvent) => { |
| 193 | if (!root.current?.contains(event.target as Node)) close(); |
| 194 | }; |
| 195 | const onKey = (event: KeyboardEvent) => { |
| 196 | if (event.key === "Escape") close(); |
| 197 | }; |
| 198 | window.addEventListener("pointerdown", onDown); |
| 199 | window.addEventListener("keydown", onKey); |
| 200 | return () => { |
| 201 | window.removeEventListener("pointerdown", onDown); |
| 202 | window.removeEventListener("keydown", onKey); |
| 203 | }; |
| 204 | }, [open, close]); |
| 205 |
| 206 | return ( |
| 207 | <div ref={root} className={cn("relative", className)}> |
| 208 | <button |
| 209 | type="button" |
| 210 | aria-haspopup="menu" |
| 211 | aria-expanded={open} |
| 212 | onClick={() => (open ? close() : setOpen(true))} |
| 213 | className="relative flex h-10 items-center gap-1.5 rounded-full pr-3 pl-4 text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 214 | > |
| 215 | <LiquidGlass appearance="class" interactive swell /> |
| 216 | Sort by {sort.toLowerCase()} |
| 217 | <IconChevronDown className="size-4 text-foreground/60" /> |
| 218 | </button> |
| 219 | {open ? ( |
| 220 | <div role="menu" aria-label="Sort by" className="absolute top-12 left-0 z-10 w-52 origin-top-left rounded-[22px] p-1.5 text-foreground"> |
| 221 | <LiquidGlass glassRef={glass} appearance="class" floating /> |
| 222 | <motion.div |
| 223 | initial={{ opacity: 0, scale: 0.96 }} |
| 224 | animate={leaving ? { opacity: 0, scale: 0.96 } : { opacity: 1, scale: 1 }} |
| 225 | transition={{ duration: leaving ? 0.16 : 0.24, ease: EASE }} |
| 226 | className="origin-top-left" |
| 227 | > |
| 228 | {SORTS.map((item, index) => ( |
| 229 | <button |
| 230 | key={item} |
| 231 | type="button" |
| 232 | role="menuitemradio" |
| 233 | aria-checked={item === sort} |
| 234 | onPointerEnter={() => setActive(index)} |
| 235 | onPointerLeave={() => setActive(-1)} |
| 236 | onClick={() => { |
| 237 | setSort(item); |
| 238 | close(); |
| 239 | }} |
| 240 | className={cn( |
| 241 | "flex w-full items-center justify-between rounded-2xl px-3 py-2 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", |
| 242 | index === active && "liquid-glass-well", |
| 243 | )} |
| 244 | > |
| 245 | {item} |
| 246 | {item === sort ? <IconCheck className="size-4" /> : null} |
| 247 | </button> |
| 248 | ))} |
| 249 | </motion.div> |
| 250 | </div> |
| 251 | ) : null} |
| 252 | </div> |
| 253 | ); |
| 254 | } |
| 255 |
| 256 | // ── Dialog ────────────────────────────────────────────── |
| 257 |
| 258 | /** A glass dialog inside its stage: floating="dialog", materializing as it zooms in. */ |
| 259 | export function GlassDialog({ className }: { className?: string }) { |
| 260 | const [open, setOpen] = React.useState(false); |
| 261 | const [leaving, setLeaving] = React.useState(false); |
| 262 | const [hover, setHover] = React.useState(false); |
| 263 | const glass = React.useRef<LiquidGlassHandle | null>(null); |
| 264 | const trigger = React.useRef<HTMLButtonElement>(null); |
| 265 | const first = React.useRef<HTMLButtonElement>(null); |
| 266 |
| 267 | const close = React.useCallback(() => { |
| 268 | setLeaving(true); |
| 269 | growShadow(glass.current, "out", 180); |
| 270 | void glass.current?.dematerialize({ duration: 180, scale: 0.97 }).then(() => { |
| 271 | setOpen(false); |
| 272 | setLeaving(false); |
| 273 | trigger.current?.focus(); |
| 274 | }); |
| 275 | }, []); |
| 276 |
| 277 | React.useLayoutEffect(() => { |
| 278 | if (!open || leaving) return; |
| 279 | void glass.current?.materialize({ duration: 280, scale: 0.95 }); |
| 280 | growShadow(glass.current, "in", 280); |
| 281 | first.current?.focus(); |
| 282 | }, [open, leaving]); |
| 283 |
| 284 | return ( |
| 285 | <div className={cn("absolute inset-0 grid place-items-center", className)}> |
| 286 | <button |
| 287 | ref={trigger} |
| 288 | type="button" |
| 289 | onClick={() => setOpen(true)} |
| 290 | className="relative flex h-10 items-center rounded-full px-5 text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 291 | > |
| 292 | <LiquidGlass appearance="class" interactive swell /> |
| 293 | Share album… |
| 294 | </button> |
| 295 | <> |
| 296 | {open ? ( |
| 297 | <div className="absolute inset-0 grid place-items-center p-4" onKeyDown={(event) => event.key === "Escape" && close()}> |
| 298 | <motion.div |
| 299 | aria-hidden="true" |
| 300 | className="absolute inset-0 bg-background/50" |
| 301 | initial={{ opacity: 0 }} |
| 302 | animate={{ opacity: leaving ? 0 : 1 }} |
| 303 | transition={{ duration: 0.2 }} |
| 304 | onClick={close} |
| 305 | /> |
| 306 | <div |
| 307 | role="dialog" |
| 308 | aria-modal="true" |
| 309 | aria-labelledby="share-title" |
| 310 | className="relative w-full max-w-[300px] rounded-[28px] p-5 text-foreground" |
| 311 | > |
| 312 | <LiquidGlass glassRef={glass} appearance="class" floating="dialog" /> |
| 313 | <motion.div |
| 314 | initial={{ opacity: 0, scale: 0.95 }} |
| 315 | animate={leaving ? { opacity: 0, scale: 0.97 } : { opacity: 1, scale: 1 }} |
| 316 | transition={{ duration: leaving ? 0.18 : 0.28, ease: EASE }} |
| 317 | > |
| 318 | <h3 id="share-title" className="text-base font-semibold"> |
| 319 | Share “Summer 2026” |
| 320 | </h3> |
| 321 | <p className="mt-1.5 text-sm text-foreground/70">Anyone with the link can see the 48 photos in this album.</p> |
| 322 | <div className="mt-5 grid grid-cols-2 gap-2"> |
| 323 | <button |
| 324 | ref={first} |
| 325 | type="button" |
| 326 | onClick={close} |
| 327 | onPointerEnter={() => setHover(true)} |
| 328 | onPointerLeave={() => setHover(false)} |
| 329 | className={cn("h-10 rounded-full text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", hover && "liquid-glass-well")} |
| 330 | > |
| 331 | Cancel |
| 332 | </button> |
| 333 | <button |
| 334 | type="button" |
| 335 | onClick={close} |
| 336 | className="liquid-glass-well h-10 rounded-full text-sm font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring" |
| 337 | > |
| 338 | Copy link |
| 339 | </button> |
| 340 | </div> |
| 341 | </motion.div> |
| 342 | </div> |
| 343 | </div> |
| 344 | ) : null} |
| 345 | </> |
| 346 | </div> |
| 347 | ); |
| 348 | } |
| 349 |
| 350 | // ── Lens ──────────────────────────────────────────────── |
| 351 |
| 352 | /** The tabs' lens on its own: drag it over the text; pressed, it magnifies more. */ |
| 353 | export function GlassLens({ size = 132, initial = { x: 0.5, y: 0.45 } }: { size?: number; initial?: { x: number; y: number } }) { |
| 354 | // Magnifies from the moment the glass exists, and more while it's held. |
| 355 | const lens = React.useMemo(() => { |
| 356 | let current: LiquidGlassHandle | null = null; |
| 357 | return { |
| 358 | get current() { |
| 359 | return current; |
| 360 | }, |
| 361 | set current(value: LiquidGlassHandle | null) { |
| 362 | current = value; |
| 363 | value?.magnify(1.12); |
| 364 | }, |
| 365 | }; |
| 366 | }, []); |
| 367 | const drag = useDraggable({ initial }); |
| 368 | React.useEffect(() => { |
| 369 | lens.current?.magnify(drag.dragging ? 1.22 : 1.12); |
| 370 | }, [drag.dragging, lens]); |
| 371 | return ( |
| 372 | <div |
| 373 | ref={drag.ref} |
| 374 | style={{ ...drag.style, width: size, height: size }} |
| 375 | onPointerDown={drag.onPointerDown} |
| 376 | onPointerMove={drag.onPointerMove} |
| 377 | onPointerUp={drag.onPointerUp} |
| 378 | onPointerCancel={drag.onPointerCancel} |
| 379 | className={cn("absolute cursor-grab rounded-full", drag.dragging && "cursor-grabbing")} |
| 380 | role="img" |
| 381 | aria-label="A magnifying glass lens. Drag it." |
| 382 | > |
| 383 | <LiquidGlass glassRef={lens} appearance="class" variant="lens" geometry={LENS_GEOMETRY} samples={4} /> |
| 384 | </div> |
| 385 | ); |
| 386 | } |
| 387 |