← ui

water

ui

loading as water: what has not come lies under it and surfaces as it comes

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

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

Latest changes · 2026-10-11
  • New: loading as water, for any component. What has not come yet lies under it, what has come surfaces. calm (waiting its turn): the content softened, a little of its colour drained, a faint dark grey over it. falling (on its way): drops fall in that water, broad swells travelling out from the middle and bending the content as they pass, then the next drop. The water rises and ebbs over half a second, never cutting, so a video surfacing over it crosses its ebb. It is an SVG filter on the content itself, nothing laid over it, the grey only where something is drawn. <Water state> wraps anything (a picture, a card, a panel); useWater(state) dresses an element you cannot wrap (a ref, a style, and the filter to render beside it). No drop falls under reduced motion. The gallery's cells wear it for motion's waiting and loading.
  • Fixed: what is under water keeps its rounded corners. A filter is drawn after the element's own clip, so the blur spread past rounded corners into soft square ones; the water now clips its result to the element's corners, read from the element.
Updated 2026-10-11 · release notes

shelf controls

used by video

the demo above, verbatim

water.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
import { Water, type WaterState } from "@/registry/base-nova/ui/water"

// #region states
// One choice, worn by a picture and by a card of words alike: anything under water.
// calm: waiting (its turn has not come). falling: loading (on its way). clear: here.
const STATES = ["calm", "falling", "clear"] as const
type Choice = (typeof STATES)[number]
const stateOf = (c: Choice): WaterState | null => (c === "clear" ? null : c)
// #endregion

