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.
| 6 |
| useRef, |
| 7 | type CSSProperties, |
| 8 | type ReactNode, |
| 9 | type RefObject, |
| 10 | } from "react"; |
| 11 |
| 12 | import { |
| 13 | attachLiquidGlass, |
| 14 | createLiquidGlass, |
| 15 | type LiquidGlass as LiquidGlassHandle, |
| 16 | type LiquidGlassOptions, |
| 17 | } from "./glass.js"; |
| 18 | import { attachLiquidGlassHighlight, type LiquidGlassHighlight, type LiquidGlassHighlightOptions } from "./highlight.js"; |
| 19 | import { attachLiquidGlassTabs, type LiquidGlassTabs, type LiquidGlassTabsOptions } from "./tabs.js"; |
| 20 |
| 21 | const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; |
| 22 |
| 23 | /** |
| 24 | * Applies options to a glass whenever one of their values changes, after the first |
| 25 | * build. Returns the latest options, for that first build. |
| 26 | */ |
| 27 | function useGlassOptions(glass: RefObject<LiquidGlassHandle | null>, options: LiquidGlassOptions) { |
| 28 | // Options arrive as fresh objects each render; compare values instead. A `shape` |
| 29 | // function counts by its outline at the glass's size, which the glass reads itself. |
| 30 | const latest = useRef(options); |
| 31 | latest.current = options; |
| 32 | const key = JSON.stringify(options, (name, value: unknown) => (name === "shape" && typeof value === "function" ? "function" : value)); |
| 33 | const applied = useRef(key); |
| 34 | useIsomorphicLayoutEffect(() => { |
| 35 | if (applied.current === key) return; |
| 36 | applied.current = key; |
| 37 | glass.current?.update(latest.current); |
| 38 | }, [glass, key]); |
| 39 | return latest; |
| 40 | } |
| 41 |
| 42 | export type LiquidGlassProps = LiquidGlassOptions & { |
| 43 | className?: string | undefined; |
| 44 | style?: CSSProperties | undefined; |
| 45 | /** Extra layers over the glass, such as a tint of your own. */ |
| 46 | children?: ReactNode | undefined; |
| 47 | /** Receives the glass, for materialize(), pourFrom() and the rest. */ |
| 48 | glassRef?: { current: LiquidGlassHandle | null } | undefined; |
| 49 | }; |
| 50 |
| 51 | /** |
| 52 | * Liquid Glass behind a surface's content. Render it first inside a positioned, |
| 53 | * rounded element and paint nothing over it: no background, border, shadow or |
| 54 | * backdrop-filter on the surface, and no opacity or filter on it or its ancestors |
| 55 | * while the glass shows. |
| 56 | * |
| 57 | * ```tsx |
| 58 | * <nav className="relative rounded-full px-6 py-3"> |
| 59 | * <LiquidGlass /> |
| 60 | * … |
| 61 | * </nav> |
| 62 | * ``` |
| 63 | */ |
| 64 | export function LiquidGlass({ className, style, children, glassRef, ...options }: LiquidGlassProps) { |
| 65 | const element = useRef<HTMLSpanElement>(null); |
| 66 | const glass = useRef<LiquidGlassHandle | null>(null); |
| 67 | const latest = useGlassOptions(glass, options); |
| 68 |
| 69 | useIsomorphicLayoutEffect(() => { |
| 70 | const node = element.current; |
| 71 | if (!node) return; |
| 72 | const instance = attachLiquidGlass(node, latest.current); |
| 73 | glass.current = instance; |
| 74 | if (glassRef) glassRef.current = instance; |
| 75 | return () => { |
| 76 | instance.destroy(); |
| 77 | glass.current = null; |
| 78 | if (glassRef) glassRef.current = null; |
| 79 | }; |
| 80 | }, [glassRef]); |
| 81 |
| 82 | return ( |
| 83 | <span ref={element} aria-hidden="true" className={className ? `liquid-glass ${className}` : "liquid-glass"} style={style}> |
| 84 | <span className="liquid-glass-layer" data-layer="body" /> |
| 85 | <span className="liquid-glass-layer" data-layer="refract" /> |
| 86 | <span className="liquid-glass-layer" data-layer="shade" /> |
| 87 | <span className="liquid-glass-layer" data-layer="glow" /> |
| 88 | <span className="liquid-glass-layer" data-layer="edge" /> |
| 89 | <span className="liquid-glass-layer" data-layer="highlight" /> |
| 90 | {children} |
| 91 | </span> |
| 92 | ); |
| 93 | } |
| 94 |
| 95 | /** |
| 96 | * Turns an element you already render into a glass surface. The glass is inserted |
| 97 | * as its first child. Returns a ref to the glass. |
| 98 | */ |
| 99 | export function useLiquidGlass(surface: RefObject<HTMLElement | null>, options: LiquidGlassOptions = {}) { |
| 100 | const glass = useRef<LiquidGlassHandle | null>(null); |
| 101 | const latest = useGlassOptions(glass, options); |
| 102 |
| 103 | useIsomorphicLayoutEffect(() => { |
| 104 | const node = surface.current; |
| 105 | if (!node) return; |
| 106 | const instance = createLiquidGlass(node, latest.current); |
| 107 | glass.current = instance; |
| 108 | return () => { |
| 109 | instance.destroy(); |
| 110 | glass.current = null; |
| 111 | }; |
| 112 | }, [surface]); |
| 113 |
| 114 | return glass; |
| 115 | } |
| 116 |
| 117 | /** |
| 118 | * Turns a row of tabs you render into Liquid Glass tabs: a glass track with a solid |
| 119 | * thumb that clears into a lens, travels and can be dragged. Selection stays yours; |
| 120 | * the thumb follows `aria-selected`, `aria-checked` or Radix's `data-state`. |
| 121 | * |
| 122 | * ```tsx |
| 123 | * const track = useRef<HTMLDivElement>(null); |
| 124 | * useLiquidGlassTabs(track); |
| 125 | * <div ref={track} role="tablist" className="flex rounded-full p-0.5">…</div> |
| 126 | * ``` |
| 127 | */ |
| 128 | export function useLiquidGlassTabs(track: RefObject<HTMLElement | null>, options: LiquidGlassTabsOptions = {}) { |
| 129 | const tabs = useRef<LiquidGlassTabs | null>(null); |
| 130 | // Callbacks may be fresh each render; the tabs call the latest ones. |
| 131 | const latest = useRef(options); |
| 132 | latest.current = options; |
| 133 | const { appearance, orientation, tabs: selector, glass, refraction } = options; |
| 134 | const glassKey = JSON.stringify(glass ?? null); |
| 135 |
| 136 | useIsomorphicLayoutEffect(() => { |
| 137 | const node = track.current; |
| 138 | if (!node) return; |
| 139 | const instance = attachLiquidGlassTabs(node, { |
| 140 | appearance, |
| 141 | orientation, |
| 142 | tabs: selector, |
| 143 | glass: JSON.parse(glassKey) as LiquidGlassTabsOptions["glass"] ?? undefined, |
| 144 | refraction, |
| 145 | isSelected: latest.current.isSelected ? tab => latest.current.isSelected?.(tab) ?? false : undefined, |
| 146 | onSelect: latest.current.onSelect ? tab => latest.current.onSelect?.(tab) : undefined, |
| 147 | }); |
| 148 | tabs.current = instance; |
| 149 | return () => { |
| 150 | instance.destroy(); |
| 151 | tabs.current = null; |
| 152 | }; |
| 153 | }, [track, appearance, orientation, selector, glassKey, refraction]); |
| 154 |
| 155 | return tabs; |
| 156 | } |
| 157 |
| 158 | /** |
| 159 | * A tab's text, semibold while its tab is selected, as iOS 27's segmented control |
| 160 | * sets it, without the tab changing width. |
| 161 | * |
| 162 | * ```tsx |
| 163 | * <button role="tab" aria-selected={selected}><LiquidGlassTabLabel>Week</LiquidGlassTabLabel></button> |
| 164 | * ``` |
| 165 | */ |
| 166 | export function LiquidGlassTabLabel({ children }: { children: string }) { |
| 167 | return ( |
| 168 | <span className="liquid-glass-tabs-label" data-text={children}> |
| 169 | <span>{children}</span> |
| 170 | </span> |
| 171 | ); |
| 172 | } |
| 173 |
| 174 | /** |
| 175 | * Gives a glass surface's rows a hover pill: a well in the glass that slides to |
| 176 | * the row under the pointer or with focus, stretching toward the row it is |
| 177 | * heading for. |
| 178 | * |
| 179 | * ```tsx |
| 180 | * const menu = useRef<HTMLDivElement>(null); |
| 181 | * useLiquidGlassHighlight(menu); |
| 182 | * <div ref={menu} role="menu" className="relative rounded-2xl p-1.5"><LiquidGlass floating />…</div> |
| 183 | * ``` |
| 184 | */ |
| 185 | export function useLiquidGlassHighlight(surface: RefObject<HTMLElement | null>, options: LiquidGlassHighlightOptions = {}) { |
| 186 | const highlight = useRef<LiquidGlassHighlight | null>(null); |
| 187 | const { items } = options; |
| 188 |
| 189 | // After the glass, which the pill sits over. |
| 190 | useEffect(() => { |
| 191 | const node = surface.current; |
| 192 | if (!node) return; |
| 193 | const instance = attachLiquidGlassHighlight(node, { items }); |
| 194 | highlight.current = instance; |
| 195 | return () => { |
| 196 | instance.destroy(); |
| 197 | highlight.current = null; |
| 198 | }; |
| 199 | }, [surface, items]); |
| 200 |
| 201 | return highlight; |
| 202 | } |
| 203 |
| 204 | export type { LiquidGlassHandle, LiquidGlassHighlight, LiquidGlassTabs, LiquidGlassTabsOptions }; |
| 205 |