All components

Prism Hero

Prism Hero — a React and Tailwind layout building block, handcrafted here and yours to ship. Preview it live, copy the exact code, and paste it into your project. No rebuild, no lock-in.

NedDev layout

What it is

Prism Hero — a React and Tailwind layout building block, handcrafted here and yours to ship. Preview it live, copy the exact code, and paste it into your project. No rebuild, no lock-in.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Four free copies a week. No card.

Needs

npm i @react-three/drei @react-three/fiber motion three

Licence

This component was written for this catalogue. It is yours to ship in client work and in products you sell, under the terms of use.

Prism Hero

components/ui/prism-hero.tsx
"use client"

import * as React from "react"
import * as THREE from "three"
import { Canvas, useFrame, useThree } from "@react-three/fiber"
import { MeshTransmissionMaterial, Environment, Lightformer } from "@react-three/drei"
import { motion, useAnimationControls, useReducedMotion } from "motion/react"

/* -------------------------------------------------------------------------- */
/*  Headline texture                                                          */
/* -------------------------------------------------------------------------- */

/**
 * Draws the headline to a canvas so it can live *inside* the 3D scene, which
 * is what lets the crystal actually refract it. Using the page's own fonts
 * keeps the component free of external assets.
 */
/**
 * Canvas `ctx.font` does not understand CSS custom properties, so any
 * `var(--x)` in the stack has to be resolved against the document first —
 * otherwise the assignment is rejected and text silently falls back to 10px.
 */
function resolveFontStack(stack: string): string {
  if (typeof window === "undefined") return stack
  const root = getComputedStyle(document.documentElement)
  return stack.replace(/var\(\s*(--[\w-]+)\s*\)/g, (_m, name: string) => {
    const v = root.getPropertyValue(name).trim()
    return v || "serif"
  })
}

function drawHeadline(
  text: string,
  color: string,
  fontFamily: string,
  italic: boolean
): THREE.CanvasTexture | null {
  if (typeof document === "undefined") return null

  const W = 2048
  const H = 640
  const canvas = document.createElement("canvas")
  canvas.width = W
  canvas.height = H
  const ctx = canvas.getContext("2d")
  if (!ctx) return null

  ctx.clearRect(0, 0, W, H)

  // Fit the headline to the canvas width rather than guessing a size.
  const stack = resolveFontStack(fontFamily)
  const style = italic ? "italic " : ""
  let size = 460
  do {
    ctx.font = `${style}500 ${size}px ${stack}`
    size -= 8
  } while (ctx.measureText(text).width > W * 0.92 && size > 40)

  ctx.fillStyle = color
  ctx.textAlign = "center"
  ctx.textBaseline = "middle"
  ctx.fillText(text, W / 2, H / 2)

  const t = new THREE.CanvasTexture(canvas)
  t.colorSpace = THREE.SRGBColorSpace
  t.anisotropy = 8
  t.needsUpdate = true
  return t
}

/**
 * Built synchronously so the mesh mounts with its map already attached —
 * attaching a map to an already-mounted material is a well-known three.js
 * trap. Redrawn once webfonts land so the real face is baked in.
 */
function useHeadlineTexture(
  text: string,
  color: string,
  fontFamily: string,
  italic: boolean
) {
  // Built once, synchronously, so the mesh mounts with its map attached.
  const [texture, setTexture] = React.useState(() =>
    drawHeadline(text, color, fontFamily, italic)
  )

  React.useEffect(() => {
    let cancelled = false
    const rebake = () => {
      if (!cancelled) setTexture(drawHeadline(text, color, fontFamily, italic))
    }
    // Re-bake once webfonts land, otherwise the fallback face is baked in.
    if (document.fonts?.ready) {
      document.fonts.ready.then(rebake).catch(rebake)
    } else {
      rebake()
    }
    return () => {
      cancelled = true
    }
  }, [text, color, fontFamily, italic])

  React.useEffect(() => () => texture?.dispose(), [texture])

  return texture
}

function Headline({
  texture,
  z = -2.2,
}: {
  texture: THREE.CanvasTexture | null
  z?: number
}) {
  const { viewport } = useThree()

  // Fill most of the frame; the crystal sits in front of it.
  const width = Math.min(viewport.width * 0.96, 16)
  const height = width * (640 / 2048)

  if (!texture) return null

  return (
    <mesh position={[0, 0, z]} renderOrder={-1}>
      <planeGeometry args={[width, height]} />
      <meshBasicMaterial
        map={texture}
        transparent
        depthWrite={false}
        toneMapped={false}
      />
    </mesh>
  )
}

