Skip to content
goodtabs UI
BrowseBrowse components

Liquid divider

A boundary between two sections, drawn as a slow liquid edge on a theme token.

npx shadcn@latest add https://ui.goodtabs.co/r/liquid-divider.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/liquid-divider

Two sections with a 128px card edge between them. Reduced motion draws one still curve.

Muted

A taller 176px band on the muted token at 0.6 speed.

Usage

import { LiquidDivider } from "@/components/ui/liquid-divider"

<LiquidDivider fill="card" height={128} />
<section className="bg-card">...</section>

Props

PropTypeDefaultDescription
fill"card" | "muted""card"The token under the edge. Give the next section the same background.
heightnumber96Height of the band the edge moves in, in CSS pixels.
speednumber1Multiplies the speed of the swell.

Source

liquid-divider/liquid-divider.tsx
import * as React from "react";

import { ShaderCanvas } from "@/components/ui/shader-canvas";
import { cn } from "@/lib/utils";

const FRAGMENT = `precision mediump float;
uniform float uTime;
uniform vec2 uResolution;
uniform float uScale;
uniform float uSpeed;
uniform vec3 uFill;
uniform vec3 uInk;

// Height of the surface at x, as a fraction of the band. A warped sum of three sines at unrelated
// rates never lines up, so no crest repeats. Amplitudes sum to 0.37, which keeps it inside 0.13 to 0.87.
float surface(float x, float t) {
  float u = x / 420.0;
  float warp = u + 0.22 * sin(u * 0.7 + t * 0.11);
  float a = sin(warp * 1.9 + t * 0.19);
  float b = sin(warp * 3.1 - t * 0.14 + 1.3);
  float c = sin(warp * 5.3 + t * 0.23 + 4.0);
  return 0.5 + 0.21 * a + 0.12 * b + 0.04 * c;
}

void main() {
  vec2 p = gl_FragCoord.xy / uScale;
  float band = uResolution.y / uScale;
  float t = uTime * uSpeed;
  float y = surface(p.x, t) * band;

  // Divide by the length of the gradient so the edge keeps one width on a steep slope.
  float rise = (surface(p.x + 1.0, t) - surface(p.x - 1.0, t)) * 0.5 * band;
  float d = (p.y - y) / sqrt(1.0 + rise * rise);
  float aa = 0.75 / uScale;

  float body = 1.0 - smoothstep(-aa, aa, d);
  // The meniscus: a lip of light that hugs the edge, gone by the bottom of the band so the next section
  // meets a flat fill and no seam. The hairline sits on top, drawn once in the foreground at 20 percent.
  float lip = body * exp(d / (band * 0.2)) * smoothstep(0.0, band * 0.3, p.y) * 0.09;
  float hairline = (1.0 - smoothstep(0.5 - aa, 0.5 + aa, abs(d))) * 0.2;

  float ink = lip + hairline;
  vec3 color = uFill * body * (1.0 - ink) + uInk * ink;
  float alpha = body + hairline * (1.0 - body);
  gl_FragColor = vec4(color, alpha);
}`;

const FILLS = {
  card: { token: "--card", svg: "fill-card" },
  muted: { token: "--muted", svg: "fill-muted" },
} as const;

type LiquidDividerProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The token under the edge. Give the next section the same background. */
  fill?: keyof typeof FILLS;
  /** Height of the band the edge moves in, in CSS pixels. */
  height?: number;
  /** Multiplies the speed of the swell. */
  speed?: number;
};

/**
 * A boundary between two sections, drawn as a slow liquid edge. Everything below the edge is the fill
 * token, so the section underneath carries on with no seam. Without WebGL it is the same idea in SVG.
 */
