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.
| 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"; |
| 18 | import { attachLiquidGlassTabs, type LiquidGlassTabs, type LiquidGlassTabsOptions } from "./tabs"; |
| 19 |
| 20 | const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; |
| 21 |
| 22 | /** Applies options to a glass whenever one of their values changes, after the first build. */ |
| 23 | function useGlassOptions(glass: RefObject<LiquidGlassHandle | null>, options: LiquidGlassOptions) { |
| 24 | // Options arrive as fresh objects each render; compare values instead. |
| 25 | const key = JSON.stringify(options); |
| 26 | const applied = useRef(key); |
| 27 | useIsomorphicLayoutEffect(() => { |
| 28 | if (applied.current === key) return; |
| 29 | applied.current = key; |
| 30 | glass.current?.update(JSON.parse(key) as LiquidGlassOptions); |
| 31 | }, [glass, key]); |
| 32 | return key; |
| 33 | } |
| 34 |
| 35 | export type LiquidGlassProps = LiquidGlassOptions & { |
| 36 | className?: string | undefined; |
| 37 | style?: CSSProperties | undefined; |
| 38 | /** Extra layers over the glass, such as a tint of your own. */ |
| 39 | children?: ReactNode | undefined; |
| 40 | /** Receives the glass, for materialize() and dematerialize(). */ |
| 41 | glassRef?: { current: LiquidGlassHandle | null } | undefined; |
| 42 | }; |
| 43 |
| 44 | /** |
| 45 | * Liquid Glass behind a surface's content. Render it first inside a positioned, |
| 46 | * rounded element and paint nothing over it: no background, border, shadow or |
| 47 | * backdrop-filter on the surface, and no opacity or filter on it or its ancestors |
| 48 | * while the glass shows. |
| 49 | * |
| 50 | * ```tsx |
| 51 | * <nav className="relative rounded-full px-6 py-3"> |
| 52 | * <LiquidGlass /> |
| 53 | * … |
| 54 | * </nav> |
| 55 | * ``` |
| 56 | */ |
| 57 | export function LiquidGlass({ className, style, children, glassRef, ...options }: LiquidGlassProps) { |
| 58 | const element = useRef<HTMLSpanElement>(null); |
| 59 | const glass = useRef<LiquidGlassHandle | null>(null); |
| 60 | const key = useGlassOptions(glass, options); |
| 61 | const initial = useRef(key); |
| 62 | initial.current = key; |
| 63 |
| 64 | useIsomorphicLayoutEffect(() => { |
| 65 | const node = element.current; |
| 66 | if (!node) return; |
| 67 | const instance = attachLiquidGlass(node, JSON.parse(initial.current) as LiquidGlassOptions); |
| 68 | glass.current = instance; |
| 69 | if (glassRef) glassRef.current = instance; |
| 70 | return () => { |
| 71 | instance.destroy(); |
| 72 | glass.current = null; |
| 73 | if (glassRef) glassRef.current = null; |
| 74 | }; |
| 75 | }, [glassRef]); |
| 76 |
| 77 | return ( |
| 78 | <span ref={element} aria-hidden="true" className={className ? `liquid-glass ${className}` : "liquid-glass"} style={style}> |
| 79 | <span className="liquid-glass-layer" data-layer="body" /> |
| 80 | <span className="liquid-glass-layer" data-layer="shade" /> |
| 81 | <span className="liquid-glass-layer" data-layer="glow" /> |
| 82 | <span className="liquid-glass-layer" data-layer="edge" /> |
| 83 | <span className="liquid-glass-layer" data-layer="highlight" /> |
| 84 | {children} |
| 85 | </span> |
| 86 | ); |
| 87 | } |
| 88 |
| 89 | /** |
| 90 | * Turns an element you already render into a glass surface. The glass is inserted |
| 91 | * as its first child. Returns a ref to the glass. |
| 92 | */ |
| 93 | export function useLiquidGlass(surface: RefObject<HTMLElement | null>, options: LiquidGlassOptions = {}) { |
| 94 | const glass = useRef<LiquidGlassHandle | null>(null); |
| 95 | const key = useGlassOptions(glass, options); |
| 96 | const initial = useRef(key); |
| 97 | initial.current = key; |
| 98 |
| 99 | useIsomorphicLayoutEffect(() => { |
| 100 | const node = surface.current; |
| 101 | if (!node) return; |
| 102 | const instance = createLiquidGlass(node, JSON.parse(initial.current) as LiquidGlassOptions); |
| 103 | glass.current = instance; |
| 104 | return () => { |
| 105 | instance.destroy(); |
| 106 | glass.current = null; |
| 107 | }; |
| 108 | }, [surface]); |
| 109 |
| 110 | return glass; |
| 111 | } |
| 112 |
| 113 | /** |
| 114 | * Turns a row of tabs you render into Liquid Glass tabs: a glass track with a thumb |
| 115 | * that lifts into a lens, travels and can be dragged. Selection stays yours; the |
| 116 | * thumb follows `aria-selected`, `aria-checked` or Radix's `data-state`. |
| 117 | * |
| 118 | * ```tsx |
| 119 | * const track = useRef<HTMLDivElement>(null); |
| 120 | * useLiquidGlassTabs(track); |
| 121 | * <div ref={track} role="tablist" className="flex rounded-full p-1">…</div> |
| 122 | * ``` |
| 123 | */ |
| 124 | export function useLiquidGlassTabs(track: RefObject<HTMLElement | null>, options: LiquidGlassTabsOptions = {}) { |
| 125 | const tabs = useRef<LiquidGlassTabs | null>(null); |
| 126 | // Callbacks may be fresh each render; the tabs call the latest ones. |
| 127 | const latest = useRef(options); |
| 128 | latest.current = options; |
| 129 | const { appearance, orientation, tabs: selector, glass, refraction } = options; |
| 130 | const glassKey = JSON.stringify(glass ?? null); |
| 131 |
| 132 | useIsomorphicLayoutEffect(() => { |
| 133 | const node = track.current; |
| 134 | if (!node) return; |
| 135 | const instance = attachLiquidGlassTabs(node, { |
| 136 | appearance, |
| 137 | orientation, |
| 138 | tabs: selector, |
| 139 | glass: JSON.parse(glassKey) as LiquidGlassTabsOptions["glass"] ?? undefined, |
| 140 | refraction, |
| 141 | isSelected: latest.current.isSelected ? tab => latest.current.isSelected?.(tab) ?? false : undefined, |
| 142 | onSelect: latest.current.onSelect ? tab => latest.current.onSelect?.(tab) : undefined, |
| 143 | }); |
| 144 | tabs.current = instance; |
| 145 | return () => { |
| 146 | instance.destroy(); |
| 147 | tabs.current = null; |
| 148 | }; |
| 149 | }, [track, appearance, orientation, selector, glassKey, refraction]); |
| 150 |
| 151 | return tabs; |
| 152 | } |
| 153 |
| 154 | export type { LiquidGlassHandle, LiquidGlassTabs, LiquidGlassTabsOptions }; |
| 155 |