shadimage
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 · heavythree
webglthree@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
}

More from threecn

Browse all components in this library →