"use client"; import { useEffect, useRef } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion"; /** * The Hired mark, as an object rather than a picture of one. * * Same geometry as `hired-mark.tsx` — the 64-unit grid, bar height 10.9%, gap * 7%, longest bar 50%, corner radius 23.4% — but built out of real elements on * a `preserve-3d` stage, so it has a side, catches a highlight, and turns * towards whoever is pointing at it. This is the front door's mark; the flat * SVG stays the mark everywhere inside the app, where a logo that moves when * you pass the cursor over it would be a distraction rather than a welcome. * * How the depth is made: there is no WebGL here and no dependency added for * one. The slab is LAYERS copies of the same rounded square, each a fraction * of a pixel further back, which is what gives the silhouette a milled edge * that survives the corner radius — four rotated side walls would not, and a * real 3D library is three hundred kilobytes to draw eight rectangles. Each * layer carries a vertical gradient rather than a flat colour, so the sliver of * it that shows reads as a lit bevel. * * The three bars are separate slabs floating above the face. They arrive in * order, shortest first, each growing from its left edge: the mark's meaning is * the record getting longer, and this is that sentence said out loud. * * Colour comes from the same two tokens the flat mark uses, so it inverts with * the theme. `--mark-edge` is the third, defined per theme in globals.css, * because a slab's side has to be lighter than a near-black face and darker * than a near-white one — the same value cannot do both. */ /** Bars as [x, y, width] on the 64-unit grid. Height and radius are shared. */ const BARS = [ [16, 17, 13], [16, 28.5, 22.5], [16, 40, 32], ] as const; const BAR_H = 7; const BAR_R = 1.5; const TILE_R = 15; /** Slices through the slab. More is smoother and costs a div each. */ const LAYERS = 24; /** * How the mark stands when nobody is pointing at it. * * Not square-on. A slab photographed head-on is a rounded rectangle with a * story about depth; a slab turned twenty degrees is obviously an object, * before anything moves at all. The pointer adds to this rather than replacing * it, so the mark never flattens out. */ const REST_Y = -24; const REST_X = 13; export function HiredMark3D({ size = 96, className, /** How far the mark leans when the pointer is at the edge of its field. */ tilt = 12, }: { size?: number; className?: string; tilt?: number; }) { const calm = useReducedMotion(); const host = useRef(null); // Where the pointer is, as -1..1 across the field. The springs are what stop // the mark snapping: it leads the cursor slightly and settles behind it. const px = useMotionValue(0); const py = useMotionValue(0); const sx = useSpring(px, { stiffness: 140, damping: 16, mass: 0.7 }); const sy = useSpring(py, { stiffness: 140, damping: 16, mass: 0.7 }); const rotateY = useTransform(sx, (v) => REST_Y + v * tilt); const rotateX = useTransform(sy, (v) => REST_X - v * tilt); // The highlight rides the far side of the tilt, the way a light source fixed // above the page would. Shadow goes the other way, and softens as it lifts. const sheen = useTransform( [sx, sy], ([x, y]: number[]) => `radial-gradient(58% 52% at ${34 - x * 30}% ${24 - y * 22}%, oklch(1 0 0 / 0.16), oklch(1 0 0 / 0.04) 40%, transparent 66%)`, ); const shadowX = useTransform(sx, (v) => v * size * 0.1); const shadowY = useTransform(sy, (v) => v * size * 0.06); /** * A pointer anywhere in the region gets a response, not just one on the mark * itself — a 96px target you have to hit exactly is a target most people * never find. The field is the nearest positioned ancestor, so a card can * opt its whole surface in by being `relative`. * * On top of that, a drift so slow it reads as the object being lit rather * than as an animation: half a degree either way over about twelve seconds. * It is the one thing on this page that never stops, and it stops for anyone * who has asked their system to calm down. */ useEffect(() => { if (calm) return; const el = host.current; if (!el) return; const field = el.offsetParent instanceof HTMLElement ? el.offsetParent : el; let pointer = { x: 0, y: 0 }; let frame = 0; const start = performance.now(); const onMove = (event: PointerEvent) => { const box = field.getBoundingClientRect(); if (!box.width || !box.height) return; pointer = { x: clamp(((event.clientX - box.left) / box.width) * 2 - 1), y: clamp(((event.clientY - box.top) / box.height) * 2 - 1), }; }; const onLeave = () => { pointer = { x: 0, y: 0 }; }; const tick = (now: number) => { const t = (now - start) / 1000; px.set(pointer.x + Math.sin(t / 1.9) * 0.06); py.set(pointer.y + Math.sin(t / 2.7 + 1.2) * 0.05); frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); window.addEventListener("pointermove", onMove, { passive: true }); window.addEventListener("pointerleave", onLeave); return () => { cancelAnimationFrame(frame); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerleave", onLeave); }; }, [calm, px, py]); const u = size / 64; // one grid unit, in pixels const depth = size * 0.24; const step = depth / LAYERS; return (
{/* What the mark is standing on. Not a drop shadow — it tracks the tilt, so the object and its shadow agree about where the light is. */} {/* The slab. Layer 0 is the face; everything behind it only ever shows as the rim, which is why the rim is where the gradient goes. */} {Array.from({ length: LAYERS }, (_, i) => (
))} {/* The bars, lifted off the face and extruded in their turn. */} {BARS.map(([x, y, w], index) => (
{Array.from({ length: 5 }, (_, layer) => (
))}
))} {/* Glass. One highlight that moves with the tilt, and a hairline along the top edge — the same rim light every raised surface in the app wears, which is what ties this to the rest of the interface. */}
); } function clamp(value: number) { return Math.max(-1, Math.min(1, value)); }