/* -------------------------------------------------------------------------- */
/*  Crystal                                                                   */
/* -------------------------------------------------------------------------- */

function Crystal({
  progress,
  reducedMotion,
  dispersion,
  tint,
  spec,
}: {
  progress: React.RefObject<number>
  reducedMotion: boolean
  dispersion: number
  tint: string
  spec: QualitySpec
}) {
  const ref = React.useRef<THREE.Mesh>(null)
  const pointer = React.useRef({ x: 0, y: 0 })
  const { viewport } = useThree()

  // Size the stone against the *smaller* viewport axis so it never dominates
  // a portrait phone the way a fixed world-radius does.
  const portrait = viewport.width < viewport.height
  const fit = Math.min(viewport.width, viewport.height)
  const baseScale = THREE.MathUtils.clamp(
    fit / 5.1,
    portrait ? 0.3 : 0.42,
    1
  )

  React.useEffect(() => {
    if (reducedMotion) return
    const onMove = (e: PointerEvent) => {
      pointer.current.x = (e.clientX / window.innerWidth - 0.5) * 2
      pointer.current.y = (e.clientY / window.innerHeight - 0.5) * 2
    }
    window.addEventListener("pointermove", onMove, { passive: true })
    return () => window.removeEventListener("pointermove", onMove)
  }, [reducedMotion])

  useFrame((state, delta) => {
    const mesh = ref.current
    if (!mesh) return
    const p = progress.current ?? 0
    const t = state.clock.elapsedTime

    // Slow idle rotation, accelerated by scroll. Never linear — the drift
    // keeps facets catching light at irregular intervals.
    const spin = reducedMotion ? 0 : t * 0.13 + p * Math.PI * 1.1
    mesh.rotation.y = spin
    mesh.rotation.x = Math.sin(t * 0.21) * 0.14 + p * 0.4
    mesh.rotation.z = Math.cos(t * 0.17) * 0.08

    // Ease toward the pointer rather than tracking it exactly.
    const tx = pointer.current.x * 0.35
    const ty = -pointer.current.y * 0.28
    mesh.position.x += (tx - mesh.position.x) * Math.min(1, delta * 2.2)
    mesh.position.y += (ty - mesh.position.y) * Math.min(1, delta * 2.2)

    mesh.scale.setScalar(baseScale * (1 + p * 0.18))
  })

  return (
    <mesh ref={ref} position={[0, 0, 0]}>
      {/* 20 flat facets. Slightly elongated so it reads as cut, not a ball. */}
      <icosahedronGeometry args={[1.32, 0]} />
      <MeshTransmissionMaterial
        transmission={1}
        thickness={1.35}
        roughness={0.03}
        ior={1.92}
        chromaticAberration={dispersion}
        anisotropy={0.25}
        distortion={0.18}
        distortionScale={0.35}
        temporalDistortion={0.06}
        backside={spec.backside}
        backsideThickness={0.5}
        samples={spec.samples}
        resolution={spec.resolution}
        color={tint}
        attenuationColor={tint}
        attenuationDistance={8}
      />
    </mesh>
  )
}

/* -------------------------------------------------------------------------- */
/*  Atmosphere                                                                */
/* -------------------------------------------------------------------------- */