function LiquidDivider({ fill = "card", height = 96, speed = 1, className, style, ...props }: LiquidDividerProps) {
  const { token, svg } = FILLS[fill];
  return (
    <div
      data-slot="liquid-divider"
      aria-hidden="true"
      className={cn("relative isolate w-full bg-background", className)}
      style={{ ...style, height }}
      {...props}
    >
      <ShaderCanvas fragment={FRAGMENT} tokens={{ uFill: token, uInk: "--foreground" }} uniforms={{ uSpeed: speed }}>
        <svg viewBox="0 0 1440 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
          <path
            d="M0 58C180 24 340 26 560 50S980 86 1180 62 1360 34 1440 40V100H0Z"
            className={cn("stroke-border", svg)}
            vectorEffect="non-scaling-stroke"
          />
        </svg>
      </ShaderCanvas>
    </div>
  );
}

export { LiquidDivider };
components/ui/shader-canvas.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

type UniformValue = number | readonly number[];

type ShaderCanvasProps = React.ComponentProps<"div"> & {
  /** Fragment shader source. Receives uTime, uResolution (device px), uScale (device px per CSS px), uPointer and uLoaded, plus your uniforms and tokens. */
  fragment: string;
  /** Extra uniforms. Read every frame, so a tween can mutate this object in place. */
  uniforms?: Record<string, UniformValue>;
  /** Theme tokens passed as vec3 uniforms, for example { uInk: "--foreground" }. Re-read on theme change. */
  tokens?: Record<string, string>;
  /** Images passed as sampler2D uniforms, for example { uImage: "/photo.jpg" }. uImageSize-style sizes follow: uImage gets uImageSize. */
  textures?: Record<string, string>;
  /** Upper limit on device pixels per CSS pixel. */
  maxDpr?: number;
  /** The moment, in seconds, drawn as the still frame under reduced motion. */
  stillAt?: number;
  /** "demand" draws only when `invalidateRef` is called, for pieces that rest. The default never stops while visible. */
  frames?: "continuous" | "demand";
  /** Receives a function that draws one frame now. Pair it with frames="demand". */
  invalidateRef?: React.RefObject<(() => void) | null>;
  /** "track" feeds a smoothed pointer position to uPointer on devices with a mouse. */
  pointer?: "none" | "track";
};