function States() {
  const [choice, setChoice] = React.useState<Choice>("falling")
  const state = stateOf(choice)
  return (
    <div className="space-y-3">
      <ToggleGroup
        size="sm"
        aria-label="water"
        value={choice}
        onValueChange={(v) => setChoice(v as Choice)}
      >
        {STATES.map((s) => (
          <ToggleGroupItem key={s} value={s}>
            {s}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <div className="grid gap-6 sm:grid-cols-2">
        <Water
          state={state}
          className="aspect-[2/1] overflow-hidden rounded-xl"
        >
          {/* biome-ignore lint/performance/noImgElement: portable demo asset */}
          <img
            src="/glass-valley.jpg"
            alt="A green valley beneath distant mountains"
            className="size-full object-cover"
          />
        </Water>
        <Water state={state} className="rounded-xl">
          <article className="space-y-2 rounded-xl bg-foreground/5 p-5">
            <h3 className="font-serif text-xl">The slow road</h3>
            <p className="text-sm text-foreground/70">
              We left before the sun was up, and the road ran along the water
              the whole way. One lens, no gimbal, and a rule: stop when the
              light is good.
            </p>
          </article>
        </Water>
      </div>
    </div>
  )
}

export default function Demo() {
  return (
    <div className="space-y-12">
      <Sample
        name="states"
        glance="wide"
        with="states"
        title="calm, falling, clear"
        description="anything under water: calm while it waits, drops falling while it loads, clear when it is here. A picture and a card of words wear the same water."
      >
        <States />
      </Sample>
    </div>
  )
}

the source, every file an install writes

water.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

// LOADING IS WATER. What has not come yet lies under it; what has come surfaces.
// `calm`: still water, for something waiting (its turn has not come, it is queued):
// the content softened, a little of its colour drained, a dark grey over it. `falling`:
// a drop falls in that water, for something on its way (loading): broad swells travel
// out from the middle, bending the content as they pass and settling as they widen,
// then the next drop. No state: the content stands clear, and whatever replaces it
// (the video, the loaded page) comes in sharp. Nothing is laid over the content: it is
// an SVG filter on the content itself, so the water reads through the content's own
// pixels, and the tint falls only where something is drawn. One drawing for every
// loading state in the registry; the values are set by eye on a gallery's videos
// waiting and loading (Adrian, 2026-10-11: a dimmed or blurred still read as
// unavailable, a breathing one and a pane of frost with a light passing as cheap; the
// pond he loved). No drop falls under reduced motion; the water stays calm.

export type WaterState = "calm" | "falling"

/** One drop: its swells reach past the content's corners in this long, slowing as
 *  water does, and the next falls. */
const DROP_MS = 2600
/** How far the content is pushed at the swells' crest when the drop falls, px, fading
 *  to nothing as they widen. */
const DROP_DEPTH = 34
/** How soft the content is under the water, px. */
const WATER_BLUR = 5
/** How much of its colour the content keeps under the water, and how much of a dark
 *  grey (zinc-900, the dark glass's own ground) lies over it: a hint. Darker, the
 *  content coming clear jumped in brightness, a flash on a bright picture (Adrian,
 *  2026-10-11); the water is told by its swells, not by a shade. */
const WATER_SATURATION = 0.92
const WATER_TINT = 0.1

/** The swells as a displacement map, made once: red and green push the content along
 *  x and y (128 is still), each pixel pushed outward or inward from the middle by
 *  broad crests two fifths of the radius apart, rising toward the front and falling
 *  off at it, so the disc's edge meets still water. */
let swellMap: string | null = null
function swellsOf(): string {
  if (swellMap) return swellMap
  const size = 256
  const canvas = document.createElement("canvas")
  canvas.width = size
  canvas.height = size
  const ctx = canvas.getContext("2d") as CanvasRenderingContext2D
  const image = ctx.createImageData(size, size)
  for (let y = 0; y < size; y++)
    for (let x = 0; x < size; x++) {
      const dx = (x + 0.5) / (size / 2) - 1
      const dy = (y + 0.5) / (size / 2) - 1
      const r = Math.hypot(dx, dy)
      const wave =
        r > 1 || r === 0
          ? 0
          : Math.sin((r / 0.4) * Math.PI * 2) * r ** 1.2 * (1 - r ** 6)
      const at = (y * size + x) * 4
      image.data[at] = 128 + Math.round(127 * wave * (r ? dx / r : 0))
      image.data[at + 1] = 128 + Math.round(127 * wave * (r ? dy / r : 0))
      image.data[at + 2] = 128
      image.data[at + 3] = 255
    }
  ctx.putImageData(image, 0, 0)
  swellMap = canvas.toDataURL()
  return swellMap
}

type Size = { width: number; height: number }

/** How long the water takes to rise over content going under, and to ebb from content
 *  coming clear: as long as a video takes to surface over it, so the two cross. Cut
 *  in one frame, the water landed and lifted as a jump. */
const RISE_MS = 500

/** Linear progress to an even ease, slow at both ends. */
const ease = (p: number) => p * p * (3 - 2 * p)

/** The water's filter, drawn in the content's own pixels (`size`) so the swells stay
 *  round on content of any shape. `state` is where it is going: the water rises to it
 *  and ebbs from it, every part at once (the softness, the shade, the drained colour,
 *  the drops), and once it has ebbed to nothing `onGone` lets the filter go. */
function WaterFilter({
  id,
  size,
  state,
  onGone,
}: {
  id: string
  size: Size
  state: WaterState | null
  onGone: () => void
}) {
  const swells = React.useRef<SVGFEImageElement>(null)
  const soft = React.useRef<SVGFEGaussianBlurElement>(null)
  const push = React.useRef<SVGFEDisplacementMapElement>(null)
  const drain = React.useRef<SVGFEColorMatrixElement>(null)
  const grey = React.useRef<SVGFEFloodElement>(null)
  // Read each frame, so a change of state steers the water already moving.
  const live = React.useRef({ state, size, onGone })
  live.current = { state, size, onGone }
  React.useEffect(() => {
    const image = swells.current
    const blur = soft.current
    const map = push.current
    const colour = drain.current
    const shade = grey.current
    if (!image || !blur || !map || !colour || !shade) return
    image.setAttribute("href", swellsOf())
    const still = matchMedia("(prefers-reduced-motion: reduce)").matches
    // How far the water has risen, and how much of a drop is falling, 0 to 1.
    let water = 0
    let drops = 0
    let dropFrom = 0
    let last = performance.now()
    let frame = 0
    const toward = (from: number, to: number, step: number) =>
      from < to ? Math.min(to, from + step) : Math.max(to, from - step)
    const tick = (now: number) => {
      const { state, size } = live.current
      const step = (now - last) / RISE_MS
      last = now
      water = toward(water, state ? 1 : 0, step)
      const falling = state === "falling" && !still
      if (falling && drops === 0) dropFrom = now
      drops = toward(drops, falling ? 1 : 0, step)
      const w = ease(water)
      blur.setAttribute("stdDeviation", String(WATER_BLUR * w))
      colour.setAttribute("values", String(1 - (1 - WATER_SATURATION) * w))
      shade.setAttribute("flood-opacity", String(WATER_TINT * w))
      const t = ((now - dropFrom) % DROP_MS) / DROP_MS
      // The front slows as it widens (ease-out), far enough to pass the farthest
      // corner; the swells fade evenly as they widen, so a drop is seen across the
      // whole content, not only where it fell.
      const corner = Math.hypot(size.width, size.height) / 2
      const reach = corner * (0.05 + 1.1 * (1 - (1 - t) ** 2.2))
      image.setAttribute("x", String(size.width / 2 - reach))
      image.setAttribute("y", String(size.height / 2 - reach))
      image.setAttribute("width", String(reach * 2))
      image.setAttribute("height", String(reach * 2))
      map.setAttribute("scale", String(DROP_DEPTH * (1 - t) * ease(drops) * w))
      if (!state && water === 0) {
        live.current.onGone()
        return
      }
      frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [])
  return (
    <svg
      aria-hidden="true"
      data-slot="water-filter"
      className="absolute size-0"
    >
      <filter
        id={id}
        x="0"
        y="0"
        width="1"
        height="1"
        colorInterpolationFilters="sRGB"
      >
        {/* Still where no swell is: the map lies over a flat middle grey. */}
        <feFlood floodColor="rgb(128 128 128)" result="still" />
        <feImage ref={swells} preserveAspectRatio="none" result="swells" />
        <feComposite in="swells" in2="still" operator="over" result="map" />
        <feGaussianBlur
          ref={soft}
          in="SourceGraphic"
          stdDeviation="0"
          edgeMode="duplicate"
          result="soft"
        />
        <feDisplacementMap
          ref={push}
          in="soft"
          in2="map"
          scale="0"
          xChannelSelector="R"
          yChannelSelector="G"
          result="moved"
        />
        <feColorMatrix
          ref={drain}
          in="moved"
          type="saturate"
          values="1"
          result="drained"
        />
        {/* The dark grey only where something is drawn: a card's rounded corners and
            the ground between words stay clear. */}
        <feFlood
          ref={grey}
          floodColor="rgb(24 24 27)"
          floodOpacity="0"
          result="grey"
        />
        <feComposite in="grey" in2="drained" operator="in" result="tint" />
        <feComposite in="tint" in2="drained" operator="over" />
      </filter>
    </svg>
  )
}

/** Water on an element you cannot wrap (an `<img>` sized by its own classes): put
 *  `ref` and `style` on it and render `filter` beside it. `state` null: the element
 *  comes clear (the water ebbs, then no filter is drawn). */
export function useWater<E extends HTMLElement>(
  state: WaterState | null,
): {
  ref: React.RefCallback<E>
  style: React.CSSProperties | undefined
  filter: React.ReactNode
} {
  const id = `ag-water-${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`
  const [el, setEl] = React.useState<E | null>(null)
  const [size, setSize] = React.useState<Size | null>(null)
  // The element's own corners: a filter is drawn after the element's clip, so the
  // blur spreads past rounded corners into soft square ones; clipping the filtered
  // result to the same corners (clip-path comes after filter) keeps the shape.
  const [corners, setCorners] = React.useState("0px")
  // Under water from the moment a state is asked until the water has ebbed after it.
  const [wet, setWet] = React.useState(false)
  React.useEffect(() => {
    if (state) setWet(true)
  }, [state])
  React.useEffect(() => {
    if (!wet || !el) return
    const read = () => {
      setSize({ width: el.clientWidth, height: el.clientHeight })
      setCorners(getComputedStyle(el).borderRadius)
    }
    read()
    const observer = new ResizeObserver(read)
    observer.observe(el)
    return () => observer.disconnect()
  }, [wet, el])
  const dry = React.useCallback(() => setWet(false), [])
  const under = wet && size !== null
  return {
    ref: setEl,
    style: under
      ? { filter: `url(#${id})`, clipPath: `inset(0 round ${corners})` }
      : undefined,
    filter: under ? (
      <WaterFilter id={id} size={size} state={state} onGone={dry} />
    ) : null,
  }
}

/** Anything under water: a picture, a card, a panel, a list. `state` null stands it
 *  clear. Sized and laid out as a plain `div` (`className`). */
export function Water({
  state,
  className,
  children,
}: {
  state: WaterState | null
  className?: string
  children: React.ReactNode
}) {
  const water = useWater<HTMLDivElement>(state)
  return (
    <div
      ref={water.ref}
      data-slot="water"
      data-state={state ?? undefined}
      className={cn("relative", className)}
      style={water.style}
    >
      {children}
      {water.filter}
    </div>
  )
}