shadimage
pane
uipanesource: ok · hit

Pane

The glass primitive every other component is built on — a refracted rim, a tinted backdrop, and a specular highlight that follows the pointer. Ships regular and clear variants, and flattens automatically when nested.

npx shadcn add "https://pane.theui.company/r/pane.json"
free

Visual style

glass-blur

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

@pane/pane-style@pane/utils@pane/glass-core@pane/use-glass-pointer@pane/use-glass-support

Files1 · source cached 10/15/2026, 6:24:31 AM

registry/pane/ui/pane.tsx
"use client";

import { motion } from "motion/react";
import * as React from "react";
import { useGlassPointer } from "@/hooks/use-glass-pointer";
import { useGlassSupport } from "@/hooks/use-glass-support";
import { GLASS_MIN_FILTER_AREA } from "@/lib/glass/config";
import { useGlassConfig } from "@/lib/glass/context";
import { GlassFilter } from "@/lib/glass/glass-filter";
import type { GlassVariant } from "@/lib/glass/types";
import { type PaneVariantProps, paneVariants } from "@/lib/glass/variants";
import { cn } from "@/lib/utils";

export interface PaneProps
  extends Omit<React.ComponentProps<typeof motion.div>, "children" | "ref">,
    Omit<PaneVariantProps, "variant"> {
  variant?: GlassVariant;
  /** corner radius in px — kept numeric so the refraction filter can match it exactly */
  radius?: number;
  /**
   * Overrides nesting detection. Pass `false` on a pane rendered through a
   * portal: React context crosses the portal, so a dialog opened from inside a
   * Pane would otherwise think it is nested and drop its own backdrop, even
   * though on screen it floats over the page, not over its parent pane.
   */
  nested?: boolean;
  children?: React.ReactNode;
}

/**
 * True inside another Pane. Glass doesn't stack: a backdrop-filtered element
 * nested in another one can only sample its parent's layers, so it re-blurs an
 * already-tinted image and comes out muddy. Nested panes skip the backdrop work
 * and sit on a flat translucent tint instead.
 */
const NestedPaneContext = React.createContext(false);

/**
 * Border-box size, rounded to whole px. It has to be the border box: the
 * refraction map is a rounded rect of exactly this size pinned to the element's
 * origin, so a content-box measurement would shrink it by the padding and leave
 * the rim floating inside the surface. Rounding keeps sub-pixel layout jitter
 * from rebuilding the map.
 */
