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):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/particle-fieldUsage
<ParticleField />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| particleCount | number | 120 | Total particle count across all depth layers. Default 120. Split evenly across the active depth buckets (near/mid/far). |
| depthLayers | number | 3 | Number of virtual depth planes (1–3). Default 3. |
| parallaxStrength | number | 28 | Max pixel-intent offset of the nearest layer on pointer move. Default 28. Deeper layers scale by 0.5 and 0.2. |
| connectionRadius | number | 90 | Distance within which particles draw a connecting line. Default 90. 0 disables. |
| speed | number | 1 | Base drift speed multiplier. Default 1. |
| colorScheme | enum | foreground | Token to key particle colour against. Default "foreground". |
| accentPulseOnHover | boolean | true | Pulse a fraction of particles to --accent on pointer-enter. Default true. |
| className | string | — | Forwarded to the wrapping div for layout (position, inset, z-index). |
| aspect | string | 16 / 9 | CSS aspect-ratio for the mount box. Default "16 / 9". |
| aria-label | string | Decorative particle field background | Screen-reader label for the canvas region. |
| minHeight | 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. |
| decorative | boolean | false | 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). |
Source
"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