const VERTEX = `attribute vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;

/** Resolves a token through the cascade, so a scoped .light or .dark wrapper is respected. */
function readToken(host: HTMLElement, token: string): [number, number, number] {
  const probe = document.createElement("span");
  probe.style.color = `var(${token})`;
  probe.style.display = "none";
  host.appendChild(probe);
  const color = getComputedStyle(probe).color;
  probe.remove();
  const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!paint) return [0, 0, 0];
  paint.fillStyle = color;
  paint.fillRect(0, 0, 1, 1);
  const [r, g, b] = paint.getImageData(0, 0, 1, 1).data;
  return [r / 255, g / 255, b / 255];
}

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
  console.error(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
  return null;
}

/**
 * A raw WebGL fragment shader on a full-bleed canvas. It pauses off screen, draws one still frame
 * under reduced motion, follows the theme, and leaves its children showing when WebGL is missing.
 * Children are the static fallback, so give it something that reads on its own.
 */
function ShaderCanvas({
  fragment,
  uniforms,
  tokens,
  textures,
  maxDpr = 1.5,
  stillAt = 2,
  frames = "continuous",
  invalidateRef,
  pointer = "none",
  className,
  children,
  ...props
}: ShaderCanvasProps) {
  const host = React.useRef<HTMLDivElement>(null);
  const canvas = React.useRef<HTMLCanvasElement>(null);
  const latest = React.useRef({ uniforms, tokens });
  const textureKey = JSON.stringify(textures ?? {});
  const redraw = React.useRef<() => void>(() => {});
  const [ready, setReady] = React.useState(false);
  // Bumped when a lost context comes back, which rebuilds everything from scratch.
  const [generation, setGeneration] = React.useState(0);

  React.useEffect(() => {
    latest.current = { uniforms, tokens };
    redraw.current();
  });

  React.useEffect(() => {
    const root = host.current;
    const el = canvas.current;
    if (!root || !el) return;
    // WebGL clears its buffer once a frame is presented. A canvas that draws rarely has to keep it,
    // or the picture is gone the next time the browser re-composites the page.
    const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
    const gl = el.getContext("webgl", {
      preserveDrawingBuffer: motion.matches || frames === "demand",
      alpha: true,
      antialias: false,
      depth: false,
      stencil: false,
      premultipliedAlpha: true,
      powerPreference: "low-power",
    });
    if (!gl) return;

    const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
    const frag = compile(gl, gl.FRAGMENT_SHADER, fragment);
    const program = gl.createProgram();
    if (!vertex || !frag || !program) return;
    gl.attachShader(program, vertex);
    gl.attachShader(program, frag);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(program));
      return;
    }
    gl.useProgram(program);

    // One oversized triangle covers the clip square with no diagonal seam.
    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
    const position = gl.getAttribLocation(program, "aPosition");
    gl.enableVertexAttribArray(position);
    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    const locations = new Map<string, WebGLUniformLocation | null>();
    const at = (name: string) => {
      if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
      return locations.get(name) ?? null;
    };
    const set = (name: string, value: UniformValue) => {
      const loc = at(name);
      if (!loc) return;
      if (typeof value === "number") gl.uniform1f(loc, value);
      else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
      else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
      else if (value.length === 4) gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
    };

    let colors: Record<string, [number, number, number]> = {};
    const readTheme = () => {
      colors = Object.fromEntries(
        Object.entries(latest.current.tokens ?? {}).map(([name, token]) => [name, readToken(root, token)]),
      );
    };
    readTheme();

    // Textures load after the first frame. A sampler with nothing bound reads as black, so shaders that
    // use one should fade in on uLoaded, which goes from 0 to 1 once every image has arrived.
    const sources = Object.entries(JSON.parse(textureKey) as Record<string, string>);
    const images: WebGLTexture[] = [];
    let loaded = sources.length === 0 ? 1 : 0;
    let disposed = false;
    let pending = sources.length;
    sources.forEach(([name, src], unit) => {
      const texture = gl.createTexture();
      if (!texture) return;
      images.push(texture);
      const image = new Image();
      image.crossOrigin = "anonymous";
      image.onload = () => {
        if (disposed) return;
        gl.activeTexture(gl.TEXTURE0 + unit);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        const sampler = at(name);
        if (sampler) gl.uniform1i(sampler, unit);
        set(`${name}Size`, [image.naturalWidth, image.naturalHeight]);
        pending -= 1;
        if (pending === 0) loaded = 1;
        draw();
      };
      image.src = src;
    });

    const target = { x: 0.5, y: 0.5 };
    const eased = { x: 0.5, y: 0.5 };
    let time = stillAt;
    let visible = false;
    let frame = 0;
    let last = 0;

    const draw = () => {
      gl.viewport(0, 0, el.width, el.height);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      set("uTime", time);
      set("uResolution", [el.width, el.height]);
      set("uScale", el.width / Math.max(el.clientWidth, 1));
      set("uPointer", [eased.x, eased.y]);
      set("uLoaded", loaded);
      for (const [name, value] of Object.entries(colors)) set(name, value);
      for (const [name, value] of Object.entries(latest.current.uniforms ?? {})) set(name, value);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    };
    redraw.current = () => {
      if (motion.matches) draw();
    };

    const loop = (now: number) => {
      frame = 0;
      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      time += dt;
      // Frame-rate independent smoothing: 1 - e^(-k dt).
      const k = 1 - Math.exp(-6 * dt);
      eased.x += (target.x - eased.x) * k;
      eased.y += (target.y - eased.y) * k;
      draw();
      schedule();
    };
    const schedule = () => {
      if (frames === "demand" || frame || !visible || motion.matches || document.hidden || gl.isContextLost()) return;
      frame = requestAnimationFrame(loop);
    };
    const stop = () => {
      if (frame) cancelAnimationFrame(frame);
      frame = 0;
      last = 0;
    };

    const resize = () => {
      const scale = Math.min(window.devicePixelRatio || 1, maxDpr);
      const width = Math.max(1, Math.round(el.clientWidth * scale));
      const height = Math.max(1, Math.round(el.clientHeight * scale));
      if (el.width !== width || el.height !== height) {
        el.width = width;
        el.height = height;
      }
      draw();
    };
    const resizer = new ResizeObserver(resize);
    resizer.observe(el);

    const viewport = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (!visible) return stop();
      draw();
      schedule();
    });
    viewport.observe(el);

    // Smooth scrolling libraries flip classes on html while scrolling, so react only to the theme itself.
    const themeKey = () => {
      const html = document.documentElement;
      return `${html.classList.contains("dark")} ${html.classList.contains("light")} ${html.style.colorScheme}`;
    };
    let lastTheme = themeKey();
    const theme = new MutationObserver(() => {
      if (themeKey() === lastTheme) return;
      lastTheme = themeKey();
      readTheme();
      draw();
    });
    theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });

    const onVisibility = () => (document.hidden ? stop() : schedule());
    const onMotion = () => {
      stop();
      if (motion.matches) {
        time = stillAt;
        draw();
      } else schedule();
    };
    const onPointer = (e: PointerEvent) => {
      const box = el.getBoundingClientRect();
      target.x = (e.clientX - box.left) / box.width;
      target.y = 1 - (e.clientY - box.top) / box.height;
    };
    const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    const tracks = pointer === "track" && fine;
    document.addEventListener("visibilitychange", onVisibility);
    motion.addEventListener("change", onMotion);
    if (tracks) window.addEventListener("pointermove", onPointer, { passive: true });

    // preventDefault asks the browser to restore the context. Until then the static fallback shows.
    const onLost = (e: Event) => {
      e.preventDefault();
      stop();
      setReady(false);
    };
    const onRestored = () => setGeneration((n) => n + 1);
    el.addEventListener("webglcontextlost", onLost);
    el.addEventListener("webglcontextrestored", onRestored);

    if (invalidateRef) invalidateRef.current = draw;
    resize();
    setReady(true);
    return () => {
      disposed = true;
      if (invalidateRef) invalidateRef.current = null;
      stop();
      redraw.current = () => {};
      resizer.disconnect();
      viewport.disconnect();
      theme.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      motion.removeEventListener("change", onMotion);
      if (tracks) window.removeEventListener("pointermove", onPointer);
      el.removeEventListener("webglcontextlost", onLost);
      el.removeEventListener("webglcontextrestored", onRestored);
      images.forEach((texture) => gl.deleteTexture(texture));
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      gl.deleteShader(vertex);
      gl.deleteShader(frag);
    };
  }, [fragment, frames, generation, invalidateRef, maxDpr, pointer, stillAt, textureKey]);

  return (
    <div
      ref={host}
      data-slot="shader-canvas"
      aria-hidden="true"
      className={cn("absolute inset-0", className)}
      {...props}
    >
      {ready ? null : children}
      <canvas ref={canvas} className="absolute inset-0 size-full" />
    </div>
  );
}

export { ShaderCanvas };

For AI agents

Hand your agent this page, the markdown at /components/liquid-divider/prompt.md, or the Copy AI prompt button above.

When to use

  • Between a hero and the section under it, when a straight line feels too hard
  • Once or twice on a marketing page, as a change of surface
  • When the next section already sits on the card or muted token

When not to use

  • Inside app chrome or between list groups: use a hairline
  • Between every section, because the effect only works as an exception
  • On a coloured or image section, since the fill has to match a token

Agent notes

  • The next section must use bg-card or bg-muted to match the fill prop, or a seam shows at the bottom of the band
  • Above the edge the band shows bg-background. Pass a className such as bg-muted when the section above sits on another token
  • It is a server component and decorative, so the whole band is aria-hidden. It installs components/ui/shader-canvas.tsx, which pauses off screen and follows the theme
  • Under reduced motion it draws one still curve, and without WebGL an SVG curve in the same token shows. No GSAP is involved here

The maths

  • The edge stays inside the band: 0.5 + 0.21 + 0.12 + 0.04 = 0.87 at most and 0.13 at least, so the fill always reaches the bottom.
  • The hairline keeps a 1px width on any slope because vertical distance is divided by sqrt(1 + slope²), the length of the gradient.
  • The three sine rates 1.9, 3.1 and 5.3 share no small common factor, so crests do not line up in any window a reader will watch.