function usePaneSize(ref: React.RefObject<HTMLElement | null>) {
  const [size, setSize] = React.useState({ width: 0, height: 0 });

  React.useEffect(() => {
    const node = ref.current;
    if (!node) return;

    const observer = new ResizeObserver(([entry]) => {
      const border = entry.borderBoxSize?.[0];
      const width = border ? border.inlineSize : node.offsetWidth;
      const height = border ? border.blockSize : node.offsetHeight;
      setSize((prev) => {
        const next = { width: Math.round(width), height: Math.round(height) };
        return prev.width === next.width && prev.height === next.height
          ? prev
          : next;
      });
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [ref]);

  return size;
}

/**
 * Masks a layer down to a `thickness`-px band hugging the edge: paint the whole
 * rounded rect, then punch out the interior. Every moving highlight goes
 * through this so the shimmer stays on the rim instead of washing the content.
 */
function edgeBand(thickness: number): React.CSSProperties {
  const layers = "linear-gradient(#000 0 0), linear-gradient(#000 0 0)";
  return {
    padding: thickness,
    maskImage: layers,
    maskClip: "content-box, border-box",
    maskComposite: "exclude",
    WebkitMaskImage: layers,
    WebkitMaskClip: "content-box, border-box",
    WebkitMaskComposite: "xor",
  } as React.CSSProperties;
}

/**
 * The base glass surface every Pane component is built on: a blurred, tinted
 * body with the refraction, glow and specular sheen confined to a rim at the
 * edge, reacting to pointer position and press. Exported directly for wrapping
 * arbitrary content in glass.
 */
export function Pane({
  variant = "regular",
  interactive = false,
  radius = 28,
  nested: nestedProp,
  className,
  style,
  children,
  ...props
}: PaneProps) {
  const reactId = React.useId().replace(/:/g, "");
  const filterId = `pane-filter-${reactId}`;
  const { ref, active } = useGlassPointer<HTMLDivElement>();
  const quality = useGlassSupport();
  const nestedContext = React.useContext(NestedPaneContext);
  const nested = nestedProp ?? nestedContext;
  const { width, height } = usePaneSize(ref);
  const { spring, specular } = useGlassConfig();
  const canDisplace =
    !nested && quality === "full" && width * height >= GLASS_MIN_FILTER_AREA;

  // the glow has to stay a rim: on a 28px switch a wide band meets in the
  // middle and reads as a frosted collar rather than an edge
  const shortSide = Math.min(width || Infinity, height || Infinity);
  const glow = Math.max(2, Math.min(specular.edge, shortSide * 0.12));

  const blurVar = `var(--pane-blur-${variant})`;
  const backdropFilter =
    nested || quality === "none"
      ? undefined
      : canDisplace
        ? `blur(${blurVar}) saturate(var(--pane-saturation)) url(#${filterId})`
        : `blur(${blurVar}) saturate(var(--pane-saturation))`;
  const tint = nested
    ? `var(--pane-nested-${variant})`
    : `var(--pane-tint-${variant})`;

  return (
    <motion.div
      ref={ref}
      data-slot="pane"
      data-variant={variant}
      className={cn(
        paneVariants({ variant, interactive }),
        "overflow-hidden rounded-[var(--pane-radius)]",
        className,
      )}
      style={
        {
          ...style,
          "--pane-radius": `${radius}px`,
          backdropFilter,
          WebkitBackdropFilter: backdropFilter,
          // Tailwind's ring utilities are box-shadows too, so the pane's own
          // shadow has to carry their variables or an inline value would
          // wipe out every `ring-*` class — focus rings included.
          boxShadow:
            "var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), 0 1px 1px 0 var(--pane-shadow), 0 16px 40px -20px var(--pane-shadow)",
        } as React.CSSProperties
      }
      whileTap={interactive ? { scale: 0.97 } : undefined}
      whileHover={interactive ? { scale: 1.015 } : undefined}
      transition={spring.press}
      {...props}
    >
      {/* body — flat tint only, so content sits on an even surface */}
      <span
        aria-hidden="true"
        className="-z-10 pointer-events-none absolute inset-0 rounded-[inherit] transition-colors duration-300 ease-out"
        style={{ backgroundColor: tint }}
      />
      {/* inner glow: a short inset feather giving the rim thickness. Kept
          small — a wide one fogs the middle and the backdrop stops showing */}
      <span
        aria-hidden="true"
        className="-z-10 pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-screen transition-opacity duration-300 ease-out"
        style={{
          opacity: active ? 0.36 : 0.3,
          boxShadow: `inset 0 0 ${glow}px -1px var(--pane-highlight)`,
        }}
      />
      {/* rim: bright arcs on opposite sides of the border, the way an edge
          catches a single light source. `--pane-spin` turns them as the surface
          moves through the viewport or the pointer crosses it. */}
      <span
        aria-hidden="true"
        className="-z-10 pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-screen"
        style={{
          ...edgeBand(specular.rim),
          opacity: active ? 1 : 0.85,
          transition: "opacity 300ms ease-out",
          background: `conic-gradient(from var(--pane-spin, ${specular.angle}deg) at 50% 50%, transparent 0deg, var(--pane-highlight) 22deg, transparent 68deg, transparent 112deg, var(--pane-highlight) 158deg, transparent 204deg, transparent 248deg, var(--pane-highlight) 338deg, transparent 360deg)`,
        }}
      />
      {/* glint: a soft blob of light pinned to the pointer, on a wider band
          than the arcs so it reads as a reflection, not a second outline */}
      <span
        aria-hidden="true"
        className="-z-10 pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-screen transition-opacity duration-300 ease-out"
        style={{
          ...edgeBand(glow),
          opacity: active ? specular.intensity : 0,
          background: `radial-gradient(${specular.size}px circle at var(--pane-mx, 50%) var(--pane-my, 32%), var(--pane-highlight), transparent 70%)`,
        }}
      />
      <span
        aria-hidden="true"
        className="-z-10 pointer-events-none absolute inset-0 rounded-[inherit]"
        style={{
          boxShadow: `inset 0 0 0 var(--pane-border-width, 0.5px) var(--pane-border-${variant})`,
        }}
      />
      <NestedPaneContext.Provider value={true}>
        {children}
      </NestedPaneContext.Provider>
      {canDisplace ? (
        <GlassFilter
          id={filterId}
          variant={variant}
          width={width}
          height={height}
          radius={radius}
        />
      ) : null}
    </motion.div>
  );
}

More from pane

Browse all components in this library →