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