/** Deterministic PRNG — keeps the mote field pure and identical every mount. */
function mulberry32(seed: number) {
  return function () {
    seed |= 0
    seed = (seed + 0x6d2b79f5) | 0
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

/** Slow drifting motes. Gives the frame depth without a texture. */
function Motes({ count = 90, color }: { count?: number; color: string }) {
  const ref = React.useRef<THREE.Points>(null)

  const { positions, speeds } = React.useMemo(() => {
    const rand = mulberry32(1337)
    const pos = new Float32Array(count * 3)
    const spd = new Float32Array(count)
    for (let i = 0; i < count; i++) {
      pos[i * 3] = (rand() - 0.5) * 18
      pos[i * 3 + 1] = (rand() - 0.5) * 11
      pos[i * 3 + 2] = (rand() - 0.5) * 6 - 1
      spd[i] = 0.02 + rand() * 0.05
    }
    return { positions: pos, speeds: spd }
  }, [count])

  useFrame((_, delta) => {
    const pts = ref.current
    if (!pts) return
    const attr = pts.geometry.getAttribute("position") as THREE.BufferAttribute
    const arr = attr.array as Float32Array
    for (let i = 0; i < count; i++) {
      arr[i * 3 + 1] += speeds[i] * delta
      if (arr[i * 3 + 1] > 5.5) arr[i * 3 + 1] = -5.5
    }
    attr.needsUpdate = true
  })

  return (
    <points ref={ref} frustumCulled={false}>
      <bufferGeometry>
        <bufferAttribute
          attach="attributes-position"
          args={[positions, 3]}
          count={count}
          itemSize={3}
        />
      </bufferGeometry>
      <pointsMaterial
        size={0.045}
        color={color}
        transparent
        opacity={0.5}
        sizeAttenuation
        depthWrite={false}
      />
    </points>
  )
}

/**
 * Lifts the stone so the lower third stays free for copy. Portrait viewports
 * get less lift and the whole group is pushed back, otherwise the crystal
 * magnifies the headline into an unreadable smear on a phone.
 */
function FocalGroup({ children }: { children: React.ReactNode }) {
  const { viewport } = useThree()
  const portrait = viewport.width < viewport.height
  return (
    <group position={[0, portrait ? 0.5 : 0.62, portrait ? -0.6 : 0]}>
      {children}
    </group>
  )
}

/* -------------------------------------------------------------------------- */
/*  Scene                                                                     */
/* -------------------------------------------------------------------------- */

function Scene({
  headline,
  headlineColor,
  displayFont,
  italic,
  progress,
  reducedMotion,
  dispersion,
  tint,
  moteColor,
  spec,
}: {
  headline: string
  headlineColor: string
  displayFont: string
  italic: boolean
  progress: React.RefObject<number>
  reducedMotion: boolean
  dispersion: number
  tint: string
  moteColor: string
  spec: QualitySpec
}) {
  const texture = useHeadlineTexture(headline, headlineColor, displayFont, italic)

  return (
    <>
      {/* Studio rig, built from lightformers so nothing is fetched. */}
      <Environment resolution={256}>
        <Lightformer
          intensity={5}
          position={[0, 5, 4]}
          scale={[12, 4, 1]}
          color="#fff6e2"
        />
        <Lightformer
          intensity={3.2}
          position={[-6, 1, 3]}
          scale={[4, 9, 1]}
          color="#bcd6ff"
        />
        <Lightformer
          intensity={2.6}
          position={[6, -2, 2]}
          scale={[5, 6, 1]}
          color="#ffcf96"
        />
        <Lightformer
          intensity={1.8}
          position={[0, -4, -3]}
          scale={[9, 3, 1]}
          color="#ffffff"
        />
      </Environment>

      <FocalGroup>
        <Headline texture={texture} />
        <Crystal
          progress={progress}
          reducedMotion={reducedMotion}
          dispersion={dispersion}
          tint={tint}
          spec={spec}
        />
      </FocalGroup>
      <Motes color={moteColor} count={spec.motes} />
    </>
  )
}

/* -------------------------------------------------------------------------- */
/*  Adaptive quality                                                          */
/* -------------------------------------------------------------------------- */

type Quality = "low" | "medium" | "high"

interface QualitySpec {
  samples: number
  resolution: number
  motes: number
  backside: boolean
  maxDpr: number
}

const QUALITY: Record<Quality, QualitySpec> = {
  // Transmission re-renders the scene into a buffer every frame, so samples
  // and buffer resolution are the two knobs that actually cost money.
  // samples:2 / res:128 left visible colour speckle on the facets; 3/192 is
  // still far cheaper than the desktop tier but reads clean.
  low: { samples: 3, resolution: 192, motes: 30, backside: false, maxDpr: 1.25 },
  medium: { samples: 4, resolution: 256, motes: 55, backside: true, maxDpr: 1.5 },
  high: { samples: 6, resolution: 512, motes: 90, backside: true, maxDpr: 1.75 },
}

/** Stable subscription so the tier re-evaluates if the window is resized. */
function subscribeToViewport(cb: () => void) {
  window.addEventListener("resize", cb)
  return () => window.removeEventListener("resize", cb)
}

function detectQuality(): Quality {
  if (typeof window === "undefined") return "medium"
  const cores = navigator.hardwareConcurrency ?? 4
  const w = window.innerWidth
  if (w < 768 || cores <= 4) return "low"
  if (w < 1440 || cores <= 8) return "medium"
  return "high"
}

/* -------------------------------------------------------------------------- */
/*  Entrance motion                                                           */
/* -------------------------------------------------------------------------- */

/** Springs rather than duration curves — the settle is what reads as costly. */
const ENTER = {
  hidden: { opacity: 0, y: 18 },
  show: {
    opacity: 1,
    y: 0,
    transition: { type: "spring" as const, stiffness: 96, damping: 17, mass: 0.9 },
  },
}

const GROUP = {
  hidden: {},
  show: { transition: { staggerChildren: 0.11, delayChildren: 0.15 } },
}

/* -------------------------------------------------------------------------- */
/*  Public component                                                          */
/* -------------------------------------------------------------------------- */

export interface PrismHeroProps {
  eyebrow?: string
  /** Rendered inside the scene so the crystal refracts it. */
  headline?: string
  description?: string
  action?: React.ReactNode
  secondaryAction?: React.ReactNode
  /** Small facts strip along the bottom. */
  meta?: string[]
  /** Strength of the chromatic split. 0.2 subtle, 0.8 heavy. */
  dispersion?: number
  /** Glass tint. Keep it close to white for a neutral crystal. */
  tint?: string
  background?: string
  foreground?: string
  accent?: string
  /** CSS font-family used for the in-scene headline. */
  displayFont?: string
  italicHeadline?: boolean
  /** Pin progress and ignore scroll — for covers and thumbnails. */
  staticProgress?: number
  /** Extra objects rendered inside the R3F canvas. */
  sceneChildren?: React.ReactNode
  /** Adds top padding so the copy clears a fixed site header. */
  topInset?: boolean
  className?: string
}

export function PrismHero({
  eyebrow = "Bevel UI",
  headline = "Refraction",
  description = "A faceted crystal with real transmission and chromatic dispersion, refracting the headline behind it. No models, no HDRI, no external assets.",
  action,
  secondaryAction,
  meta = ["Procedural geometry", "Real transmission", "Zero assets"],
  dispersion = 0.42,
  tint = "#ffffff",
  background = "#08080B",
  foreground = "#EDE8DF",
  accent = "#C9A961",
  displayFont = "var(--font-display), 'Bodoni Moda', Georgia, serif",
  italicHeadline = false,
  staticProgress,
  sceneChildren,
  topInset = false,
  className,
}: PrismHeroProps) {
  const sectionRef = React.useRef<HTMLDivElement>(null)
  const progress = React.useRef(0)
  const [reducedMotion, setReducedMotion] = React.useState(false)
  const [ready, setReady] = React.useState(false)
  const prefersReduced = useReducedMotion()
  const controls = useAnimationControls()

  // useSyncExternalStore rather than an effect: the server snapshot keeps
  // hydration deterministic, there is no cascading re-render on mount, and the
  // tier re-evaluates for free when the window crosses a breakpoint.
  const quality = React.useSyncExternalStore(
    subscribeToViewport,
    detectQuality,
    () => "medium" as Quality
  )
  const spec = QUALITY[quality]

  // Transmission is the most expensive thing on the page; there is no reason
  // to keep paying for it once the hero has scrolled away.
  const stageRef = React.useRef<HTMLDivElement>(null)
  const [onScreen, setOnScreen] = React.useState(true)

  React.useEffect(() => {
    const el = stageRef.current
    if (!el || typeof IntersectionObserver === "undefined") return
    const io = new IntersectionObserver(
      ([entry]) => setOnScreen(entry.isIntersecting),
      { rootMargin: "120px" }
    )
    io.observe(el)
    return () => io.disconnect()
  }, [])

  // The entrance is decided after hydration, never during render: reading
  // `document.hidden` (or the reduced-motion hook) while rendering diverges
  // from the server output and trips a hydration mismatch. rAF is also
  // throttled in hidden tabs, which would otherwise strand the springs
  // mid-flight and reveal a half-faded hero when the tab regains focus.
  React.useEffect(() => {
    if (prefersReduced || document.hidden) {
      controls.set("show")
      return
    }
    controls.start("show")
  }, [controls, prefersReduced])

  const railRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => {
    const raf = requestAnimationFrame(() => setReady(true))
    return () => cancelAnimationFrame(raf)
  }, [])

  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
    const apply = () => setReducedMotion(mq.matches)
    apply()
    mq.addEventListener("change", apply)
    return () => mq.removeEventListener("change", apply)
  }, [])

  React.useEffect(() => {
    const paint = (p: number) => {
      progress.current = p
      if (railRef.current) railRef.current.style.transform = `scaleX(${p})`
    }

    if (staticProgress !== undefined) {
      paint(Math.min(1, Math.max(0, staticProgress)))
      return
    }
    if (reducedMotion) {
      paint(0)
      return
    }

    let raf = 0
    const update = () => {
      raf = 0
      const el = sectionRef.current
      if (!el) return
      const rect = el.getBoundingClientRect()
      const total = rect.height - window.innerHeight
      paint(total > 0 ? Math.min(1, Math.max(0, -rect.top / total)) : 0)
    }
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(update)
    }

    update()
    window.addEventListener("scroll", onScroll, { passive: true })
    window.addEventListener("resize", onScroll)
    return () => {
      if (raf) cancelAnimationFrame(raf)
      window.removeEventListener("scroll", onScroll)
      window.removeEventListener("resize", onScroll)
    }
  }, [reducedMotion, staticProgress])

  const isStatic = staticProgress !== undefined

  return (
    <div
      ref={sectionRef}
      className={[
        "relative w-full",
        isStatic ? "h-screen" : "h-[260vh]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ background }}
    >
      <div
        ref={stageRef}
        className="sticky top-0 h-screen w-full overflow-hidden"
      >
        {/* Scene ---------------------------------------------------------- */}
        <div
          className="absolute inset-0 transition-opacity duration-[1200ms] ease-out"
          style={{ opacity: ready ? 1 : 0 }}
        >
          <Canvas
            frameloop={onScreen ? "always" : "never"}
            dpr={[1, spec.maxDpr]}
            gl={{
              antialias: true,
              alpha: false,
              powerPreference: "high-performance",
            }}
            camera={{ fov: 40, near: 0.1, far: 60, position: [0, 0, 7] }}
            onCreated={({ gl }) => gl.setClearColor(new THREE.Color(background), 1)}
          >
            <Scene
              headline={headline}
              headlineColor={foreground}
              displayFont={displayFont}
              italic={italicHeadline}
              progress={progress}
              reducedMotion={reducedMotion}
              dispersion={dispersion}
              tint={tint}
              moteColor={accent}
              spec={spec}
            />
            {sceneChildren}
          </Canvas>
        </div>

        {/* Vignette + grain ----------------------------------------------- */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0"
          style={{
            background: `radial-gradient(120% 90% at 50% 45%, transparent 40%, ${background} 100%)`,
          }}
        />
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 opacity-[0.16] mix-blend-overlay"
          style={{
            backgroundImage:
              "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")",
          }}
        />

        {/* Copy ------------------------------------------------------------ */}
        <motion.div
          variants={GROUP}
          initial="hidden"
          animate={controls}
          className={`pointer-events-none absolute inset-0 flex flex-col px-5 sm:px-10 ${
            topInset
              ? "pb-8 pt-24 sm:pb-14 sm:pt-28"
              : "py-8 sm:py-14"
          }`}
        >
          <motion.div variants={ENTER} className="flex items-center gap-3">
            <span
              aria-hidden
              className="h-px w-8"
              style={{ background: accent, opacity: 0.7 }}
            />
            <span
              className="font-[family-name:var(--font-mono)] text-[10px] uppercase tracking-[0.32em]"
              style={{ color: accent }}
            >
              {eyebrow}
            </span>
          </motion.div>

          <div aria-hidden className="flex-1" />

          <div className="mx-auto w-full max-w-2xl text-center">
            <motion.p
              variants={ENTER}
              className="mx-auto max-w-md text-[13px] leading-relaxed sm:text-[15px]"
              style={{ color: foreground, opacity: 0.62 }}
            >
              {description}
            </motion.p>

            {(action || secondaryAction) && (
              <motion.div
                variants={ENTER}
                className="pointer-events-auto mt-6 flex flex-col items-center justify-center gap-3 sm:mt-8 sm:flex-row"
              >
                {action}
                {secondaryAction}
              </motion.div>
            )}
          </div>

          <motion.div
            variants={ENTER}
            className="mt-8 flex flex-wrap items-center justify-between gap-4 sm:mt-14"
          >
            <div className="hidden flex-wrap items-center gap-x-5 gap-y-2 sm:flex">
              {meta.map((m) => (
                <span
                  key={m}
                  className="font-[family-name:var(--font-mono)] text-[10px] uppercase tracking-[0.22em]"
                  style={{ color: foreground, opacity: 0.6 }}
                >
                  {m}
                </span>
              ))}
            </div>

            {!isStatic && (
              <div
                className="h-px w-24 overflow-hidden"
                style={{ background: `${foreground}22` }}
              >
                <div
                  ref={railRef}
                  className="h-full origin-left"
                  style={{ background: accent, transform: "scaleX(0)" }}
                />
              </div>
            )}
          </motion.div>
        </motion.div>
      </div>
    </div>
  )
}

export default PrismHero

Similar components