threecn
uiclothsource: ok · refreshed
Cloth
A real spring-mass cloth (Verlet) hanging from its top edge and rippling under procedural wind, with the theme woven --primary to --accent.
npx shadcn add "https://threecn.dev/r/cloth.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/7/2026, 11:30:34 PM
components/threecn/cloth.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 WIDTH = 4.2
const HEIGHT = 3
const DAMPING = 0.97
const ITERATIONS = 4
type ClothSim = {
positions: Float32Array
colors: Float32Array
indices: Uint32Array
pinned: Uint8Array
homeX: Float32Array
homeY: Float32Array
cA: Uint32Array
cB: Uint32Array
cRest: Float32Array
cols: number
rows: number
}
function buildCloth(segments: number): ClothSim {
const cols = segments + 1
const rows = segments + 1
const n = cols * rows
const positions = new Float32Array(n * 3)
const colors = new Float32Array(n * 3)
const pinned = new Uint8Array(n)
const homeX = new Float32Array(n)
const homeY = new Float32Array(n)
for (let v = 0; v < rows; v++) {
for (let u = 0; u < cols; u++) {
const i = v * cols + u
const x = (u / segments - 0.5) * WIDTH
const y = (0.5 - v / segments) * HEIGHT
positions[i * 3] = x
positions[i * 3 + 1] = y
positions[i * 3 + 2] = 0
homeX[i] = x
homeY[i] = y
if (v === 0) pinned[i] = 1 // top row hangs the cloth
}
}
// Structural constraints: each particle links to its right and lower neighbor.
const restX = WIDTH / segments
const restY = HEIGHT / segments
const cA: number[] = []
const cB: number[] = []
const cRest: number[] = []
for (let v = 0; v < rows; v++) {
for (let u = 0; u < cols; u++) {
const i = v * cols + u
if (u < cols - 1) {
cA.push(i)
cB.push(i + 1)
cRest.push(restX)
}
if (v < rows - 1) {
cA.push(i)
cB.push(i + cols)
cRest.push(restY)
}
}
}
// Two triangles per cell.
const indices = new Uint32Array(segments * segments * 6)
let o = 0
for (let v = 0; v < segments; v++) {
for (let u = 0; u < segments; u++) {
const a = v * cols + u
const b = a + 1
const c = a + cols
const d = c + 1
indices[o++] = a
indices[o++] = c
indices[o++] = b
indices[o++] = b
indices[o++] = c
indices[o++] = d
}
}
return {
positions,
colors,
indices,
pinned,
homeX,
homeY,
cA: Uint32Array.from(cA),
cB: Uint32Array.from(cB),
cRest: Float32Array.from(cRest),
cols,
rows,
}
}
function Drape({
segments,
speed,
wind,
theme,
}: {
segments: number
speed: number
wind: number
theme: ThemeMode
}) {
const { primaryColor, accentColor } = useShadcnTheme(theme)
const geoRef = React.useRef<THREE.BufferGeometry>(null)
const sim = React.useMemo(() => buildCloth(segments), [segments])
// Verlet needs each particle's previous position. It's mutated every frame,
// so it lives in a ref (never read during render) rather than in the memo.
const prevRef = React.useRef<Float32Array | null>(null)
React.useLayoutEffect(() => {
prevRef.current = sim.positions.slice()
}, [sim])
// Weave the theme gradient into the fabric, top → bottom.
React.useLayoutEffect(() => {
const attr = geoRef.current?.getAttribute("color") as
| THREE.BufferAttribute
| undefined
if (!attr) return
const arr = attr.array as Float32Array
const { cols, rows } = sim
const c = new THREE.Color()
for (let v = 0; v < rows; v++) {
c.copy(primaryColor).lerp(accentColor, v / (rows - 1))
for (let u = 0; u < cols; u++) {
const i = (v * cols + u) * 3
arr[i] = c.r
arr[i + 1] = c.g
arr[i + 2] = c.b
}
}
attr.needsUpdate = true
}, [sim, primaryColor, accentColor])
useFrame((state, delta) => {
const geo = geoRef.current
if (!geo) return
const prev = prevRef.current
if (!prev) return
const posAttr = geo.getAttribute("position") as THREE.BufferAttribute
const positions = posAttr.array as Float32Array
const t = state.clock.getElapsedTime()
const dt = Math.min(delta, 0.033)
const dt2 = dt * dt
const { pinned, homeX, homeY, cA, cB, cRest, cols } = sim
// Wind gusts, stronger toward the free bottom edge.
const windZ = (Math.sin(t * 1.3) * 0.5 + 0.6) * 9 * wind * speed
const windX = Math.sin(t * 0.7) * 2 * wind * speed
const gravity = -3.2
// Pin the top row, letting it sway so the whole sheet ripples.
for (let u = 0; u < cols; u++) {
const i = u
const io = i * 3
positions[io] = homeX[i] + Math.sin(t * 0.6 + u * 0.35) * 0.12
positions[io + 1] = homeY[i]
positions[io + 2] = Math.sin(t * 0.9 + u * 0.5) * 0.35
}
// Verlet integrate the free particles.
for (let i = cols; i < pinned.length; i++) {
if (pinned[i]) continue
const io = i * 3
const gust = Math.sin(t * 2 + positions[io + 1] * 0.8)
const ax = windX * (0.6 + 0.4 * gust)
const ay = gravity
const az = windZ * (0.5 + 0.5 * gust)
const px = positions[io]
const py = positions[io + 1]
const pz = positions[io + 2]
positions[io] = px + (px - prev[io]) * DAMPING + ax * dt2
positions[io + 1] = py + (py - prev[io + 1]) * DAMPING + ay * dt2
positions[io + 2] = pz + (pz - prev[io + 2]) * DAMPING + az * dt2
prev[io] = px
prev[io + 1] = py
prev[io + 2] = pz
}
// Relax the distance constraints a few times per frame.
for (let iter = 0; iter < ITERATIONS; iter++) {
for (let k = 0; k < cRest.length; k++) {
const a = cA[k]
const b = cB[k]
const ao = a * 3
const bo = b * 3
const dx = positions[bo] - positions[ao]
const dy = positions[bo + 1] - positions[ao + 1]
const dz = positions[bo + 2] - positions[ao + 2]
const dist = Math.hypot(dx, dy, dz) || 1e-6
const diff = (dist - cRest[k]) / dist
const pa = pinned[a]
const pb = pinned[b]
if (pa && pb) continue
const w = pa || pb ? 1 : 0.5
const mx = dx * diff * w
const my = dy * diff * w
const mz = dz * diff * w
if (!pa) {
positions[ao] += mx
positions[ao + 1] += my
positions[ao + 2] += mz
}
if (!pb) {
positions[bo] -= mx
positions[bo + 1] -= my
positions[bo + 2] -= mz
}
}
}
;(geo.getAttribute("position") as THREE.BufferAttribute).needsUpdate = true
geo.computeVertexNormals()
})
return (
<mesh key={segments}>
<bufferGeometry ref={geoRef}>
<bufferAttribute attach="attributes-position" args={[sim.positions, 3]} />
<bufferAttribute attach="attributes-color" args={[sim.colors, 3]} />
<bufferAttribute attach="index" args={[sim.indices, 1]} />
</bufferGeometry>
<meshStandardMaterial
vertexColors
side={THREE.DoubleSide}
roughness={0.42}
metalness={0.16}
/>
</mesh>
)
}
export type ClothProps = {
/** Grid resolution per side (more = finer fabric, heavier sim). */
segments?: number
/** Overall motion speed multiplier. */
speed?: number
/** Wind strength. */
wind?: number
className?: string
theme?: ThemeMode
environment?: SceneContainerProps["environment"]
}
/**
* A real spring-mass cloth (Verlet integration) hanging from its top edge and
* rippling under procedural wind. The theme gradient is woven top-to-bottom,
* `--primary` → `--accent`, and the surface catches the scene lighting.
*/
export function Cloth({
segments = 26,
speed = 1,
wind = 1,
className,
theme = "auto",
environment = "studio",
}: ClothProps) {
return (
<SceneContainer
className={className}
theme={theme}
environment={environment}
camera={[0, 0, 6.5]}
fov={42}
>
<Drape segments={segments} speed={speed} wind={wind} 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
}