bottega

three

Particle Field

A 3D particle field rendered with @react-three/fiber, composed over canvas-wrapper so it inherits WebGL feature-detect, lazy mount, and a reduced-motion / no-WebGL poster fallback. Particles span up to three virtual depth planes, each parallaxing against a spring-lagged pointer offset (near moves most, far nearly static) for genuine volumetric depth; depth-keyed size + opacity make it read as a constellation rather than flat noise, with hairline distance-mapped connection threads and an optional accent burst that pulses the near plane to --accent on pointer enter then burns back out. Colours are read from the contract tokens at runtime, so the field themes with the UI and under .dark. The <Canvas> scene is dynamic-imported ssr:false (R3F can't SSR), and each layer disposes its geometry/material on unmount.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/particle-field

Usage

Usagetsx
<ParticleField />

Props

PropTypeDefaultDescription
particleCountnumber120Total particle count across all depth layers. Default 120. Split evenly across the active depth buckets (near/mid/far).
depthLayersnumber3Number of virtual depth planes (1–3). Default 3.
parallaxStrengthnumber28Max pixel-intent offset of the nearest layer on pointer move. Default 28. Deeper layers scale by 0.5 and 0.2.
connectionRadiusnumber90Distance within which particles draw a connecting line. Default 90. 0 disables.
speednumber1Base drift speed multiplier. Default 1.
colorSchemeenumforegroundToken to key particle colour against. Default "foreground".
accentPulseOnHoverbooleantruePulse a fraction of particles to --accent on pointer-enter. Default true.
classNamestringForwarded to the wrapping div for layout (position, inset, z-index).
aspectstring16 / 9CSS aspect-ratio for the mount box. Default "16 / 9".
aria-labelstringDecorative particle field backgroundScreen-reader label for the canvas region.
minHeightstringMin-height of the mount box, forwarded to canvas-wrapper. Default "20rem". Set "0" when the parent already constrains height so the field tracks `aspect`/the container instead of being floored at 20rem.
decorativebooleanfalseWhen true, the field is treated as pure decoration behind content and the whole region is hidden from AT (aria-hidden). Default false (img role + label).

Source

particle-field.tsxtsx
"use client";

import dynamic from "next/dynamic";
import { CanvasWrapper } from "@/registry/three/canvas-wrapper/canvas-wrapper";
import { ParticleFieldPoster } from "./particle-field-poster";

// R3F cannot SSR — load the <Canvas> scene client-side only. The poster shows
// while it loads and on every guarded path (reduced-motion / no-WebGL / offscreen).
const ParticleFieldScene = dynamic(() => import("./particle-field-scene"), {
  ssr: false,
});

export interface ParticleFieldProps {
  /**
   * Total particle count across all depth layers. Default 120. Split evenly
   * across the active depth buckets (near/mid/far).
   */
  particleCount?: number;
  /** Number of virtual depth planes (1–3). Default 3. */
  depthLayers?: number;
  /**
   * Max pixel-intent offset of the nearest layer on pointer move. Default 28.
   * Deeper layers scale by 0.5 and 0.2.
   */
  parallaxStrength?: number;
  /** Distance within which particles draw a connecting line. Default 90. 0 disables. */
  connectionRadius?: number;
  /** Base drift speed multiplier. Default 1. */
  speed?: number;
  /** Token to key particle colour against. Default "foreground". */
  colorScheme?: "foreground" | "accent" | "muted";
  /** Pulse a fraction of particles to --accent on pointer-enter. Default true. */
  accentPulseOnHover?: boolean;
  /** Forwarded to the wrapping div for layout (position, inset, z-index). */
  className?: string;
  /** CSS aspect-ratio for the mount box. Default "16 / 9". */
  aspect?: string;
  /** Screen-reader label for the canvas region. */
  "aria-label"?: string;
  /**
   * Min-height of the mount box, forwarded to canvas-wrapper. Default "20rem".
   * Set "0" when the parent already constrains height so the field tracks
   * `aspect`/the container instead of being floored at 20rem.
   */
  minHeight?: string;
  /**
   * When true, the field is treated as pure decoration behind content and the
   * whole region is hidden from AT (aria-hidden). Default false (img role + label).
   */
  decorative?: boolean;
}

/**
 * A 3D particle field rendered with @react-three/fiber, composed over
 * canvas-wrapper so it inherits the full Bottega 3D contract: WebGL
 * feature-detect, lazy mount, and a reduced-motion / no-WebGL poster fallback.
 *
 * Particles are distributed across up to three virtual depth planes, each
 * moving at a distinct parallax coefficient against the (spring-lagged) pointer
 * offset — near moves most, far nearly static — for genuine spatial depth.
 * Depth-keyed size + opacity make the field read as volumetric; hairline,
 * distance-mapped connection threads evoke a living constellation. On pointer
 * enter, a fraction of the near plane briefly cross-fades to --accent and burns
 * back out. Colours are read from the contract tokens at runtime, so the field
 * themes with the UI (and under .dark) automatically.
 *
 * The scene is dynamic-imported ssr:false (R3F can't SSR) and disposes its GPU
 * geometry/material on unmount.
 */
export function ParticleField({
  particleCount = 120,
  depthLayers = 3,
  parallaxStrength = 28,
  connectionRadius = 90,
  speed = 1,
  colorScheme = "foreground",
  accentPulseOnHover = true,
  className,
  aspect = "16 / 9",
  minHeight,
  "aria-label": ariaLabel = "Decorative particle field background",
  decorative = false,
}: ParticleFieldProps) {
  const field = (
    <CanvasWrapper
      className={decorative ? undefined : className}
      aspect={aspect}
      minHeight={minHeight}
      poster={
        <ParticleFieldPoster
          label={ariaLabel}
          {...(decorative ? { decorative: true } : {})}
        />
      }
    >
      <ParticleFieldScene
        particleCount={particleCount}
        depthLayers={depthLayers}
        parallaxStrength={parallaxStrength}
        connectionRadius={connectionRadius}
        speed={speed}
        colorScheme={colorScheme}
        accentPulseOnHover={accentPulseOnHover}
      />
    </CanvasWrapper>
  );

  // Pure-decoration mode: hide the whole region from assistive tech. Otherwise
  // the poster's role="img" + aria-label announces the field once.
  if (decorative) {
    return (
      <div aria-hidden="true" className={className}>
        {field}
      </div>
    );
  }
  return field;
}

Dependencies

  • three
  • @react-three/fiber
  • @bottega/canvas-wrapper
  • @bottega/tokens