← ui

ground

ui

controls that read the picture behind them and take the scheme it calls for

npx shadcn add @ag/ground
Already installed? Preview an update
npx shadcn add @ag/ground --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-05
  • Changed (breaking): backdrop is ground, the picture a surface stands on (a backdrop is a title's wide picture in the media words): Backdrop, BackdropGroup, BackdropFit and useBackdropSource are Ground, GroundGroup, GroundFit and useGroundSource, lib/backdrop-core is lib/ground-core (BACKDROP is GROUND), and the data slots ground and ground-group. Migration: shadcn add @ag/ground, delete backdrop.tsx and backdrop-core.ts, rename the imports.
Updated 2026-11-05 · release notes

also called auto contrast

shelf glass & surfaces

uses material

used by video

the demo above, verbatim

ground.demo.tsx
"use client"

import { Pause, Play, SkipForward } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { Badge } from "@/registry/base-nova/ui/badge"
import { Button } from "@/registry/base-nova/ui/button"
import { Card } from "@/registry/base-nova/ui/card"
import { AdaptiveGlass, Ground } from "@/registry/base-nova/ui/ground"
import { Gallery } from "@/registry/base-nova/ui/liquid-glass.demo"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
  VideoPicture,
  VideoPlayback,
} from "@/registry/base-nova/ui/video-playback"

// #region drag
/** A surface the hand moves: dragged by the pointer, or focused and moved by the
 *  arrows (shift for larger steps). A press on a control inside stays the control's. */
function Draggable({
  label,
  className,
  children,
}: {
  label: string
  className?: string
  children: React.ReactNode
}) {
  const [at, setAt] = React.useState({ x: 0, y: 0 })
  const grip = React.useRef<{ x: number; y: number } | null>(null)
  return (
    <div
      role="application"
      aria-roledescription="movable card"
      aria-label={`${label}, arrows move it`}
      // biome-ignore lint/a11y/noNoninteractiveTabindex: an application the arrows move takes focus
      tabIndex={0}
      className={cn(
        "absolute cursor-grab touch-none select-none rounded-3xl active:cursor-grabbing",
        className,
      )}
      // Moved by its margin, never a transform: a transform makes a stacking
      // context, which a fluid glass's blended words then isolate, and its frost
      // would see nothing of the picture outside it.
      style={{ marginLeft: at.x, marginTop: at.y }}
      onPointerDown={(e) => {
        if ((e.target as HTMLElement).closest("button")) return
        e.currentTarget.setPointerCapture(e.pointerId)
        grip.current = { x: e.clientX - at.x, y: e.clientY - at.y }
      }}
      onPointerMove={(e) => {
        const g = grip.current
        if (g) setAt({ x: e.clientX - g.x, y: e.clientY - g.y })
      }}
      onPointerUp={() => {
        grip.current = null
      }}
      onKeyDown={(e) => {
        const step = e.shiftKey ? 48 : 12
        const by: Record<string, [number, number]> = {
          ArrowLeft: [-step, 0],
          ArrowRight: [step, 0],
          ArrowUp: [0, -step],
          ArrowDown: [0, step],
        }
        const d = by[e.key]
        if (!d) return
        e.preventDefault()
        setAt((p) => ({ x: p.x + d[0], y: p.y + d[1] }))
      }}
    >
      {children}
    </div>
  )
}
// #endregion

