threecn
uidna-helixsource: ok · hit
DnaHelix
A rotating double helix of spheres joined by rungs, colored with --primary and --accent.
npx shadcn add "https://threecn.dev/r/dna-helix.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/9/2026, 5:39:22 AM
components/threecn/dna-helix.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"
type Node = {
y: number
angle: number
a: [number, number, number]
b: [number, number, number]
}
function buildHelix(count: number, radius: number, height: number): Node[] {
const nodes: Node[] = []
const turns = 2.2
for (let i = 0; i < count; i++) {
const t = count === 1 ? 0 : i / (count - 1)
const angle = t * Math.PI * 2 * turns
const y = (t - 0.5) * height
nodes.push({
y,
angle,
a: [Math.cos(angle) * radius, y, Math.sin(angle) * radius],
b: [Math.cos(angle + Math.PI) * radius, y, Math.sin(angle + Math.PI) * radius],
})
}
return nodes
}
function Helix({
count,
speed,
radius,
theme,
}: {
count: number
speed: number
radius: number
theme: ThemeMode
}) {
const groupRef = React.useRef<THREE.Group>(null)
const { primaryColor, accentColor, mutedColor } = useShadcnTheme(theme)
const nodes = React.useMemo(
() => buildHelix(count, radius, 6),
[count, radius]
)
useFrame((_, delta) => {
if (groupRef.current) groupRef.current.rotation.y += delta * 0.5 * speed
})
return (
<group ref={groupRef}>
{nodes.map((n, i) => (
<group key={i}>
<mesh position={n.a}>
<sphereGeometry args={[0.13, 20, 20]} />
<meshStandardMaterial
color={primaryColor}
emissive={primaryColor}
emissiveIntensity={0.25}
metalness={0.5}
roughness={0.25}
/>
</mesh>
<mesh position={n.b}>
<sphereGeometry args={[0.13, 20, 20]} />
<meshStandardMaterial
color={accentColor}
emissive={accentColor}
emissiveIntensity={0.15}
metalness={0.5}
roughness={0.25}
/>
</mesh>
{i % 2 === 0 ? (
<mesh position={[0, n.y, 0]} rotation={[0, -n.angle, 0]}>
<boxGeometry args={[radius * 2, 0.05, 0.05]} />
<meshStandardMaterial
color={mutedColor}
transparent
opacity={0.6}
/>
</mesh>
) : null}
</group>
))}
</group>
)
}
export type DnaHelixProps = {
/** Number of base pairs (nodes per strand). */
count?: number
/** Rotation speed multiplier. */
speed?: number
/** Helix radius. */
radius?: number
className?: string
theme?: ThemeMode
environment?: SceneContainerProps["environment"]
}
/**
* A rotating DNA-style double helix: two strands of spheres (`--primary` and
* `--accent`) joined by `--muted-foreground` rungs. Self-contained, theme-aware.
*/
export function DnaHelix({
count = 24,
speed = 1,
radius = 1.1,
className,
theme = "auto",
environment = "city",
}: DnaHelixProps) {
return (
<SceneContainer
className={className}
theme={theme}
environment={environment}
camera={[0, 0, 7]}
fov={42}
>
<Helix count={count} speed={speed} radius={radius} 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
}