Infinite Plane

Tiled pannable backdrop for the canvas with dot or grid pattern that drifts with the viewport.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/infinite-plane.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

4 stories available:

Code

"use client";

import {
  type ComponentPropsWithoutRef,
  forwardRef,
  type ReactNode,
} from "react";

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

/**
 * Pattern style for the plane backdrop.
 *
 * @public
 */
export type InfinitePlanePattern = "blank" | "dot" | "grid";

/**
 * Localizable strings.
 *
 * @public
 */
export type InfinitePlaneLabels = {
  /** Aria-label override. Defaults to `"Infinite plane"`. */
  region?: string;
};

const DEFAULT_LABELS = {
  region: "Infinite plane",
} as const satisfies Required<InfinitePlaneLabels>;

/**
 * Props for {@link InfinitePlane}.
 *
 * @public
 */
export type InfinitePlaneProps = {
  /** Children render in the plane's coordinate space. */
  children?: ReactNode;
  /** Localizable strings. */
  labels?: InfinitePlaneLabels;
  /** Backdrop pattern. Defaults to `"dot"`. */
  pattern?: InfinitePlanePattern;
  /** Pattern grid spacing in pixels. Defaults to `32`. */
  spacing?: number;
  /** Optional offset applied to the pattern (drift with viewport translation). Defaults to `{ x: 0, y: 0 }`. */
  translate?: { x: number; y: number };
  /** Zoom factor — drives the pattern's effective spacing. Defaults to `1`. */
  zoom?: number;
} & ComponentPropsWithoutRef<"div">;

const safeSpacing = (value: number): number => (value < 4 ? 4 : value);

const safeZoom = (value: number): number => {
  if (value < 0.1) {
    return 0.1;
  }
  if (value > 10) {
    return 10;
  }
  return value;
};

const buildBackground = (input: {
  pattern: InfinitePlanePattern;
  spacing: number;
  translate: { x: number; y: number };
  zoom: number;
}): React.CSSProperties => {
  if (input.pattern === "blank") {
    return {};
  }
  const size = safeSpacing(input.spacing) * safeZoom(input.zoom);
  const pos = `${input.translate.x}px ${input.translate.y}px`;
  if (input.pattern === "grid") {
    return {
      backgroundImage:
        "linear-gradient(to right, hsl(var(--border)) 1px, transparent 1px), linear-gradient(to bottom, hsl(var(--border)) 1px, transparent 1px)",
      backgroundPosition: pos,
      backgroundSize: `${size}px ${size}px`,
    };
  }
  return {
    backgroundImage:
      "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)",
    backgroundPosition: pos,
    backgroundSize: `${size}px ${size}px`,
  };
};

/**
 * Tiled pannable backdrop for the canvas. Renders a `dot` or `grid`
 * pattern that drifts with the viewport translate + scales with the
 * zoom, plus a slot for spatial children that share its coordinate
 * space.
 *
 * Pure presentation; the host owns the viewport transform and supplies
 * `translate` + `zoom` from its pan / zoom controller.
 *
 * @example
 * ```tsx
 * <InfinitePlane translate={{ x: pan.x, y: pan.y }} zoom={zoom}>
 *   <ObjectCard …/>
 *   <ObjectCard …/>
 * </InfinitePlane>
 * ```
 *
 * @public
 */
export const InfinitePlane = forwardRef<HTMLDivElement, InfinitePlaneProps>(
  (props, ref) => {
    const {
      children,
      className,
      labels,
      pattern = "dot",
      spacing = 32,
      translate = { x: 0, y: 0 },
      zoom = 1,
      ...rest
    } = props;
    const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
    const background = buildBackground({ pattern, spacing, translate, zoom });
    return (
      <div
        aria-label={resolvedLabels.region}
        className={cn(
          "relative h-full w-full overflow-hidden bg-background",
          className,
        )}
        data-infinite-plane
        data-infinite-plane-pattern={pattern}
        ref={ref}
        role="region"
        style={background}
        {...rest}
      >
        {children}
      </div>
    );
  },
);
InfinitePlane.displayName = "InfinitePlane";
typescript

Dependencies

  • @vllnt/ui@^0.2.1