Liquid Glass
The material itself: a lens that refracts what’s behind it, with squircle bevel, specular rim, dispersion and spring physics.
An inset glass sheet with detents: content peeks around it, and it turns edge-to-edge and more opaque at full height.
Browser support · Real refraction (SVG displacement inside backdrop-filter) in Chromium browsers. Safari and Firefox fall back to a frosted blur with the same rim and highlights.
npx shadcn@latest add https://freedesignmd.com/r/glass-sheet.json1/**2 * GlassSheet — an iOS 26 Liquid Glass sheet with detents.3 *4 * - Half / peek detents are inset from the screen edges with a large corner5 * radius, so content peeks around them (HIG). The radius stays concentric6 * with the container (`radius` = container radius − inset).7 * - Drag the grabber (or anywhere on the header) to move between detents;8 * release uses the drag velocity to pick a detent and springs there.9 * - At the full detent the sheet grows to the edges, its corners match the10 * screen and the glass becomes more opaque for long-form reading.11 *12 * Props: detents (fractions of the container height, e.g. [0.2, 0.52, 0.94]),13 * detent (index, controlled) + onDetentChange, inset (px, default 8),14 * radius (corner radius at rest, default 36), tone, label (aria-label),15 * header (always-visible content next to the grabber), children (body).16 * Must be placed inside a positioned container (position: relative) — it fills17 * that container, like a sheet inside a screen.18 * Keyboard: the grabber is a button — Enter/Space cycles detents, Arrow19 * Up/Down step through them. role="dialog" aria-modal="false".20 * Reduced motion: detent changes are instant. Reduced transparency/contrast:21 * opaque surface.22 */23import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";24import type { CSSProperties, KeyboardEvent, ReactNode } from "react";25import { GlassScope, LiquidGlass, createVelocityTracker, useGlassEnv, useSpring } from "./liquid-glass";26 27export interface GlassSheetProps {28 detents?: number[];29 detent: number;30 onDetentChange: (i: number) => void;31 inset?: number;32 radius?: number;33 tone?: "light" | "dark";34 label: string;35 header?: ReactNode;36 children?: ReactNode;37 className?: string;38}39 40const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;41 42export function GlassSheet({ detents = [0.2, 0.52, 0.94], detent, onDetentChange, inset = 8, radius = 36, tone = "light", label, header, children, className }: GlassSheetProps) {43 const env = useGlassEnv();44 const host = useRef<HTMLDivElement | null>(null);45 const sheet = useRef<HTMLElement | null>(null);46 const body = useRef<HTMLDivElement | null>(null);47 const H = useRef(0);48 const [full, setFull] = useState(false);49 50 const fullIdx = detents.length - 1;51 // expansion 0 (inset, glassy) → 1 (edge-to-edge, more opaque) over the last detent interval52 const paint = (hpx: number) => {53 const s = sheet.current;54 const total = H.current;55 if (!s || !total) return;56 const lo = detents[Math.max(0, fullIdx - 1)] * total;57 const hi = detents[fullIdx] * total;58 const e = Math.min(1, Math.max(0, (hpx - lo) / Math.max(1, hi - lo)));59 const ins = inset * (1 - e);60 s.style.height = `${Math.max(56, hpx).toFixed(1)}px`;61 s.style.left = `${ins.toFixed(2)}px`;62 s.style.right = `${ins.toFixed(2)}px`;63 s.style.bottom = `${ins.toFixed(2)}px`;64 s.style.setProperty("--fd-gsh-e", e.toFixed(3));65 s.style.borderRadius = `${(radius + inset * e).toFixed(2)}px ${(radius + inset * e).toFixed(2)}px ${(radius * (1 - e) + 0).toFixed(2)}px ${(radius * (1 - e)).toFixed(2)}px`;66 if (e > 0.98 !== fullRef.current) {67 fullRef.current = e > 0.98;68 setFull(fullRef.current);69 }70 };71 const fullRef = useRef(false);72 const hs = useSpring(0, paint, { stiffness: 380, damping: 34, precision: 0.2 });73 74 const target = useCallback((i: number) => detents[Math.min(Math.max(i, 0), fullIdx)] * H.current, [detents, fullIdx]);75 76 // measure container77 useIso(() => {78 const h = host.current?.parentElement;79 if (!h) return;80 const measure = () => {81 H.current = h.clientHeight;82 hs.set(target(detentRef.current));83 };84 measure();85 const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;86 ro?.observe(h);87 return () => ro?.disconnect();88 // eslint-disable-next-line react-hooks/exhaustive-deps89 }, []);90 const detentRef = useRef(detent);91 detentRef.current = detent;92 93 const dragging = useRef(false);94 useEffect(() => {95 if (dragging.current || !H.current) return;96 hs.stiffness = 380;97 hs.damping = 34;98 if (env.reducedMotion) hs.set(target(detent));99 else hs.to(target(detent));100 }, [detent, target, env.reducedMotion, hs]);101 102 // drag on the header zone103 const grab = useRef<HTMLDivElement | null>(null);104 useEffect(() => {105 const g = grab.current;106 if (!g) return;107 const vt = createVelocityTracker();108 let pid = -1, y0 = 0, h0 = 0, moved = false;109 const down = (e: PointerEvent) => {110 if (e.button !== 0) return;111 pid = e.pointerId;112 y0 = e.clientY;113 h0 = hs.value;114 moved = false;115 vt.reset();116 vt.push(0, e.clientY);117 // follow the gesture on window: the finger leaves the grab zone immediately118 window.addEventListener("pointermove", move);119 window.addEventListener("pointerup", up);120 window.addEventListener("pointercancel", up);121 };122 const move = (e: PointerEvent) => {123 if (e.pointerId !== pid) return;124 const dy = y0 - e.clientY;125 if (!moved && Math.abs(dy) < 4) return;126 if (!moved) {127 moved = true;128 dragging.current = true;129 g.classList.add("is-dragging");130 }131 vt.push(0, e.clientY);132 const min = target(0), max = target(fullIdx);133 let h = h0 + dy;134 if (h > max) h = max + (h - max) * 0.2;135 if (h < min) h = min - (min - h) * 0.3;136 hs.stiffness = 1200;137 hs.damping = 70;138 if (env.reducedMotion) hs.set(h);139 else hs.to(h);140 };141 const up = (e: PointerEvent) => {142 if (e.pointerId !== pid) return;143 pid = -1;144 window.removeEventListener("pointermove", move);145 window.removeEventListener("pointerup", up);146 window.removeEventListener("pointercancel", up);147 if (!moved) return;148 g.classList.remove("is-dragging");149 dragging.current = false;150 const { vy } = vt.get();151 const projected = hs.value - vy * 0.18; // fling152 let best = 0, bd = Infinity;153 detents.forEach((d, i) => {154 const dd = Math.abs(d * H.current - projected);155 if (dd < bd) (bd = dd), (best = i);156 });157 hs.stiffness = 380;158 hs.damping = 32;159 if (env.reducedMotion) hs.set(target(best));160 else hs.to(target(best), -vy);161 if (best !== detentRef.current) onDetentChange(best);162 // eat the click that ends a drag163 const stop = (ev: Event) => {164 ev.stopPropagation();165 ev.preventDefault();166 };167 g.addEventListener("click", stop, { capture: true, once: true });168 setTimeout(() => g.removeEventListener("click", stop, { capture: true }), 0);169 };170 g.addEventListener("pointerdown", down);171 return () => {172 g.removeEventListener("pointerdown", down);173 window.removeEventListener("pointermove", move);174 window.removeEventListener("pointerup", up);175 window.removeEventListener("pointercancel", up);176 };177 }, [detents, fullIdx, target, onDetentChange, env.reducedMotion, hs]);178 179 const onGrabKey = (e: KeyboardEvent<HTMLButtonElement>) => {180 if (e.key === "ArrowUp") {181 e.preventDefault();182 onDetentChange(Math.min(fullIdx, detent + 1));183 } else if (e.key === "ArrowDown") {184 e.preventDefault();185 onDetentChange(Math.max(0, detent - 1));186 }187 };188 189 return (190 <GlassScope>191 <div ref={host} className={["fd-gsh-host", `fd-gsh--${tone}`, className ?? ""].filter(Boolean).join(" ")}>192 <style>{css}</style>193 <LiquidGlass194 ref={sheet}195 className={`fd-gsh${full ? " is-full" : ""}`}196 variant="regular"197 tone={tone}198 radius="auto"199 bezel={28}200 role="dialog"201 aria-modal="false"202 aria-label={label}203 style={{ "--fd-gsh-r": `${radius}px` } as CSSProperties}204 >205 <div ref={grab} className="fd-gsh-grab">206 <button207 type="button"208 className="fd-gsh-handle"209 aria-label={`Resize sheet — ${["collapsed", "half height", "full height"][Math.min(detent, 2)] ?? `detent ${detent + 1}`}`}210 onClick={() => onDetentChange(detent >= fullIdx ? 0 : detent + 1)}211 onKeyDown={onGrabKey}212 >213 <span aria-hidden="true" />214 </button>215 {header}216 </div>217 <div ref={body} className="fd-gsh-body" aria-hidden={detent === 0 || undefined}>218 {children}219 </div>220 </LiquidGlass>221 </div>222 </GlassScope>223 );224}225 226const css = `227.fd-gsh-host,.fd-gsh-host *,.fd-gsh-host *::before,.fd-gsh-host *::after{box-sizing:border-box}228.fd-gsh-host{position:absolute;inset:0;margin:0;padding:0;pointer-events:none;z-index:4}229.fd-gsh{position:absolute !important;left:8px;right:8px;bottom:8px;height:50%;display:flex;flex-direction:column;margin:0;padding:0;overflow:hidden;pointer-events:auto;will-change:height;text-align:left;230 --fd-lg-shadow:0 -10px 40px -10px rgba(0,0,0,0.28),0 0 0 0.5px rgba(0,0,0,0.04);231 color:#0B0B0F;font:400 15px/1.4 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif}232.fd-gsh--dark .fd-gsh{color:#F2F2F7}233/* more opaque as it expands: fill alpha .3 → .86 */234.fd-gsh .fd-lg-fill{background:rgba(250,250,252,calc(0.5 + var(--fd-gsh-e,0) * 0.44))}235.fd-gsh--dark .fd-gsh .fd-lg-fill{background:rgba(28,28,32,calc(0.5 + var(--fd-gsh-e,0) * 0.42))}236.fd-gsh-grab{position:relative;flex:none;margin:0;padding:0 20px 8px;touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}237.fd-gsh-grab.is-dragging,.fd-gsh-grab.is-dragging *{cursor:grabbing}238.fd-gsh-handle{display:flex;justify-content:center;align-items:center;width:100%;height:22px;margin:0;padding:0;border:0;background:none;cursor:inherit;-webkit-appearance:none;appearance:none;border-radius:8px}239.fd-gsh-handle span{display:block;width:38px;height:5px;margin:0;border-radius:3px;background:rgba(11,11,15,0.22)}240.fd-gsh--dark .fd-gsh-handle span{background:rgba(255,255,255,0.32)}241.fd-gsh-handle:focus-visible{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:-2px}242.fd-gsh-body{position:relative;flex:1;min-height:0;margin:0;padding:0 20px 24px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none}243.fd-gsh-body::-webkit-scrollbar{display:none}244@media (prefers-reduced-transparency: reduce){.fd-gsh .fd-lg-fill{background:#F2F2F7}.fd-gsh--dark .fd-gsh .fd-lg-fill{background:#1C1C22}}245@media (prefers-contrast: more){.fd-gsh .fd-lg-fill{background:#fff}.fd-gsh--dark .fd-gsh .fd-lg-fill{background:#000}}246`;247 248/* ───────────────────────── demo: maps / hotel booking ───────────────────────── */249 250const ROOMS = [251 { n: "Deluxe King, harbour view", d: "32 m² · Breakfast for two · Free cancellation until 10 Jun", p: 318, c: ["#FFB199", "#FF0844"] },252 { n: "Junior Suite with terrace", d: "48 m² · Terrace with daybeds · Late checkout", p: 452, c: ["#84FAB0", "#0BA360"] },253 { n: "Twin Classic, courtyard", d: "26 m² · Quiet side · Pay at hotel", p: 246, c: ["#A1C4FD", "#3B5BDB"] },254];255 256export function Demo() {257 const [detent, setDetent] = useState(1);258 const [room, setRoom] = useState(0);259 return (260 <div className="fd-gshd">261 <style>{demoCss}</style>262 <div className="fd-gshd-device">263 <MapLayer />264 <div className="fd-gshd-pin" style={{ left: "58%", top: "36%" }} aria-hidden="true">265 <span>€318</span>266 </div>267 <div className="fd-gshd-pin is-dim" style={{ left: "28%", top: "22%" }} aria-hidden="true">268 <span>€204</span>269 </div>270 <div className="fd-gshd-pin is-dim" style={{ left: "76%", top: "18%" }} aria-hidden="true">271 <span>€389</span>272 </div>273 <GlassSheet274 detent={detent}275 onDetentChange={setDetent}276 label="Casa Marèa, Lisbon"277 header={278 <div className="fd-gshd-head">279 <div className="fd-gshd-thumb" aria-hidden="true" />280 <div className="fd-gshd-titles">281 <p className="fd-gshd-title">Casa Marèa</p>282 <p className="fd-gshd-sub">Boutique hotel · Alfama, Lisbon · ★ 4.8</p>283 </div>284 <button type="button" className="fd-gshd-x" aria-label="Close">285 <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" aria-hidden="true">286 <path d="M6 6l12 12M18 6 6 18" />287 </svg>288 </button>289 </div>290 }291 >292 <div className="fd-gshd-chips" role="list">293 {["14 – 17 Jun", "2 guests", "3 nights", "4 min to tram 28"].map((c) => (294 <span key={c} role="listitem" className="fd-gshd-chip">295 {c}296 </span>297 ))}298 </div>299 <p className="fd-gshd-h">Choose your room</p>300 <ul className="fd-gshd-rooms" role="radiogroup" aria-label="Rooms">301 {ROOMS.map((r, i) => (302 <li key={r.n}>303 <button type="button" role="radio" aria-checked={room === i} className="fd-gshd-room" onClick={() => setRoom(i)}>304 <span className="fd-gshd-room-img" style={{ background: `linear-gradient(135deg, ${r.c[0]}, ${r.c[1]})` }} aria-hidden="true" />305 <span className="fd-gshd-room-txt">306 <b>{r.n}</b>307 <span>{r.d}</span>308 </span>309 <span className="fd-gshd-room-p">310 €{r.p}311 <small>/night</small>312 </span>313 </button>314 </li>315 ))}316 </ul>317 <button type="button" className="fd-gshd-cta">318 Reserve · €{ROOMS[room].p * 3} total319 </button>320 <p className="fd-gshd-h">About</p>321 <p className="fd-gshd-p">322 Twelve rooms in a restored 1890s merchant’s house above the Tagus. Azulejo-tiled stairwell, a rooftop bar that opens at 17:00 and a323 breakfast of pastéis de nata from the bakery next door.324 </p>325 <p className="fd-gshd-p">Check-in from 15:00 · Check-out until 11:00 · No lift (3 floors) · Pets on request.</p>326 </GlassSheet>327 </div>328 </div>329 );330}331 332function MapLayer() {333 return (334 <svg className="fd-gshd-map" viewBox="0 0 400 860" preserveAspectRatio="xMidYMid slice" aria-hidden="true">335 <rect width="400" height="860" fill="#EDE9E0" />336 <path d="M0 560 C80 520 150 600 230 560 S360 520 400 540 V860 H0 Z" fill="#9FD3F2" />337 <path d="M0 600 C90 570 160 640 240 600 S360 570 400 590" fill="none" stroke="#B9E1F7" strokeWidth="6" />338 <g fill="#C9E7B5">339 <path d="M30 80h90v70H30z" />340 <path d="M260 230l80 10-10 80-80-10z" />341 <circle cx="150" cy="420" r="38" />342 </g>343 <g fill="#E1DCD1">344 {Array.from({ length: 60 }, (_, i) => {345 const x = (i * 67) % 380;346 const y = ((i * 131) % 520) + 10;347 return <rect key={i} x={x} y={y} width={18 + (i % 4) * 6} height={14 + (i % 3) * 6} rx="2" />;348 })}349 </g>350 <g fill="none" stroke="#FFFFFF" strokeLinecap="round">351 <path d="M-10 200 C100 180 200 260 410 220" strokeWidth="14" />352 <path d="M120 -10 C140 200 100 380 180 560" strokeWidth="12" />353 <path d="M-10 420 C120 400 250 470 410 430" strokeWidth="10" />354 <path d="M300 -10 C280 140 330 300 290 560" strokeWidth="9" />355 <path d="M-10 320 L410 300" strokeWidth="6" />356 <path d="M60 -10 L40 560" strokeWidth="5" />357 <path d="M210 -10 L230 560" strokeWidth="5" />358 </g>359 <g fill="none" stroke="#F6C445" strokeLinecap="round">360 <path d="M-10 200 C100 180 200 260 410 220" strokeWidth="5" />361 </g>362 <path d="M120 -10 C140 200 100 380 180 560" fill="none" stroke="#E5484D" strokeWidth="3" strokeDasharray="1 7" strokeLinecap="round" />363 <g fontFamily="ui-sans-serif,-apple-system,Inter,sans-serif" fontWeight="600" fill="#6B6B76" fontSize="10" letterSpacing="0.06em">364 <text x="20" y="250">ALFAMA</text>365 <text x="240" y="380">BAIXA</text>366 <text x="40" y="640" fill="#3B82C4">RIO TEJO</text>367 </g>368 </svg>369 );370}371 372const F = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;373const D = `"SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",Roboto,sans-serif`;374const demoCss = `375.fd-gshd,.fd-gshd *,.fd-gshd *::before,.fd-gshd *::after{box-sizing:border-box}376.fd-gshd{min-height:100vh;width:100%;margin:0;padding:28px 16px;display:flex;align-items:center;justify-content:center;377 background:radial-gradient(900px 600px at 20% 10%,#E6F0FF,transparent 60%),radial-gradient(900px 600px at 90% 90%,#FFEFE0,transparent 60%),#F2F2F7;color:#0B0B0F;font:400 15px/1.4 ${F};letter-spacing:normal}378.fd-gshd-device{position:relative;width:390px;max-width:100%;height:min(820px,calc(100vh - 56px));min-height:620px;margin:0;border-radius:52px;overflow:hidden;background:#EDE9E0;379 box-shadow:0 0 0 10px #0B0B0F,0 0 0 11px #2A2A31,0 50px 100px -40px rgba(20,20,40,0.55)}380.fd-gshd-map{position:absolute;inset:0;display:block;width:100%;height:100%}381.fd-gshd-pin{position:absolute;margin:0;padding:0;transform:translate(-50%,-100%)}382.fd-gshd-pin span{display:block;margin:0;padding:7px 11px;border-radius:999px;background:#0B0B0F;color:#fff;font:700 13px/1 ${F};box-shadow:0 6px 16px -4px rgba(0,0,0,0.4)}383.fd-gshd-pin::after{content:"";display:block;width:10px;height:10px;margin:-5px auto 0;background:#0B0B0F;transform:rotate(45deg);border-radius:0 0 2px 0}384.fd-gshd-pin.is-dim span{background:#fff;color:#0B0B0F}385.fd-gshd-pin.is-dim::after{background:#fff}386.fd-gshd-head{display:flex;align-items:center;gap:12px;margin:2px 0 0;padding:0}387.fd-gshd-thumb{flex:none;width:52px;height:52px;margin:0;border-radius:14px;background:388 linear-gradient(#fff 0 0) 20% 55%/16% 22% no-repeat,linear-gradient(#fff 0 0) 60% 50%/16% 26% no-repeat,389 linear-gradient(180deg,#FFB86B,#E5484D 60%,#6E2B5A)}390.fd-gshd-titles{flex:1;min-width:0;margin:0;padding:0}391.fd-gshd-title{margin:0;font:700 1.375rem/1.15 ${D};letter-spacing:-0.02em;color:inherit}392.fd-gshd-sub{margin:2px 0 0;font:500 13px/1.3 ${F};color:#6B6B76;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}393.fd-gshd-x{flex:none;display:grid;place-items:center;width:30px;height:30px;margin:0;padding:0;border:0;border-radius:50%;background:rgba(120,120,128,0.16);color:#6B6B76;cursor:pointer;-webkit-appearance:none;appearance:none}394.fd-gshd-x svg{display:block}395.fd-gshd-x:focus-visible,.fd-gshd-room:focus-visible,.fd-gshd-cta:focus-visible{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:2px}396.fd-gshd-chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 18px;padding:0}397.fd-gshd-chip{display:inline-block;margin:0;padding:7px 11px;border-radius:999px;background:rgba(120,120,128,0.12);font:600 13px/1 ${F};color:#1C1C22}398.fd-gshd-h{margin:0 0 10px;font:700 17px/1.2 ${F};letter-spacing:-0.01em;color:inherit}399.fd-gshd-rooms{list-style:none;margin:0 0 14px;padding:0;display:flex;flex-direction:column;gap:8px}400.fd-gshd-rooms li{margin:0;padding:0}401.fd-gshd-room{display:flex;align-items:center;gap:12px;width:100%;margin:0;padding:10px;border:0;border-radius:20px;background:rgba(255,255,255,0.62);color:#0B0B0F;text-align:left;cursor:pointer;-webkit-appearance:none;appearance:none;402 font:inherit;box-shadow:inset 0 0 0 1px rgba(0,0,0,0.05);transition:box-shadow 240ms cubic-bezier(0.22,1,0.36,1),background-color 240ms}403.fd-gshd-room[aria-checked="true"]{background:#fff;box-shadow:inset 0 0 0 2px #007AFF}404.fd-gshd-room-img{flex:none;width:56px;height:56px;border-radius:12px;display:block}405.fd-gshd-room-txt{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}406.fd-gshd-room-txt b{font:650 15px/1.25 ${F};letter-spacing:-0.01em}407.fd-gshd-room-txt span{font:400 12px/1.35 ${F};color:#6B6B76}408.fd-gshd-room-p{flex:none;font:700 17px/1 ${F};text-align:right}409.fd-gshd-room-p small{display:block;margin-top:3px;font:500 11px/1 ${F};color:#6B6B76}410.fd-gshd-cta{display:block;width:100%;height:52px;margin:0 0 22px;padding:0;border:0;border-radius:999px;background:#007AFF;color:#fff;font:600 17px/1 ${F};letter-spacing:-0.01em;cursor:pointer;-webkit-appearance:none;appearance:none;box-shadow:0 10px 24px -10px rgba(0,122,255,0.7)}411.fd-gshd-p{margin:0 0 12px;font:400 15px/1.5 ${F};color:#3A3A44}412@media (max-width:480px){413 .fd-gshd{padding:0}414 .fd-gshd-device{width:100%;height:100vh;min-height:0;border-radius:0;box-shadow:none}415}416`;The material itself: a lens that refracts what’s behind it, with squircle bevel, specular rim, dispersion and spring physics.
glass, glassProminent, clear and clear-prominent capsules in four sizes, with a springy press and a glow from the touch point.
Floating iOS 26 tab bar: a liquid lens springs between tabs, search sits apart, and it minimizes as you scroll.