export default function GroundDemo() {
  return (
    <div className="space-y-12">
      <Sample
        name="photographs"
        title="photographs gliding past"
        description="three photographs as one source (useGroundSource over the three images), each piece of glass read by the one under it as they glide; a piece over two of them mid-glide is read across both. The same gallery is liquid-glass's first example, where dark and light are beside adaptive to compare"
      >
        <Gallery />
      </Sample>
      <Sample
        name="moving"
        title="over a moving picture"
        description="a video is a source by itself: each corner is read by the frame under it as the film plays, a white title card, then grass and sky. A visible control waits a second of the same picture before it flips, so a cut never strobes it"
      >
        <MaterialProvider material="glass">
          <VideoPlayback
            src="/bunny.mp4"
            poster="/bunny-poster.jpg"
            width={1280}
            height={720}
            label="Big Buck Bunny"
            play="visible"
            muted
            loop
          >
            <VideoPicture>
              <div className="absolute inset-x-4 top-4 flex items-center justify-between">
                <Badge>top left</Badge>
                <Badge>top right</Badge>
              </div>
              <div className="absolute inset-x-4 bottom-4 flex items-center justify-between">
                <Button size="sm">bottom left</Button>
                <Button size="sm">bottom right</Button>
              </div>
            </VideoPicture>
          </VideoPlayback>
        </MaterialProvider>
      </Sample>
      <Sample
        name="sky-and-ground"
        title="drag it over the picture"
        description="drag the two cards (or focus one and use the arrows). The panel chooses a scheme by the part of the picture under it: light glass over the sky, dark over the ground, thicker glass across the horizon. The fluid card chooses none: it is the picture under it, frosted and pushed light or dark along the picture's own shapes, its words inverted against it"
        with="drag"
      >
        <MaterialProvider material="glass">
          <Ground src="/lightbox/wide.jpg" className="h-[28rem] rounded-xl">
            <div className="flex items-center gap-2 p-4">
              <Button>over the sky</Button>
              <Button variant="secondary" size="icon" aria-label="play">
                <Play />
              </Button>
              <Badge>light</Badge>
            </div>
            <div className="absolute right-4 bottom-4 flex items-center gap-2">
              <Badge>dark</Badge>
              <Button variant="secondary" size="icon" aria-label="pause">
                <Pause />
              </Button>
              <Button>over the ground</Button>
            </div>
            <Draggable label="adaptive card" className="top-16 left-[30%]">
              <Card className="w-56 gap-2 p-4 text-sm">
                <p className="font-medium">adaptive</p>
                <p className="opacity-75">
                  one scheme, read from the picture under the whole card
                </p>
                <Button
                  className="mt-1 self-start"
                  size="sm"
                  icon={<SkipForward />}
                >
                  next
                </Button>
              </Card>
            </Draggable>
            <Draggable label="fluid card" className="top-40 left-[58%]">
              <AdaptiveGlass
                fluid
                shape="surface"
                className="flex w-56 flex-col gap-2 p-4 text-sm [--glass-radius:--theme(--radius-3xl)]"
              >
                <p className="font-medium">fluid</p>
                <p className="opacity-80">
                  no scheme: dark words where the glass is light, light where it
                  is dark
                </p>
              </AdaptiveGlass>
            </Draggable>
          </Ground>
        </MaterialProvider>
      </Sample>
      <Sample
        name="pinned"
        glance
        title="a scheme named wins"
        description="scheme on a control, or on a provider, pins it whatever is under it"
      >
        <MaterialProvider material="glass">
          <Ground src="/lightbox/square.jpg" className="h-52 rounded-xl">
            <div className="flex flex-wrap items-center gap-2 p-4">
              <Button>read</Button>
              <Button scheme="dark">pinned dark</Button>
              <Button scheme="light">pinned light</Button>
            </div>
          </Ground>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

ground.tsx
"use client"

// THE GROUND, READ: the picture behind the surfaces (a page's, a section's, a player's
// frame), the thing glass stands on. What holds pixels (a video, a picture) answers
// `scheme="ground"` for every surface over it, each by the part of the picture under
// it (lib/ground-core: a frame sampled into a small grid of lightness, a verdict per
// surface with memory, so a cut is held before a visible surface flips and a strobe
// flips nothing).
//
//   <Ground src="/peaks.jpg">            a picture behind a section
//     <Button material="glass">go</Button>   light glass over the sky, dark over rock
//   </Ground>
//
// VideoPicture makes its own (a player's chrome adapts with nothing said). A source
// samples only while it is on screen and its document visible: a playing video on its
// own frames (about every one), a picture once it has loaded, a canvas when its owner
// says it redrew (`refresh`). Where the surfaces stand is looked at the frame anything
// moves (a sample, a scroll anywhere, a resize) and when a held change's hold ends;
// the change itself eases (the glass's transition), so it follows the picture rather
// than jumping after it. Reading the pixels needs them readable: same origin, or
// served with CORS and `crossOrigin` set; a picture that is neither screams at its
// first sample.

import * as React from "react"
import { cn } from "@/lib/utils"
import {
  type Cell,
  cellsUnder,
  contentBox,
  GROUND,
  type Grid,
  gridOf,
  judge,
  oklchGrain,
  reading,
  sheenOf,
  tintFor,
  underRim,
  type Verdict,
} from "@/registry/base-nova/lib/ground-core"
import {
  GroundScope,
  type GroundSource,
  MaterialProvider,
  type Scheme,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import {
  Glass,
  type GlassProps,
  type GlassStyle,
  glassShapes,
  glassStyle,
  glassVariants,
} from "@/registry/base-nova/ui/material-glass"

/** What can be read: a video, a picture, or a canvas something draws into (a
 *  comparison stage, a chart). A canvas says nothing when it redraws, so its owner
 *  calls the source's `refresh` after drawing. */
type Media = HTMLVideoElement | HTMLImageElement | HTMLCanvasElement
export type GroundFit = "contain" | "cover" | "fill"

/** A playing video's frames, read at most this often (ms): about every frame of a
 *  film, so the glass answers a cut while it is the cut. */
const SAMPLE_MS = 33

/** The sampling canvas: each cell of the grid `GROUND.fine` samples a side. */
const W = GROUND.cols * GROUND.fine
const H = GROUND.rows * GROUND.fine

interface Watcher {
  element: HTMLElement | null
  onChange: (scheme: "light" | "dark") => void
  verdict: Verdict | null
}

const naturalOf = (m: Media) =>
  m instanceof HTMLVideoElement
    ? { width: m.videoWidth, height: m.videoHeight }
    : m instanceof HTMLCanvasElement
      ? { width: m.width, height: m.height }
      : { width: m.naturalWidth, height: m.naturalHeight }

const ready = (m: Media) =>
  m instanceof HTMLVideoElement
    ? m.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
    : m instanceof HTMLCanvasElement
      ? m.width > 0 && m.height > 0
      : m.complete && m.naturalWidth > 0

/** A rectangle on the screen, px. */
interface Box {
  left: number
  top: number
  width: number
  height: number
}

/** Where a medium's picture stands on the screen: its element's box, cut to its
 *  content as `object-fit` places it. */
const pictureBox = (m: Media, fit: GroundFit): Box => {
  const box = m.getBoundingClientRect()
  const c = contentBox(box, naturalOf(m), fit)
  return {
    left: box.left + c.x,
    top: box.top + c.y,
    width: c.width,
    height: c.height,
  }
}

/** Sets a surface's custom property only when its value changed: the source looks
 *  every frame anything moves (a glide, a drag), and each write restyles the glass
 *  and restarts what it eases, though most frames move a value by less than the eye
 *  could see. Values are written at the grain they are seen at (`grain`). */
const write = (element: HTMLElement | null, name: string, value: string) => {
  if (element && element.style.getPropertyValue(name) !== value)
    element.style.setProperty(name, value)
}
/** Two decimals for a multiple (a thickness, a sheen); a colour in perceptual steps
 *  (`oklchGrain`): steps of 4 of 255 in sRGB were far finer than the eye in the
 *  light and changed on nearly every frame of a glide. */
const grain = {
  multiple: (v: number) => v.toFixed(2),
  color: oklchGrain,
}

/** The box round every picture: what a surface with no element of its own stands
 *  over. */
const union = (pictures: { box: Box }[]): Box => {
  const left = Math.min(...pictures.map((p) => p.box.left))
  const top = Math.min(...pictures.map((p) => p.box.top))
  const right = Math.max(...pictures.map((p) => p.box.left + p.box.width))
  const bottom = Math.max(...pictures.map((p) => p.box.top + p.box.height))
  return { left, top, width: right - left, height: bottom - top }
}

/** The same media, in the same order: a list handed in anew each render is not a
 *  change. */
const sameMedia = (a: readonly Media[], b: readonly Media[]) =>
  a.length === b.length && a.every((m, i) => m === b[i])

/** The source over `media` (one picture or video, or several: photographs gliding
 *  under the same controls): hand it to <GroundScope>. `fit` is how each fills its
 *  element (`object-fit`); `outside` the lightness of what shows where none does
 *  (L*, 0: a black stage). A surface over two of them is read across both.
 *  `read` is whether the pictures have been read yet: until then the glass over
 *  them wears its unread scheme, so a picture its owner draws should show only from
 *  then, fading in while the glass eases to it (<Ground> does), never be seen
 *  first with the glass catching up a beat later. */
export function useGroundSource(
  media: Media | readonly Media[] | null,
  { fit, outside = 0 }: { fit: GroundFit; outside?: number },
): GroundSource & { refresh(): void; read: boolean } {
  const [read, setRead] = React.useState(false)
  const [state] = React.useState(() => ({
    fit,
    outside,
    grids: new Map<Media, Grid>(),
    watchers: new Set<Watcher>(),
    canvas: null as CanvasRenderingContext2D | null,
    /** The look asked for on the next frame, and the one a hold's end waits for. */
    asked: 0,
    held: 0,
  }))
  state.fit = fit
  state.outside = outside
  const given: readonly Media[] =
    media === null ? [] : "length" in media ? media : [media]
  const kept = React.useRef<readonly Media[]>(given)
  if (!sameMedia(kept.current, given)) kept.current = given
  const list = kept.current

  const look = React.useCallback(() => {
    window.clearTimeout(state.held)
    if (state.grids.size === 0) return
    const pictures = [...state.grids].map(([m, grid]) => ({
      grid,
      box: pictureBox(m, state.fit),
    }))
    // A box on the screen read across every picture under it.
    const readBox = (r: Box) => {
      const cells: Cell[] = []
      for (const { grid, box } of pictures) {
        if (box.width === 0 || box.height === 0) continue
        const area = {
          x: (r.left - box.left) / box.width,
          y: (r.top - box.top) / box.height,
          w: r.width / box.width,
          h: r.height / box.height,
        }
        const px = box.width * box.height
        for (const c of cellsUnder(grid, area))
          cells.push({ ...c, weight: c.weight * px })
      }
      return reading(cells, r.width * r.height, state.outside)
    }
    const now = performance.now()
    let wake = Number.POSITIVE_INFINITY
    for (const w of state.watchers) {
      // Unset, the surface is the whole of what is shown: the pictures' union.
      const r: Box = w.element?.getBoundingClientRect() ?? union(pictures)
      if (r.width === 0 || r.height === 0) continue
      const read = readBox(r)
      const visible =
        !w.element ||
        w.element.checkVisibility({
          opacityProperty: true,
          visibilityProperty: true,
        })
      const next = judge(w.verdict, read, now, visible)
      const thickness = tintFor(next.worn, read)
      // The rim reads against what is right under it, the picture seen through the
      // glass's face: less light over dark, more over light (inherited by what is
      // inside a group). Continuous; it eases.
      write(
        w.element,
        "--glass-ground-sheen",
        grain.multiple(sheenOf(underRim(next.worn, read, thickness))),
      )
      // And picks up the picture's colour under it (`glassLight` leans the rim's
      // light toward it).
      write(w.element, "--glass-ground", grain.color(read.color))
      // As thick as its words need over what is under it, no thicker (eased by the
      // glass's own transition): the face's tint at this opacity.
      write(w.element, "--glass-thickness", grain.multiple(thickness))
      if (!w.verdict || next.worn !== w.verdict.worn) w.onChange(next.worn)
      w.verdict = next
      if (next.pending) wake = Math.min(wake, next.since + GROUND.holdMs - now)
    }
    // A change waiting out its hold is looked at again the moment the hold ends, a
    // still picture sending nothing else.
    if (wake < Number.POSITIVE_INFINITY)
      state.held = window.setTimeout(look, Math.max(0, wake) + 1)
  }, [state])

  // Asked for by whatever moved (a frame, a scroll, a redraw), at most once a frame.
  const soon = React.useCallback(() => {
    if (state.asked) return
    state.asked = requestAnimationFrame(() => {
      state.asked = 0
      look()
    })
  }, [state, look])

  const sample = React.useCallback(
    (m: Media) => {
      if (!ready(m)) return
      if (!state.canvas) {
        const canvas = document.createElement("canvas")
        canvas.width = W
        canvas.height = H
        // The frame is scaled down where it lives (the GPU) and only the grid's few
        // thousand samples come back: read every frame, it costs a fraction of a ms.
        state.canvas = canvas.getContext("2d")
        if (!state.canvas)
          throw new Error("ground: no 2d canvas to sample into")
      }
      const ctx = state.canvas
      ctx.drawImage(m, 0, 0, W, H)
      let pixels: ImageData
      try {
        pixels = ctx.getImageData(0, 0, W, H)
      } catch (e) {
        throw new Error(
          `ground: the pixels of ${m instanceof HTMLCanvasElement ? "a canvas holding another origin's picture" : m.currentSrc || "the picture"} cannot be read (another origin without CORS: serve it with Access-Control-Allow-Origin and set crossOrigin, or name a scheme)`,
          { cause: e },
        )
      }
      state.grids.set(
        m,
        gridOf(pixels.data, GROUND.cols, GROUND.rows, GROUND.fine),
      )
      // Read now: told in the same pass as the surfaces' first verdicts (looked at
      // right here, not a frame later), so a picture shown from `read` and the
      // glass easing to its reading start on one frame.
      setRead(true)
      look()
    },
    [state, look],
  )

  React.useEffect(() => {
    state.grids.clear()
    if (list.length === 0) return
    const onScreen = new Set<Media>()
    const lastSample = new Map<Media, number>()
    const frames = new Map<HTMLVideoElement, number>()
    const awake = () =>
      onScreen.size > 0 && document.visibilityState === "visible"
    // A playing video is read on its own frames, at most every SAMPLE_MS.
    const onFrame = (v: HTMLVideoElement) => () => {
      const now = performance.now()
      if (awake() && now - (lastSample.get(v) ?? 0) >= SAMPLE_MS) {
        lastSample.set(v, now)
        sample(v)
      }
      if (!v.paused) frames.set(v, v.requestVideoFrameCallback(onFrame(v)))
    }
    // Looked at only when something can have changed: a scroll anywhere
    // (photographs gliding under the controls, the page moving them), a hand moving a
    // surface, a resize, and a transition or an animation starting or ending (chrome
    // rising on a transform says so by no other event). A still picture under still
    // controls costs nothing.
    const onMove = () => {
      if (awake()) soon()
    }
    const MOVES = [
      "scroll",
      "transitionstart",
      "transitionend",
      "animationstart",
      "animationend",
    ] as const
    const start = () => {
      if (!awake()) return
      for (const m of list) {
        if (m instanceof HTMLVideoElement && !m.paused) {
          m.cancelVideoFrameCallback(frames.get(m) ?? 0)
          frames.set(m, m.requestVideoFrameCallback(onFrame(m)))
        }
        sample(m)
      }
    }
    const seen = new IntersectionObserver((entries) => {
      for (const e of entries)
        e.isIntersecting
          ? onScreen.add(e.target as Media)
          : onScreen.delete(e.target as Media)
      start()
    })
    const events = (m: Media) =>
      m instanceof HTMLVideoElement
        ? ["loadeddata", "seeked", "play", "pause"]
        : m instanceof HTMLCanvasElement
          ? []
          : ["load"]
    for (const m of list) {
      seen.observe(m)
      for (const e of events(m)) m.addEventListener(e, start)
    }
    document.addEventListener("visibilitychange", start)
    for (const e of MOVES)
      document.addEventListener(e, onMove, { capture: true, passive: true })
    // A hand moving a surface (a panel dragged over the picture, chrome rising under
    // the pointer) is looked at the frame it moves.
    document.addEventListener("pointermove", onMove, { passive: true })
    window.addEventListener("resize", soon)
    return () => {
      seen.disconnect()
      for (const m of list) {
        for (const e of events(m)) m.removeEventListener(e, start)
        if (m instanceof HTMLVideoElement)
          m.cancelVideoFrameCallback(frames.get(m) ?? 0)
      }
      document.removeEventListener("visibilitychange", start)
      for (const e of MOVES)
        document.removeEventListener(e, onMove, { capture: true })
      document.removeEventListener("pointermove", onMove)
      window.removeEventListener("resize", soon)
      window.clearTimeout(state.held)
      cancelAnimationFrame(state.asked)
      state.asked = 0
    }
  }, [list, state, sample, soon])

  return React.useMemo(
    () => ({
      watch(element, onChange) {
        const w: Watcher = { element, onChange, verdict: null }
        state.watchers.add(w)
        look()
        return () => {
          state.watchers.delete(w)
          element?.style.removeProperty("--glass-thickness")
          element?.style.removeProperty("--glass-ground-sheen")
          element?.style.removeProperty("--glass-ground")
        }
      },
      // A canvas's owner says it redrew: it is read again, and its surfaces looked at.
      refresh() {
        for (const m of kept.current)
          if (m instanceof HTMLCanvasElement) sample(m)
      },
      read,
    }),
    [state, look, sample, read],
  )
}

/** A FLUID glass, which chooses no scheme: the glass is the picture under it, read
 *  by the compositor pixel by pixel (`backdrop-filter`), blurred into frost, its
 *  colour mostly drained and its contrast pushed, so it is light where the picture
 *  is light and dark where it is dark, following the picture's own shapes (a sky,
 *  a river, a face) rather than any band of them; the words are white blended by
 *  `difference` against it, dark on the light parts and light on the dark, inside
 *  one word. Nothing is sampled: it follows anything, a playing video and the page
 *  itself included, on the frame it moves.
 *
 *  The frost is a LAYER of its own and the words its siblings, never its children:
 *  a backdrop-filter makes its element an isolated group, and words inside it
 *  blended against the glass's own (transparent) background, not the frosted
 *  picture, so they stayed white on white glass. Beside it, they blend against
 *  what is painted under them: the picture and the frost. So the host draws no
 *  glass and makes no stacking context. Its words must be elements (a span, an
 *  icon): a blend applies to an element, not to bare text. And nothing between it
 *  and the picture may isolate (a transform, opacity, a filter): the blending words
 *  make the nearest such ancestor a layer of its own, and the frost then sees only
 *  what is inside that layer, not the picture (a card dragged by `translate` lost
 *  its frost; by its margin it keeps it). The host is glass's shape alone
 *  (`glassShapes`); its light (`rim`, `tint`) is set on it and the frost inherits it. */
const FLUID_HOST =
  "relative rounded-(--glass-radius) text-white [&>*:not([data-slot=glass-frost])]:relative [&>*:not([data-slot=glass-frost])]:mix-blend-difference"
// Over the frost, a veil of white: the contrast leaves the dark parts black and heavy
// ("too dark and too much contrast", Adrian, 2026-10-11), and a filter cannot lift
// black (brightness multiplies it); a veil adds light to every pixel, black becoming
// a deep grey and white staying white, so the words keep their contrast.
const FLUID_FROST = cn(
  glassVariants({ shape: "surface" }),
  // The host's glass, drawn on a layer of its own: its rim is the host's.
  "pointer-events-none absolute inset-0 [--glass-radius:inherit] [--glass-rim:inherit] [--glass-tint:rgb(255_255_255/0.14)]",
)
/** The frost, in the order it needs: blurred, then its colour drained, THEN its
 *  contrast pushed, so contrast acts on lightness alone. Tailwind's composed filter
 *  pushes contrast before saturation, and a blue sky's channels clipped to cyan (the
 *  words, white minus cyan, turned red): "burned" (Adrian, 2026-10-11). Neutral, the
 *  words blended against it are neutral too, black on the light glass, white on the
 *  dark. It is the frost's `--glass-backdrop`, the glass's one ground channel (a
 *  whole filter in its order), set on its style so it holds on either face. */
// Contrast high enough that the frost is nearly black or nearly white, the mid grey
// (where the words, inverted against it, turned the same grey and read as disabled)
// left to the soft edge between them; drained first, it never burns.
const FLUID_FILTER = "blur(16px) grayscale(1) contrast(3.4)"
const FROST_STYLE: GlassStyle = { "--glass-backdrop": FLUID_FILTER }

/** `Glass` (server-safe, told its scheme) as a surface that reads its own: the
 *  scheme it is given or its provider's, `ground` answered by the picture under
 *  this element. For a piece of glass of your own over a picture (a header pill, an
 *  avatar's ring); every base control already does this itself.
 *
 *  `fluid` (EXPERIMENTAL) chooses no scheme at all and needs no source: see FLUID. */
export function AdaptiveGlass<T extends React.ElementType = "div">({
  scheme,
  fluid = false,
  ref,
  className,
  ...props
}: Omit<GlassProps<T>, "scheme"> & { scheme?: Scheme; fluid?: boolean }) {
  const [s, here] = useSurfaceScheme<HTMLElement>(
    fluid ? "auto" : scheme,
    ref as React.Ref<HTMLElement> | undefined,
  )
  if (!fluid)
    return (
      <Glass
        {...(props as GlassProps<T>)}
        ref={here}
        scheme={s}
        className={className}
      />
    )
  const {
    as,
    shape = "card",
    rim,
    rimWidth,
    rimColor,
    tint,
    tintAmount,
    style,
    children,
    ...rest
  } = props as Omit<GlassProps<"div">, "as" | "scheme"> & {
    as?: React.ElementType
  }
  return React.createElement(
    as ?? "div",
    {
      ...(as === "button" ? { type: "button" } : {}),
      ...rest,
      ref: here,
      "data-slot": "glass",
      "data-material": "glass",
      "data-shape": shape,
      "data-scheme": s,
      "data-fluid": true,
      style: {
        ...glassStyle({ rim, rimWidth, rimColor, tint, tintAmount }),
        ...style,
      },
      className: cn(glassShapes[shape], FLUID_HOST, className),
    },
    <span
      aria-hidden
      data-slot="glass-frost"
      className={FLUID_FROST}
      style={FROST_STYLE}
    />,
    children,
  )
}

/** Controls that read as ONE (a row of a gallery's buttons, a player's transport)
 *  read the picture as one: the band under the group decides one scheme for every
 *  piece in it. Read one by one, a pill over the sky turned light beside circles over
 *  rock that stayed dark, and the row looked broken. Its glass's thickness and rim
 *  are the band's too (set on the group, inherited by every glass in it). */
export function GroundGroup({
  scheme,
  ref,
  children,
  ...props
}: React.ComponentProps<"div"> & { scheme?: Scheme }) {
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  return (
    <div ref={here} data-slot="ground-group" {...props}>
      <MaterialProvider scheme={s}>{children}</MaterialProvider>
    </div>
  )
}

/** A picture behind a section, read: every control inside wears the scheme the
 *  picture under it asks for (`scheme="ground"`), glass over the sky light, over
 *  the rock dark. The picture fills the box (`fit`, `cover` by default); it is
 *  decoration, hidden from assistive technology. It shows once it has been read,
 *  fading in over the glass's own 300 ms while the glass eases to its reading, one
 *  movement: shown on load, it stood a beat under glass still wearing the scheme of
 *  the empty page, which then snapped ("abrupt and too lagged", Adrian,
 *  2026-10-11). */
export function Ground({
  src,
  fit = "cover",
  crossOrigin,
  className,
  children,
}: {
  src: string
  fit?: GroundFit
  /** For a picture on another origin, served with CORS. */
  crossOrigin?: "anonymous" | "use-credentials"
  className?: string
  children?: React.ReactNode
}) {
  const [picture, setPicture] = React.useState<HTMLImageElement | null>(null)
  const source = useGroundSource(picture, { fit })
  return (
    <div
      data-slot="ground"
      className={cn("relative isolate overflow-hidden", className)}
    >
      {/* biome-ignore lint/performance/noImgElement: the picture is read pixel by pixel, a plain element any React app has */}
      <img
        ref={setPicture}
        src={src}
        alt=""
        aria-hidden
        crossOrigin={crossOrigin}
        decoding="async"
        className={cn(
          "-z-10 pointer-events-none absolute inset-0 size-full transition-opacity duration-300 ease-out motion-reduce:transition-none",
          source.read ? "opacity-100" : "opacity-0",
          fit === "cover"
            ? "object-cover"
            : fit === "contain"
              ? "object-contain"
              : "object-fill",
        )}
      />
      <GroundScope source={source}>
        <MaterialProvider scheme="ground">{children}</MaterialProvider>
      </GroundScope>
    </div>
  )
}
ground-core.ts
// WHAT THE PIXELS BEHIND A SURFACE ASK FOR: light ink or dark, read off the picture it
// stands over. Framework-free arithmetic; the side holding the pixels (ui/ground:
// a video, a picture) samples a frame into a small grid and asks here, per surface.
//
// The unit is the one contrast is felt in: CIE lightness L* (0 black, 100 white,
// 50 a perceptual mid-grey), never a raw channel average, which calls a saturated
// blue sky dark. A surface's area of the frame gives its mean lightness and the band
// most of it lies in (the 10th to the 90th percentile of the cells under it): a light
// frame wants the light scheme (light glass, dark words), a dark one the dark scheme.
// And the glass is as thick as its words need and no thicker (`tintFor`): dark glass
// enough to darken the BRIGHTEST part under it for white words, light glass enough to
// lighten the darkest for dark ones. A fixed thickening for any surface over light
// and dark at once overshot (82% black over a rock its 50% already darkened enough).
// The area's mean colour, averaged in linear light, is what the glass's edge picks
// up from what it stands over (light catching a curve takes the colour around it).
//
// A verdict has memory. Between `darkBelow` and `lightAbove` the scheme it has is
// kept (a mid-grey frame never flickers), and a change it can be SEEN making waits
// for the frame to hold it `holdMs`: a cut, a flash, a pan across a window. A surface
// no one can see (chrome hidden while a video plays) changes at once, so it shows
// up already right.

export type Worn = "light" | "dark"

/** A frame as lightness and colour, one value per cell, rows top to bottom. */
export interface Grid {
  cols: number
  rows: number
  /** L* per cell, 0..100. */
  l: Float32Array
  /** Linear-light RGB per cell, 0..1, three values each: colours are averaged in
   *  light, as the eye mixes them, never in sRGB's encoded numbers (which darken a
   *  mix). */
  rgb: Float32Array
  /** The darkest and the brightest of each cell's `GROUND.fine` × `fine` samples,
   *  L*: what a glass's words can stand over inside the cell, which its mean hides. */
  lo: Float32Array
  hi: Float32Array
}

/** A colour, sRGB 0..255. */
export type Rgb = readonly [number, number, number]

/** A part of the frame, in shares of its width and height (0..1, may run past). */
export interface Area {
  x: number
  y: number
  w: number
  h: number
}

/** What an area of the frame is like: its mean lightness, its darkest and brightest
 *  cell, and its mean colour (what an edge of glass over it picks up). */
export interface Reading {
  mean: number
  low: number
  high: number
  color: Rgb
}

export const GROUND = {
  /** The grid a frame is sampled into: enough for a control's area to span cells,
   *  little enough to read every frame. */
  cols: 32,
  rows: 18,
  /** Samples per side of each cell, read for its darkest and brightest part: a cell
   *  of a wide picture spans 40 px, and its mean let a bright cliff inside it through
   *  dark glass at 3.5:1, where the glass's own blur (6 to 14 px) shows the cliff
   *  nearly whole. Four a side is a sample about the blur's size. */
  fine: 4,
  /** Over this the light scheme; under `darkBelow` the dark one; between, kept. */
  lightAbove: 62,
  darkBelow: 48,
  /** How long a frame must hold a new scheme before a visible surface takes it:
   *  longer than a flash or a strobe's half period (each turn back resets it), short
   *  enough that a cut is answered while it is still the cut. The change itself then
   *  eases (the glass's own transition), so the hold is the only lag. A second felt
   *  "very slow and too lagged" (Adrian, 2026-10-11). */
  holdMs: 180,
} as const

/** One sRGB channel, 0..255, to linear light, 0..1. */
const linear = (c: number) => {
  const v = c / 255
  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
/** Linear light, 0..1, to an sRGB channel, 0..255. */
const encoded = (v: number) =>
  255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)

/** Luminance (linear) to CIE L*. */
const lOf = (y: number) =>
  y > 216 / 24389 ? 116 * Math.cbrt(y) - 16 : (24389 / 27) * y
/** CIE L* to luminance (linear): the grey of that lightness. */
const yOf = (l: number) => (l > 8 ? ((l + 16) / 116) ** 3 : l / (24389 / 27))

/** sRGB 0..255 to CIE L*. */
export function lightness(r: number, g: number, b: number): number {
  return lOf(0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b))
}

/** sRGB 0..255 to OKLCH: lightness 0..1, chroma, hue in degrees (0 for a grey). */
export function oklchOf([r, g, b]: Rgb): { l: number; c: number; h: number } {
  const [lr, lg, lb] = [linear(r), linear(g), linear(b)]
  const l_ = Math.cbrt(
    0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb,
  )
  const m_ = Math.cbrt(
    0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb,
  )
  const s_ = Math.cbrt(
    0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb,
  )
  const l = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_
  const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_
  const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_
  const c = Math.hypot(a, bb)
  const h = c === 0 ? 0 : ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360
  return { l, c, h }
}

/** A colour at the grain the eye tells apart, as CSS: OKLCH in steps of one
 *  just-noticeable difference (about 0.02 in OKLab) in lightness and chroma, and of
 *  10° in hue (at the most vivid ground a picture gives, about 0.2 chroma, a
 *  difference of 0.035, which the rim's light, 35% the ground's, carries as less
 *  than one). A colour gliding under a glass then changes the written value a few
 *  times a second, not on every frame. */
export const OKLCH_GRAIN = { l: 0.02, c: 0.02, h: 10 } as const
export function oklchGrain(rgb: Rgb): string {
  const { l, c, h } = oklchOf(rgb)
  const step = (v: number, s: number) => Math.round(v / s) * s
  const sc = step(c, OKLCH_GRAIN.c)
  return `oklch(${step(l, OKLCH_GRAIN.l).toFixed(2)} ${sc.toFixed(2)} ${sc === 0 ? 0 : step(h, OKLCH_GRAIN.h) % 360})`
}

/** A frame's pixels (RGBA, `cols × fine` by `rows × fine`, as a canvas hands them)
 *  as a grid of `cols` × `rows` cells, each the mean of its `fine` × `fine` samples in
 *  light, with the darkest and brightest of them. */
export function gridOf(
  rgba: ArrayLike<number>,
  cols: number,
  rows: number,
  fine = 1,
): Grid {
  const width = cols * fine
  if (rgba.length !== width * rows * fine * 4)
    throw new Error(
      `ground: ${rgba.length} values for a ${width}×${rows * fine} frame, expected ${width * rows * fine * 4}`,
    )
  const l = new Float32Array(cols * rows)
  const rgb = new Float32Array(cols * rows * 3)
  const lo = new Float32Array(cols * rows)
  const hi = new Float32Array(cols * rows)
  const n = fine * fine
  for (let i = 0; i < l.length; i++) {
    const cx = (i % cols) * fine
    const cy = Math.floor(i / cols) * fine
    let r = 0
    let g = 0
    let b = 0
    let dark = 100
    let light = 0
    for (let y = cy; y < cy + fine; y++)
      for (let x = cx; x < cx + fine; x++) {
        const p = (y * width + x) * 4
        const sr = linear(rgba[p] as number)
        const sg = linear(rgba[p + 1] as number)
        const sb = linear(rgba[p + 2] as number)
        r += sr
        g += sg
        b += sb
        const sl = lOf(0.2126 * sr + 0.7152 * sg + 0.0722 * sb)
        dark = Math.min(dark, sl)
        light = Math.max(light, sl)
      }
    rgb.set([r / n, g / n, b / n], i * 3)
    l[i] = lOf((0.2126 * r + 0.7152 * g + 0.0722 * b) / n)
    lo[i] = dark
    hi[i] = light
  }
  return { cols, rows, l, rgb, lo, hi }
}

/** A cell's lightness and colour (linear RGB) and how much of an area it covers (in
 *  any one unit per reading). */
export interface Cell {
  l: number
  rgb: readonly [number, number, number]
  weight: number
  /** Its darkest and brightest sample, L* (its mean `l` unless given). */
  lo?: number
  hi?: number
}

/** The cells of a frame an area covers, each weighted by how much of it the area
 *  covers, in shares of the frame; the part of the area off the frame is not here. */
export function cellsUnder(grid: Grid, area: Area): Cell[] {
  const cells: Cell[] = []
  const x0 = Math.max(0, area.x)
  const x1 = Math.min(1, area.x + area.w)
  const y0 = Math.max(0, area.y)
  const y1 = Math.min(1, area.y + area.h)
  if (!(x1 > x0 && y1 > y0)) return cells
  const c0 = Math.floor(x0 * grid.cols)
  const c1 = Math.min(grid.cols - 1, Math.ceil(x1 * grid.cols) - 1)
  const r0 = Math.floor(y0 * grid.rows)
  const r1 = Math.min(grid.rows - 1, Math.ceil(y1 * grid.rows) - 1)
  for (let r = r0; r <= r1; r++)
    for (let c = c0; c <= c1; c++) {
      const ox = Math.min(x1, (c + 1) / grid.cols) - Math.max(x0, c / grid.cols)
      const oy = Math.min(y1, (r + 1) / grid.rows) - Math.max(y0, r / grid.rows)
      const weight = ox * oy
      const i = r * grid.cols + c
      if (weight > 0)
        cells.push({
          l: grid.l[i] as number,
          rgb: [
            grid.rgb[i * 3] as number,
            grid.rgb[i * 3 + 1] as number,
            grid.rgb[i * 3 + 2] as number,
          ],
          weight,
          lo: grid.lo[i] as number,
          hi: grid.hi[i] as number,
        })
    }
  return cells
}

/** What a set of weighted cells is like, `total` being the whole area's weight: the
 *  part no cell covers is read as `outside`, a grey of that lightness (the stage
 *  round a letterboxed picture, black, is 0). Cells may come from several pictures:
 *  an area over two photographs gliding past is read across both. */
export function reading(
  cells: Cell[],
  total: number,
  outside: number,
): Reading {
  if (!(total > 0)) throw new Error(`ground: an area with no size (${total})`)
  const covered = cells.reduce((s, c) => s + c.weight, 0)
  if (covered > total * (1 + 1e-6))
    throw new Error(
      `ground: cells cover ${covered} of an area of ${total}: two pictures of one source overlap, and only one of them shows`,
    )
  const grey = yOf(outside)
  const all =
    total - covered > total * 1e-9
      ? [
          ...cells,
          {
            l: outside,
            rgb: [grey, grey, grey] as const,
            weight: total - covered,
          },
        ]
      : [...cells]
  const mean = all.reduce((s, c) => s + c.l * c.weight, 0) / total
  const channel = (k: 0 | 1 | 2) =>
    Math.round(
      encoded(all.reduce((s, c) => s + c.rgb[k] * c.weight, 0) / total),
    )
  const color: Rgb = [channel(0), channel(1), channel(2)]
  // The darkest and the brightest part, never a band of most of it: a glass's words
  // may stand over any part of it, and thickness solved for the 90th percentile of
  // cell means left a line over a bright cliff in a sky at 3.5:1.
  const low = Math.min(...all.map((c) => c.lo ?? c.l))
  const high = Math.max(...all.map((c) => c.hi ?? c.l))
  return { mean, low, high, color }
}

/** One frame's area read, off-frame as `outside`. */
export function readArea(grid: Grid, area: Area, outside: number): Reading {
  if (!(area.w > 0 && area.h > 0))
    throw new Error(`ground: an area with no size (${area.w}×${area.h})`)
  return reading(cellsUnder(grid, area), area.w * area.h, outside)
}

/** A surface's standing: the scheme it wears, and a change waiting to be held long
 *  enough. */
export interface Verdict {
  worn: Worn
  pending: Worn | null
  since: number
}

/** The next verdict, from the last (none: the first reading decides at once), a
 *  reading, the time, and whether the surface can be seen. */
export function judge(
  prev: Verdict | null,
  read: Reading,
  now: number,
  visible: boolean,
): Verdict {
  const want: Worn =
    read.mean > GROUND.lightAbove
      ? "light"
      : read.mean < GROUND.darkBelow
        ? "dark"
        : (prev?.worn ?? (read.mean >= 55 ? "light" : "dark"))
  if (!prev || !visible || want === prev.worn)
    return { worn: want, pending: null, since: now }
  if (prev.pending !== want) return { ...prev, pending: want, since: now }
  if (now - prev.since >= GROUND.holdMs)
    return { worn: want, pending: null, since: now }
  return prev
}

/** The contrast every glass keeps its words at against the part of the picture
 *  least in their favour, as WCAG measures it: 4.5:1, AA for body text, held by the
 *  QUIETEST words that are still content, a secondary line at `muted` of the words'
 *  ink (a description, a name, a count). The words whole then reach 7:1 and more.
 *  One rule for both faces, white words on dark glass and dark ones (zinc-950) on
 *  light. Apple's Liquid Glass keeps text legible the same way, adjusting the
 *  luminosity of what is under it and growing more opaque where text needs it (HIG,
 *  Materials: "the regular variant blurs and adjusts the luminosity of background
 *  content to maintain legibility"; Color: "Liquid Glass appears more opaque in
 *  larger elements like sidebars to preserve legibility"). Held by the words whole
 *  at 7:1 until 2026-11-04, the secondary lines fell to 3:1 over a bright canyon and
 *  a white page (measured on the kanban, the panel and the cards). */
export const WORDS = {
  contrast: 4.5,
  muted: 0.65,
  light: [255, 255, 255],
  dark: [9, 9, 11],
} as const
const luminance = ([r, g, b]: readonly [number, number, number]) =>
  0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
const ratioOf = (a: number, b: number) =>
  (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
/** The contrast of `words` at `WORDS.muted` over a grey ground of lightness `l`, as
 *  the render composites them: in encoded sRGB. */
function mutedContrast(words: readonly [number, number, number], l: number) {
  const ground = encoded(yOf(l))
  const mixed = words.map(
    (c) => ground + (c - ground) * WORDS.muted,
  ) as unknown as [number, number, number]
  return ratioOf(luminance(mixed), yOf(l))
}
/** The lightest a ground may be under white words, and the darkest under dark
 *  ones, for the muted words to reach `WORDS.contrast`: found by halving. */
function solveGround(
  words: readonly [number, number, number],
  under: boolean,
): number {
  let lo = 0
  let hi = 100
  for (let i = 0; i < 30; i++) {
    const mid = (lo + hi) / 2
    const reaches = mutedContrast(words, mid) >= WORDS.contrast
    if (reaches === under) lo = mid
    else hi = mid
  }
  return under ? lo : hi
}
const UNDER_WHITE = solveGround(WORDS.light, true)
const OVER_INK = solveGround(WORDS.dark, false)

/** How thick a glass must be for its words, and no thicker: the opacity of its
 *  tint over a picture read as `read`. Dark glass (white words) darkens what is under
 *  it toward black; enough that its BRIGHTEST part (`high`) comes down to `under`.
 *  Light glass (dark words) lightens toward its own pale tint; enough that its
 *  DARKEST part (`low`) comes up to `over`. Both from `WORDS`. Each held between a
 *  floor (the glass always has a body) and a ceiling (it stays glass, though over
 *  a white sky the dark face may need nearly all of it: the light face is what such
 *  a sky asks for, and `judge` gives it). Lightness mixes about linearly in L* at
 *  these opacities, so the tint is the share of the way to go. */
export const TINT = {
  dark: { under: UNDER_WHITE, min: 0.42, max: 0.86, ground: 4 },
  light: { over: OVER_INK, min: 0.5, max: 0.92, ground: 96 },
} as const
export function tintFor(worn: Worn, read: Reading): number {
  const clamp = (a: number, lo: number, hi: number) =>
    Math.min(hi, Math.max(lo, a))
  // A picture already as dark (or light) as the words need takes the floor: past
  // the tint's own lightness the share would divide by a sign that flipped.
  if (worn === "dark") {
    const { under, min, max, ground } = TINT.dark
    if (read.high <= under) return min
    return clamp((read.high - under) / (read.high - ground), min, max)
  }
  const { over, min, max, ground } = TINT.light
  if (read.low >= over) return min
  return clamp((over - read.low) / (ground - read.low), min, max)
}

/** The lightness right under a glass's rim: the picture seen through the glass's
 *  face, the face's own lightness (`TINT`'s ground) at its thickness. The rim is
 *  drawn on the glass, not on the picture: dark glass over a white sky is dark under
 *  its rim, and a rim made stronger for the sky's sake read a hard white line on a
 *  dark face ("too white, too intense for no reason", Adrian, 2026-10-11). */
export function underRim(worn: Worn, read: Reading, thickness: number): number {
  const face = worn === "dark" ? TINT.dark.ground : TINT.light.ground
  return read.mean * (1 - thickness) + face * thickness
}

/** How strongly a glass's rim should read over a ground of lightness `l` (L*; the
 *  ground right under the rim, `underRim`): its `--glass-ground-sheen`. A white
 *  edge's contrast grows as its ground darkens, so over a dark ground the rim
 *  carries less light and over a light one more, the edge reading about the same on
 *  both. Between `SHEEN.dark` (black) and `SHEEN.light` (white), along the
 *  lightness. */
export const SHEEN = { dark: 0.55, light: 1.1 } as const
export function sheenOf(l: number): number {
  const t = Math.min(1, Math.max(0, l / 100))
  return SHEEN.dark + (SHEEN.light - SHEEN.dark) * t
}

/** Where a picture's content stands in its element's box, as `object-fit` puts it: a
 *  box in the element's own px. */
export function contentBox(
  box: { width: number; height: number },
  natural: { width: number; height: number },
  fit: "contain" | "cover" | "fill",
): { x: number; y: number; width: number; height: number } {
  if (fit === "fill" || !(natural.width > 0 && natural.height > 0))
    return { x: 0, y: 0, width: box.width, height: box.height }
  const scale = (fit === "contain" ? Math.min : Math.max)(
    box.width / natural.width,
    box.height / natural.height,
  )
  const width = natural.width * scale
  const height = natural.height * scale
  return {
    x: (box.width - width) / 2,
    y: (box.height - height) / 2,
    width,
    height,
  }
}