Todos os componentes

Prism Hero

Prism Hero — um bloco de layout para React e Tailwind, feito à mão aqui e teu para usares. Pré-visualiza ao vivo, copia o código exato e cola no teu projeto. Sem rebuild, sem prisão.

NedDev layout

O que é

Prism Hero — um bloco de layout para React e Tailwind, feito à mão aqui e teu para usares. Pré-visualiza ao vivo, copia o código exato e cola no teu projeto. Sem rebuild, sem prisão.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Quatro cópias grátis por semana. Sem cartão.

Precisa de

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

Licença

Este componente foi escrito para este catálogo. É teu para usares em trabalho de cliente e em produtos que vendas, nos termos de uso.

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

Componentes parecidos