threecn
uicurl-flowsource: ok · refreshed
CurlFlow
Particles advected through a divergence-free curl-noise field into silky filaments, tinted --accent to --primary by height.
npx shadcn add "https://threecn.dev/r/curl-flow.json"free
Where it goes
blog-list
Visual style
particle
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · heavythreewebglthree@react-three/fiber@react-three/drei
Files3 · source cached 10/8/2026, 10:49:49 AM
components/threecn/curl-flow.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 = 3.4
/** A smooth scalar potential; three seeded copies form a vector potential. */
function potential(x: number, y: number, z: number, t: number, seed: number) {
return (
Math.sin(x * 0.7 + seed + t * 0.3) +
Math.cos(y * 0.9 - seed * 1.3 + t * 0.2) +
Math.sin(z * 0.8 + seed * 0.7 - t * 0.25)
)
}
function seedSphere(count: number, radius: number) {
const arr = new Float32Array(count * 3)
for (let i = 0; i < count; i++) {
const r = Math.cbrt(Math.random()) * radius
const theta = Math.random() * Math.PI * 2
const phi = Math.acos(2 * Math.random() - 1)
arr[i * 3] = r * Math.sin(phi) * Math.cos(theta)
arr[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta)
arr[i * 3 + 2] = r * Math.cos(phi)
}
return arr
}
function Filaments({
count,
speed,
size,
theme,
}: {
count: number
speed: number
size: number
theme: ThemeMode
}) {
const { primaryColor, accentColor, isDark } = useShadcnTheme(theme)
const geoRef = React.useRef<THREE.BufferGeometry>(null)
const positions = React.useMemo(
() => seedSphere(count, BOUND * 0.9),
[count]
)
const colors = React.useMemo(() => new Float32Array(count * 3), [count])
useFrame((state, delta) => {
const geo = geoRef.current
if (!geo) return
const posAttr = geo.getAttribute("position") as THREE.BufferAttribute
const colAttr = geo.getAttribute("color") as THREE.BufferAttribute
const pos = posAttr.array as Float32Array
const cols = colAttr.array as Float32Array
const t = state.clock.getElapsedTime()
const dt = Math.min(delta, 0.05) * speed
const e = 0.1
const inv2e = 1 / (2 * e)
const col = new THREE.Color()
for (let i = 0; i < count; i++) {
const o = i * 3
const x = pos[o]
const y = pos[o + 1]
const z = pos[o + 2]
// curl of the vector potential (pa, pb, pc) → divergence-free flow.
const dPc_dy =
(potential(x, y + e, z, t, 9.7) - potential(x, y - e, z, t, 9.7)) * inv2e
const dPb_dz =
(potential(x, y, z + e, t, 5.2) - potential(x, y, z - e, t, 5.2)) * inv2e
const dPa_dz =
(potential(x, y, z + e, t, 0) - potential(x, y, z - e, t, 0)) * inv2e
const dPc_dx =
(potential(x + e, y, z, t, 9.7) - potential(x - e, y, z, t, 9.7)) * inv2e
const dPb_dx =
(potential(x + e, y, z, t, 5.2) - potential(x - e, y, z, t, 5.2)) * inv2e
const dPa_dy =
(potential(x, y + e, z, t, 0) - potential(x, y - e, z, t, 0)) * inv2e
const vx = dPc_dy - dPb_dz
const vy = dPa_dz - dPc_dx
const vz = dPb_dx - dPa_dy
let nx = x + vx * dt
let ny = y + vy * dt
let nz = z + vz * dt
// Respawn particles that drift out of the volume.
if (nx * nx + ny * ny + nz * nz > BOUND * BOUND) {
const r = Math.cbrt(Math.random()) * BOUND * 0.6
const theta = Math.random() * Math.PI * 2
const phi = Math.acos(2 * Math.random() - 1)
nx = r * Math.sin(phi) * Math.cos(theta)
ny = r * Math.sin(phi) * Math.sin(theta)
nz = r * Math.cos(phi)
}
pos[o] = nx
pos[o + 1] = ny
pos[o + 2] = nz
// Tint by height: --accent low → --primary high.
const k = THREE.MathUtils.clamp(ny / BOUND + 0.5, 0, 1)
col.copy(accentColor).lerp(primaryColor, k)
cols[o] = col.r
cols[o + 1] = col.g
cols[o + 2] = col.b
}
posAttr.needsUpdate = true
colAttr.needsUpdate = true
})
return (
<points frustumCulled={false}>
<bufferGeometry ref={geoRef}>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial
vertexColors
size={size}
sizeAttenuation
transparent
opacity={isDark ? 0.9 : 0.8}
depthWrite={false}
blending={isDark ? THREE.AdditiveBlending : THREE.NormalBlending}
/>
</points>
)
}
export type CurlFlowProps = {
/** Number of flowing particles. */
count?: number
/** Flow speed multiplier. */
speed?: number
/** Particle size. */
size?: number
className?: string
theme?: ThemeMode
environment?: SceneContainerProps["environment"]
}
/**
* Thousands of particles advected through a divergence-free curl-noise field,
* organizing themselves into drifting silky filaments. Height tints them
* `--accent` → `--primary`; additive glow in dark mode. A flow, not a drizzle.
*/
export function CurlFlow({
count = 2400,
speed = 1,
size = 0.04,
className,
theme = "auto",
environment = "night",
}: CurlFlowProps) {
return (
<SceneContainer
className={className}
theme={theme}
environment={environment}
camera={[0, 0, 8]}
fov={45}
>
<Filaments count={count} speed={speed} size={size} 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
}