threecn
uiboidssource: ok · hit
Boids
A living flock steering by separation, alignment and cohesion; each agent tints --accent when slow to --primary at full speed.
npx shadcn add "https://threecn.dev/r/boids.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · heavythreewebglthree@react-three/fiber@react-three/drei
Files3 · source cached 10/8/2026, 4:59:54 PM
components/threecn/boids.tsx
"use client"
import * as React from "react"
import { useFrame } from "@react-three/fiber"
import * as THREE from "three"
import {
SceneContainer,
type SceneContainerProps,
} from "@/components/threecn/scene-container"
import {
useShadcnTheme,
type ThemeMode,
} from "@/components/hooks/use-shadcn-theme"
const BOUND = 4.2
const PERCEPTION = 1.4
const SEPARATION = 0.7
const MAX_SPEED = 2.2
type Swarm = { pos: Float32Array; vel: Float32Array }
function seedSwarm(count: number): Swarm {
const pos = new Float32Array(count * 3)
const vel = new Float32Array(count * 3)
for (let i = 0; i < count; i++) {
const r = Math.cbrt(Math.random()) * BOUND * 0.7
const theta = Math.random() * Math.PI * 2
const phi = Math.acos(2 * Math.random() - 1)
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta)
pos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta)
pos[i * 3 + 2] = r * Math.cos(phi)
vel[i * 3] = (Math.random() - 0.5) * 2
vel[i * 3 + 1] = (Math.random() - 0.5) * 2
vel[i * 3 + 2] = (Math.random() - 0.5) * 2
}
return { pos, vel }
}
function Flock({
count,
speed,
theme,
}: {
count: number
speed: number
theme: ThemeMode
}) {
const { primaryColor, accentColor } = useShadcnTheme(theme)
const meshRef = React.useRef<THREE.InstancedMesh>(null)
const swarmRef = React.useRef<Swarm | null>(null)
// The swarm is mutated every frame, so it lives in a ref (never read during
// render). Seed it whenever the agent count changes.
React.useLayoutEffect(() => {
swarmRef.current = seedSwarm(count)
}, [count])
const dummy = React.useMemo(() => new THREE.Object3D(), [])
const tmpColor = React.useMemo(() => new THREE.Color(), [])
const up = React.useMemo(() => new THREE.Vector3(0, 1, 0), [])
const dir = React.useMemo(() => new THREE.Vector3(), [])
useFrame((_, delta) => {
const mesh = meshRef.current
const swarm = swarmRef.current
if (!mesh || !swarm) return
const dt = Math.min(delta, 0.05) * speed
const { pos, vel } = swarm
for (let i = 0; i < count; i++) {
const io = i * 3
const px = pos[io]
const py = pos[io + 1]
const pz = pos[io + 2]
let ax = 0
let ay = 0
let az = 0 // alignment
let cx = 0
let cy = 0
let cz = 0 // cohesion
let sx = 0
let sy = 0
let sz = 0 // separation
let n = 0
for (let j = 0; j < count; j++) {
if (j === i) continue
const jo = j * 3
const dx = pos[jo] - px
const dy = pos[jo + 1] - py
const dz = pos[jo + 2] - pz
const dist2 = dx * dx + dy * dy + dz * dz
if (dist2 > PERCEPTION * PERCEPTION) continue
n++
ax += vel[jo]
ay += vel[jo + 1]
az += vel[jo + 2]
cx += pos[jo]
cy += pos[jo + 1]
cz += pos[jo + 2]
if (dist2 < SEPARATION * SEPARATION) {
const inv = 1 / (Math.sqrt(dist2) + 1e-5)
sx -= dx * inv
sy -= dy * inv
sz -= dz * inv
}
}
let vx = vel[io]
let vy = vel[io + 1]
let vz = vel[io + 2]
if (n > 0) {
// Alignment + cohesion + separation, gently weighted.
vx += (ax / n - vx) * 0.04 + (cx / n - px) * 0.012 + sx * 0.9
vy += (ay / n - vy) * 0.04 + (cy / n - py) * 0.012 + sy * 0.9
vz += (az / n - vz) * 0.04 + (cz / n - pz) * 0.012 + sz * 0.9
}
// Steer back toward the center when leaving the sphere.
const rad = Math.sqrt(px * px + py * py + pz * pz)
if (rad > BOUND) {
const k = (rad - BOUND) * 0.05
vx -= (px / rad) * k
vy -= (py / rad) * k
vz -= (pz / rad) * k
}
// Clamp speed.
const sp = Math.sqrt(vx * vx + vy * vy + vz * vz) || 1e-5
if (sp > MAX_SPEED) {
const s = MAX_SPEED / sp
vx *= s
vy *= s
vz *= s
}
vel[io] = vx
vel[io + 1] = vy
vel[io + 2] = vz
pos[io] = px + vx * dt
pos[io + 1] = py + vy * dt
pos[io + 2] = pz + vz * dt
// Orient the cone along the velocity and place it.
dir.set(vx, vy, vz).normalize()
dummy.position.set(pos[io], pos[io + 1], pos[io + 2])
dummy.quaternion.setFromUnitVectors(up, dir)
dummy.updateMatrix()
mesh.setMatrixAt(i, dummy.matrix)
// Slow = --accent, fast = --primary.
tmpColor.copy(accentColor).lerp(primaryColor, sp / MAX_SPEED)
mesh.setColorAt(i, tmpColor)
}
mesh.instanceMatrix.needsUpdate = true
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true
})
return (
<instancedMesh
key={count}
ref={meshRef}
args={[undefined, undefined, count]}
>
<coneGeometry args={[0.09, 0.34, 6]} />
<meshStandardMaterial roughness={0.35} metalness={0.3} />
</instancedMesh>
)
}
export type BoidsProps = {
/** Number of agents in the flock. */
count?: number
/** Flight speed multiplier. */
speed?: number
className?: string
theme?: ThemeMode
environment?: SceneContainerProps["environment"]
}
/**
* A living flock: hundreds of instanced agents steering by Reynolds's three
* rules — separation, alignment, cohesion — with a soft pull back to center.
* Each boid tints from `--accent` when slow to `--primary` at full speed.
* One draw call.
*/
export function Boids({
count = 170,
speed = 1,
className,
theme = "auto",
environment = "night",
}: BoidsProps) {
return (
<SceneContainer
className={className}
theme={theme}
environment={environment}
camera={[0, 0, 9]}
fov={45}
>
<Flock count={count} speed={speed} theme={theme} />
</SceneContainer>
)
}
components/threecn/scene-container.tsx
"use client"
import * as React from "react"
import { Canvas } from "@react-three/fiber"
import { OrbitControls } from "@react-three/drei"
import { cn } from "@/lib/utils"
import {
useShadcnTheme,
type ThemeMode,
} from "@/components/hooks/use-shadcn-theme"
export type EnvironmentPreset = "studio" | "city" | "dawn" | "night"
type LightRig = {
ambient: { color: string; intensity: number }
key: { color: string; intensity: number; position: [number, number, number] }
fill: { color: string; intensity: number; position: [number, number, number] }
rim: { color: string; intensity: number; position: [number, number, number] }
}
/**
* Offline lighting rigs. We avoid drei's <Environment preset> because it
* fetches HDRIs from a CDN — scenes must work with zero external assets.
*/
const RIGS: Record<EnvironmentPreset, LightRig> = {
studio: {
ambient: { color: "#ffffff", intensity: 0.6 },
key: { color: "#ffffff", intensity: 2.2, position: [5, 6, 5] },
fill: { color: "#dfe3ff", intensity: 0.8, position: [-6, 2, 4] },
rim: { color: "#ffffff", intensity: 1.4, position: [0, 4, -6] },
},
city: {
ambient: { color: "#c7d2fe", intensity: 0.7 },
key: { color: "#e0e7ff", intensity: 1.8, position: [4, 7, 4] },
fill: { color: "#a5b4fc", intensity: 0.9, position: [-5, 1, 5] },
rim: { color: "#818cf8", intensity: 1.2, position: [-2, 3, -6] },
},
dawn: {
ambient: { color: "#fde2c8", intensity: 0.5 },
key: { color: "#ffb27a", intensity: 2.0, position: [6, 4, 4] },
fill: { color: "#9fb4ff", intensity: 0.7, position: [-5, 2, 4] },
rim: { color: "#ffd9a8", intensity: 1.0, position: [0, 5, -5] },
},
night: {
ambient: { color: "#1e1b4b", intensity: 0.4 },
key: { color: "#a78bfa", intensity: 1.6, position: [4, 5, 4] },
fill: { color: "#4338ca", intensity: 0.6, position: [-5, 1, 4] },
rim: { color: "#7c3aed", intensity: 1.6, position: [-2, 3, -6] },
},
}
function Rig({
preset,
fog,
theme,
}: {
preset: EnvironmentPreset
fog: boolean
theme: ThemeMode
}) {
const { bgColor } = useShadcnTheme(theme)
const rig = RIGS[preset]
return (
<>
{fog ? <fog attach="fog" args={[bgColor.getHex(), 8, 24]} /> : null}
<ambientLight color={rig.ambient.color} intensity={rig.ambient.intensity} />
<directionalLight
color={rig.key.color}
intensity={rig.key.intensity}
position={rig.key.position}
/>
<directionalLight
color={rig.fill.color}
intensity={rig.fill.intensity}
position={rig.fill.position}
/>
<pointLight
color={rig.rim.color}
intensity={rig.rim.intensity * 12}
position={rig.rim.position}
distance={30}
/>
</>
)
}
/**
* Track whether an element is near the viewport. WebGL contexts are a scarce
* resource (browsers cap them at ~8-16), so scenes only hold one while they are
* on (or close to) screen. The generous `rootMargin` pre-mounts a scene just
* before it scrolls into view, avoiding a blank first frame.
*/
function useInView(
ref: React.RefObject<HTMLElement | null>,
rootMargin: string
): boolean {
const [inView, setInView] = React.useState(false)
React.useEffect(() => {
const el = ref.current
if (!el) return
if (typeof IntersectionObserver === "undefined") {
// No observer available: render eagerly rather than never mounting.
// eslint-disable-next-line react-hooks/set-state-in-effect
setInView(true)
return
}
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ rootMargin }
)
observer.observe(el)
return () => observer.disconnect()
}, [ref, rootMargin])
return inView
}
export type SceneContainerProps = {
className?: string
theme?: ThemeMode
environment?: EnvironmentPreset
fog?: boolean
/** Camera position, defaults to a gentle 3/4 view. */
camera?: [number, number, number]
/** Vertical field of view. */
fov?: number
children?: React.ReactNode
/** Rendered as an HTML overlay above the canvas (not inside the 3D scene). */
overlay?: React.ReactNode
/**
* Drag to orbit the camera (rotate only — zoom and pan disabled). Defaults to
* true. Set false if the scene provides its own controls.
*/
orbit?: boolean
/**
* Mount the WebGL canvas only while it is near the viewport, and tear it down
* once it scrolls away. Keeps a grid of many scenes from exhausting the
* browser's WebGL context limit and stops `useFrame` from running offscreen.
* Defaults to true. Set false for a scene that must always render (e.g. a
* persistent hero background).
*/
lazy?: boolean
}
/**
* The canvas wrapper every threecn scene builds on. Sets up a transparent
* canvas (so your CSS background shows through), responsive DPR, a themed
* lighting rig and optional fog.
*/
export function SceneContainer({
className,
theme = "auto",
environment = "studio",
fog = false,
camera = [0, 0, 6],
fov = 45,
children,
overlay,
orbit = true,
lazy = true,
}: SceneContainerProps) {
const wrapRef = React.useRef<HTMLDivElement>(null)
const inView = useInView(wrapRef, "300px")
const active = lazy ? inView : true
return (
<div ref={wrapRef} className={cn("relative h-full w-full", className)}>
{active ? (
<Canvas
dpr={[1, 2]}
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
camera={{ position: camera, fov, near: 0.1, far: 100 }}
>
<Rig preset={environment} fog={fog} theme={theme} />
<React.Suspense fallback={null}>{children}</React.Suspense>
{orbit ? (
<OrbitControls
makeDefault
enablePan={false}
enableZoom={false}
enableDamping
dampingFactor={0.1}
/>
) : null}
</Canvas>
) : null}
{overlay ? <div className="pointer-events-none absolute inset-0">{overlay}</div> : null}
</div>
)
}
components/hooks/use-shadcn-theme.ts
"use client"
import * as React from "react"
import { Color } from "three"
/**
* The set of theme-derived colors exposed to R3F scenes.
* Every value is a live `THREE.Color`, ready to assign to a material.
*/
export type ShadcnThemeColors = {
primaryColor: Color
primaryForegroundColor: Color
bgColor: Color
foregroundColor: Color
borderColor: Color
mutedColor: Color
accentColor: Color
isDark: boolean
}
/** CSS custom properties read from `:root` / `.dark`. */
const CSS_VARS = {
primaryColor: "--primary",
primaryForegroundColor: "--primary-foreground",
bgColor: "--background",
foregroundColor: "--foreground",
borderColor: "--border",
mutedColor: "--muted-foreground",
accentColor: "--accent",
} as const
/**
* Neutral SSR defaults (a dark-ish indigo palette) used before the DOM is
* available, so the first paint is never a flash of pure black.
*/
function createDefaults(): ShadcnThemeColors {
return {
primaryColor: new Color().setHSL(263 / 360, 0.7, 0.6),
primaryForegroundColor: new Color().setHSL(0, 0, 0.98),
bgColor: new Color().setHSL(240 / 360, 0.1, 0.039),
foregroundColor: new Color().setHSL(0, 0, 0.98),
borderColor: new Color().setHSL(240 / 360, 0.037, 0.159),
mutedColor: new Color().setHSL(240 / 360, 0.05, 0.649),
accentColor: new Color().setHSL(240 / 360, 0.037, 0.159),
isDark: true,
}
}
/** Matches a bare shadcn HSL triplet, e.g. "263 70% 50%" (optional "/ alpha"). */
const HSL_TRIPLET = /^-?[\d.]+\s+-?[\d.]+%\s+-?[\d.]+%(\s*\/\s*[\d.]+%?)?$/
/**
* A lazily-created 1x1 canvas 2D context used to normalise arbitrary CSS
* colors. The browser's own color parser resolves ANY format the CSS spec
* supports — including `oklch()` (Tailwind v4 / recent shadcn), `color()`,
* `hsl()`, `rgb()`, hex and named colors — and serialises the result back as
* `#rrggbb` / `rgba(...)`, which `THREE.Color.setStyle` understands.
*/
let probeCtx: CanvasRenderingContext2D | null | undefined
function getProbeCtx(): CanvasRenderingContext2D | null {
if (probeCtx !== undefined) return probeCtx
if (typeof document === "undefined") return (probeCtx = null)
probeCtx = document.createElement("canvas").getContext("2d")
return probeCtx
}
/**
* Normalise any CSS color string to a form THREE can parse (or `null` if the
* browser rejects it). Assigning an invalid color to `fillStyle` is a no-op, so
* we probe with two different sentinels: if the result differs, the input was
* valid and both reflect its normalised value; if it "sticks" to a sentinel,
* the input was invalid.
*/
function normalizeColor(input: string): string | null {
const ctx = getProbeCtx()
if (!ctx) return null
ctx.fillStyle = "#000"
ctx.fillStyle = input
const a = ctx.fillStyle
ctx.fillStyle = "#fff"
ctx.fillStyle = input
const b = ctx.fillStyle
return a === b ? a : null
}
/**
* Parse a raw CSS color (as read from a custom property) into a `THREE.Color`.
*
* shadcn ships colors as bare HSL triplets ("263 70% 50%"), which we wrap in
* `hsl(...)`. Everything else — `oklch()`, `rgb()`, `#hex`, `hsl()`, named — is
* handed to the browser's parser via a canvas probe, so modern oklch-based
* themes resolve correctly instead of silently falling back to the default.
*/
function parseColor(raw: string, target: Color): Color {
const value = raw.trim()
if (!value) return target
const candidate = HSL_TRIPLET.test(value) ? `hsl(${value})` : value
// Preferred path: let the browser resolve any CSS color format.
const normalized = normalizeColor(candidate)
if (normalized) {
try {
return target.setStyle(normalized)
} catch {
/* fall through to the manual paths below */
}
}
// Fallbacks for environments without a canvas (e.g. exotic SSR shims).
const triplet = value.match(/^(-?[\d.]+)\s+(-?[\d.]+)%\s+(-?[\d.]+)%/)
if (triplet) {
const h = parseFloat(triplet[1]) / 360
const s = parseFloat(triplet[2]) / 100
const l = parseFloat(triplet[3]) / 100
return target.setHSL(h, s, l)
}
try {
return target.setStyle(value)
} catch {
return target
}
}
function readColors(previous?: ShadcnThemeColors): ShadcnThemeColors {
if (typeof window === "undefined" || typeof document === "undefined") {
return previous ?? createDefaults()
}
const root = document.documentElement
const styles = getComputedStyle(root)
const base = previous ?? createDefaults()
const next: ShadcnThemeColors = {
...base,
primaryColor: parseColor(
styles.getPropertyValue(CSS_VARS.primaryColor),
base.primaryColor.clone()
),
primaryForegroundColor: parseColor(
styles.getPropertyValue(CSS_VARS.primaryForegroundColor),
base.primaryForegroundColor.clone()
),
bgColor: parseColor(
styles.getPropertyValue(CSS_VARS.bgColor),
base.bgColor.clone()
),
foregroundColor: parseColor(
styles.getPropertyValue(CSS_VARS.foregroundColor),
base.foregroundColor.clone()
),
borderColor: parseColor(
styles.getPropertyValue(CSS_VARS.borderColor),
base.borderColor.clone()
),
mutedColor: parseColor(
styles.getPropertyValue(CSS_VARS.mutedColor),
base.mutedColor.clone()
),
accentColor: parseColor(
styles.getPropertyValue(CSS_VARS.accentColor),
base.accentColor.clone()
),
isDark: root.classList.contains("dark"),
}
return next
}
export type ThemeMode = "auto" | "light" | "dark"
/**
* Bridges your shadcn/ui CSS variables into Three.js.
*
* - Reads `--primary`, `--background`, `--border`, ... on mount.
* - Watches `<html class>` with a MutationObserver and re-reads on dark-mode
* toggles (works with next-themes, which flips the `.dark` class).
* - SSR-safe: returns neutral defaults until the DOM is ready.
*
* @param mode `"auto"` (default) tracks the document theme. `"light"` / `"dark"`
* force a palette by temporarily toggling a detached probe element.
*/
export function useShadcnTheme(mode: ThemeMode = "auto"): ShadcnThemeColors {
const [colors, setColors] = React.useState<ShadcnThemeColors>(() =>
createDefaults()
)
React.useEffect(() => {
if (typeof window === "undefined") return
// These reads pull the live values out of an external system (the DOM's
// computed CSS variables), which is a legitimate effect → state sync.
if (mode !== "auto") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColors((prev) => readForcedMode(mode, prev))
return
}
// Initial read once mounted (handles hydration + system theme).
setColors((prev) => readColors(prev))
const root = document.documentElement
const observer = new MutationObserver(() => {
setColors((prev) => readColors(prev))
})
observer.observe(root, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
})
return () => observer.disconnect()
}, [mode])
return colors
}
/**
* Read the palette as it would appear under a forced light/dark mode.
* We synchronously toggle the `.dark` class on `<html>`, read the computed
* variables, then restore the original class in the same synchronous block.
* getComputedStyle forces a style flush, so the values are accurate and the
* browser never paints the intermediate state (no flicker).
*/
function readForcedMode(
mode: "light" | "dark",
previous: ShadcnThemeColors
): ShadcnThemeColors {
if (typeof document === "undefined") return previous
const root = document.documentElement
const wasDark = root.classList.contains("dark")
const wantDark = mode === "dark"
if (wantDark !== wasDark) root.classList.toggle("dark", wantDark)
const result = readColors(previous)
if (wantDark !== wasDark) root.classList.toggle("dark", wasDark)
return result
}