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.
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.
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
"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