Scroll Split Card
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.
Browser support · Evergreen browsers. Blob morph needs @property; the liquid lens uses an SVG filter on HTML (Chromium, Firefox; Safari crossfades).
npx shadcn@latest add https://freedesignmd.com/r/hover-transitions.json1/**2 * HoverRevealCard — a profile/media card with eight interchangeable hover3 * transitions. Pass one piece of artwork as `media`; the card renders a muted4 * (greyscale) base and a full-colour reveal, and the chosen transition moves5 * between them while an info overlay slides in.6 *7 * Effects: "blob" (organic morph reveal), "diagonal" (corner wipe with sheen),8 * "strips" (vertical shutters sweep through), "pixels" (mosaic dissolve),9 * "iris" (circle opens from the cursor), "slices" (rows slide in from10 * alternating sides), "focus" (blur to sharp + info panel), "liquid" (an SVG11 * displacement lens that bulges under the cursor).12 *13 * Props: effect, media, name, role, caption, info (ReactNode), accent.14 * CSS vars: --fd-ht-accent, --fd-ht-radius, --fd-ht-ink.15 *16 * Browser support: evergreen. "blob" uses @property (falls back to an instant17 * swap without it). "liquid" uses an SVG filter on HTML (Chromium, Firefox;18 * Safari shows the crossfade only).19 * Accessibility: cards are focusable and the reveal state also follows20 * keyboard focus; on touch, a tap toggles it. Reduced motion swaps state with21 * a ≤120ms fade.22 */23import { useEffect, useId, useRef, useState } from "react";24import type { CSSProperties, ReactNode } from "react";25 26export type HoverEffect = "blob" | "diagonal" | "strips" | "pixels" | "iris" | "slices" | "focus" | "liquid";27 28export interface HoverRevealCardProps {29 effect: HoverEffect;30 media: ReactNode;31 name: string;32 role: string;33 caption?: string;34 info?: ReactNode;35 accent?: string;36 className?: string;37 style?: CSSProperties;38}39 40const PIX_COLS = 6;41const PIX_ROWS = 8;42// deterministic pseudo-random delays so SSR and client markup match43const pixDelay = (i: number) => {44 const x = Math.sin(i * 91.7 + 13.3) * 43758.5453;45 return Math.round((x - Math.floor(x)) * 220);46};47 48function bulgeMap(): string {49 const n = 256;50 const c = document.createElement("canvas");51 c.width = c.height = n;52 const ctx = c.getContext("2d");53 if (!ctx) return "";54 const img = ctx.createImageData(n, n);55 for (let y = 0; y < n; y++) {56 for (let x = 0; x < n; x++) {57 const dx = (x / (n - 1)) * 2 - 1;58 const dy = (y / (n - 1)) * 2 - 1;59 const d = Math.min(1, Math.hypot(dx, dy));60 const f = d < 1 ? Math.pow(1 - d * d, 1.6) : 0;61 const i = (y * n + x) * 4;62 // sample toward the centre → magnified lens63 img.data[i] = 128 - dx * f * 127;64 img.data[i + 1] = 128 - dy * f * 127;65 img.data[i + 2] = 128;66 img.data[i + 3] = 255;67 }68 }69 ctx.putImageData(img, 0, 0);70 return c.toDataURL();71}72 73export function HoverRevealCard({ effect, media, name, role, caption, info, accent = "#0A84FF", className, style }: HoverRevealCardProps) {74 const ref = useRef<HTMLElement>(null);75 const mediaRef = useRef<HTMLDivElement>(null);76 const feImg = useRef<SVGFEImageElement>(null);77 const feDisp = useRef<SVGFEDisplacementMapElement>(null);78 const [on, setOn] = useState(false);79 const [touched, setTouched] = useState(false);80 const fid = `fd-ht-f${useId().replace(/:/g, "")}`;81 const lens = useRef({ on: false, x: 0, y: 0, s: 0, v: 0, raf: 0, last: 0, reduced: false });82 83 useEffect(() => {84 const el = ref.current;85 if (!el) return;86 lens.current.reduced = !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;87 if (effect === "liquid" && feImg.current && !lens.current.reduced) {88 const url = bulgeMap();89 feImg.current.setAttribute("href", url);90 feImg.current.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", url);91 }92 return () => cancelAnimationFrame(lens.current.raf);93 }, [effect]);94 95 const LENS = 180;96 const placeLens = () => {97 const L = lens.current;98 if (!feImg.current || !feDisp.current) return;99 feImg.current.setAttribute("x", String(L.x - LENS / 2));100 feImg.current.setAttribute("y", String(L.y - LENS / 2));101 feDisp.current.setAttribute("scale", (L.s * 64).toFixed(2));102 };103 const tickLens = (t: number) => {104 const L = lens.current;105 const dt = Math.min(0.032, (t - L.last) / 1000 || 0.016);106 L.last = t;107 const goal = L.on ? 1 : 0;108 for (let i = 0; i < 2; i++) {109 const h = dt / 2;110 L.v += (-380 * (L.s - goal) - 30 * L.v) * h;111 L.s += L.v * h;112 }113 placeLens();114 if (Math.abs(L.s - goal) < 0.002 && Math.abs(L.v) < 0.002) {115 L.s = goal;116 placeLens();117 L.raf = 0;118 } else L.raf = requestAnimationFrame(tickLens);119 };120 const kickLens = () => {121 const L = lens.current;122 if (effect !== "liquid" || L.reduced) return;123 if (!L.raf) {124 L.last = performance.now();125 L.raf = requestAnimationFrame(tickLens);126 } else placeLens();127 };128 129 const point = (cx: number, cy: number) => {130 const m = mediaRef.current;131 if (!m) return;132 const r = m.getBoundingClientRect();133 const x = cx - r.left;134 const y = cy - r.top;135 m.style.setProperty("--fd-ht-x", `${x.toFixed(1)}px`);136 m.style.setProperty("--fd-ht-y", `${y.toFixed(1)}px`);137 m.style.setProperty("--fd-ht-px", `${((x / r.width) * 100).toFixed(1)}%`);138 m.style.setProperty("--fd-ht-py", `${((y / r.height) * 100).toFixed(1)}%`);139 lens.current.x = x;140 lens.current.y = y;141 };142 const center = () => {143 const m = mediaRef.current;144 if (!m) return;145 const r = m.getBoundingClientRect();146 point(r.left + r.width / 2, r.top + r.height / 2);147 };148 const set = (v: boolean) => {149 setTouched(true);150 setOn(v);151 lens.current.on = v;152 kickLens();153 };154 155 return (156 <article157 ref={ref}158 tabIndex={0}159 className={`fd-ht${className ? " " + className : ""}`}160 data-fx={effect}161 data-on={on ? "" : undefined}162 data-touched={touched ? "" : undefined}163 aria-label={`${name}, ${role}`}164 style={{ ["--fd-ht-accent" as string]: accent, ...style }}165 onPointerEnter={(e) => {166 if (e.pointerType === "touch") return;167 point(e.clientX, e.clientY);168 set(true);169 }}170 onPointerMove={(e) => {171 if (e.pointerType === "touch") return;172 point(e.clientX, e.clientY);173 if (effect === "liquid") kickLens();174 }}175 onPointerLeave={(e) => {176 if (e.pointerType === "touch") return;177 point(e.clientX, e.clientY);178 set(false);179 }}180 onPointerUp={(e) => {181 if (e.pointerType !== "touch") return;182 point(e.clientX, e.clientY);183 set(!on);184 }}185 onFocus={(e) => {186 if (!e.currentTarget.matches(":focus-visible")) return;187 center();188 set(true);189 }}190 onBlur={() => set(false)}191 >192 <div193 ref={mediaRef}194 className="fd-ht-media"195 style={effect === "liquid" ? { filter: `url(#${fid})` } : undefined}196 >197 {effect === "liquid" && (198 <svg className="fd-ht-defs" aria-hidden="true" width="0" height="0">199 <filter id={fid} x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">200 <feFlood floodColor="rgb(128,128,128)" result="flat" />201 <feImage ref={feImg} result="lens" x="-200" y="-200" width={LENS} height={LENS} preserveAspectRatio="none" />202 <feComposite in="lens" in2="flat" operator="over" result="map" />203 <feDisplacementMap ref={feDisp} in="SourceGraphic" in2="map" scale="0" xChannelSelector="R" yChannelSelector="G" />204 </filter>205 </svg>206 )}207 <div className="fd-ht-layer fd-ht-base" aria-hidden="true">208 {media}209 </div>210 211 {effect === "slices" ? (212 [0, 1, 2].map((i) => (213 <div key={i} className="fd-ht-slice" data-i={i} aria-hidden="true">214 <div className="fd-ht-slice-art">{media}</div>215 </div>216 ))217 ) : effect === "blob" ? (218 <>219 <div className="fd-ht-layer fd-ht-blob fd-ht-blob-lead" aria-hidden="true" />220 <div className="fd-ht-layer fd-ht-blob fd-ht-reveal" aria-hidden="true">221 {media}222 </div>223 </>224 ) : (225 <div className="fd-ht-layer fd-ht-reveal" aria-hidden="true">226 {media}227 </div>228 )}229 230 {effect === "diagonal" && <span className="fd-ht-sheen" aria-hidden="true" />}231 {effect === "iris" && <span className="fd-ht-ring" aria-hidden="true" />}232 {effect === "liquid" && <span className="fd-ht-drop" aria-hidden="true" />}233 {effect === "strips" && (234 <div className="fd-ht-strips" aria-hidden="true">235 {[0, 1, 2, 3, 4].map((i) => (236 <span key={i} className="fd-ht-strip" style={{ ["--i" as string]: i }} />237 ))}238 </div>239 )}240 {effect === "pixels" && (241 <div className="fd-ht-pix" aria-hidden="true">242 {Array.from({ length: PIX_COLS * PIX_ROWS }, (_, i) => (243 <span key={i} className="fd-ht-tile" style={{ ["--d" as string]: `${pixDelay(i)}ms` }} />244 ))}245 </div>246 )}247 248 {info && <div className="fd-ht-info">{info}</div>}249 </div>250 <div className="fd-ht-meta">251 <div className="fd-ht-who">252 <h3 className="fd-ht-name">{name}</h3>253 <p className="fd-ht-role">{role}</p>254 </div>255 {caption && <span className="fd-ht-cap">{caption}</span>}256 </div>257 <style>{css}</style>258 </article>259 );260}261 262// organic blob, drawn in a 100×100 box263const BLOB =264 "data:image/svg+xml;utf8," +265 encodeURIComponent(266 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M52 6c13 1 27 6 34 18 8 13 9 27 4 41-5 15-17 27-32 30-16 4-33-2-44-14C4 69 2 52 8 38 15 22 33 5 52 6z"/></svg>',267 );268 269const css = `270.fd-ht{271 --fd-ease-out:cubic-bezier(0.22,1,0.36,1);--fd-ease-in-out:cubic-bezier(0.65,0,0.35,1);272 --fd-spring:cubic-bezier(0.34,1.56,0.64,1);273 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;274 --fd-ht-radius:20px;--fd-ht-ink:#0B0B0F;275 --fd-ht-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;276 --fd-ht-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;277 position:relative;display:flex;flex-direction:column;gap:14px;margin:0;padding:0;278 color:#F5F5F7;background:transparent;outline:none;cursor:default;-webkit-tap-highlight-color:transparent;279}280.fd-ht,.fd-ht *,.fd-ht *::before,.fd-ht *::after{box-sizing:border-box}281.fd-ht:focus-visible .fd-ht-media{outline:2px solid color-mix(in srgb,#0A84FF 70%,transparent);outline-offset:2px}282.fd-ht-media{283 --fd-ht-x:50%;--fd-ht-y:50%;284 position:relative;display:block;aspect-ratio:4/5;width:100%;margin:0;padding:0;overflow:hidden;285 border-radius:var(--fd-ht-radius);background:#16161B;isolation:isolate;286 box-shadow:0 0 0 1px rgba(255,255,255,.06) inset;287}288.fd-ht-defs{position:absolute;width:0;height:0;overflow:hidden}289.fd-ht-layer{position:absolute;inset:0;margin:0;padding:0}290.fd-ht-layer > *,.fd-ht-slice-art > *,.fd-ht-blob-art > *{display:block;width:100%;height:100%}291.fd-ht-base{filter:grayscale(1) contrast(1.06) brightness(.86)}292.fd-ht-reveal{z-index:1}293 294/* info overlay */295.fd-ht-info{296 position:absolute;left:0;right:0;bottom:0;z-index:6;margin:0;padding:48px 18px 16px;297 background:linear-gradient(to top,rgba(8,8,11,.82),rgba(8,8,11,0));color:#F5F5F7;298 font:400 0.8125rem/1.45 var(--fd-ht-sans);letter-spacing:0;299 opacity:0;transform:translate3d(0,14px,0);300 transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out);301 pointer-events:none;302}303.fd-ht[data-on] .fd-ht-info{opacity:1;transform:none;transition-delay:160ms}304 305/* meta */306.fd-ht-meta{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin:0;padding:0 2px}307.fd-ht-who{min-width:0;margin:0;padding:0}308.fd-ht-name{margin:0;padding:0;color:#F5F5F7;font:600 0.9375rem/1.25 var(--fd-ht-sans);letter-spacing:-0.005em}309.fd-ht-role{margin:3px 0 0;padding:0;color:rgba(245,245,247,.56);font:400 0.8125rem/1.3 var(--fd-ht-sans);letter-spacing:0}310.fd-ht-cap{311 flex:none;margin:2px 0 0;padding:0;color:rgba(245,245,247,.4);312 font:500 0.625rem/1.3 var(--fd-ht-mono);letter-spacing:.04em;text-transform:uppercase;white-space:nowrap;313}314.fd-ht[data-on] .fd-ht-cap{color:var(--fd-ht-accent)}315 316/* 1 · blob — an organic SVG mask that grows from the cursor, led by an accent blob */317.fd-ht-blob{318 -webkit-mask-image:url("${BLOB}");mask-image:url("${BLOB}");319 -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;320 -webkit-mask-position:var(--fd-ht-px,50%) var(--fd-ht-py,50%);mask-position:var(--fd-ht-px,50%) var(--fd-ht-py,50%);321 -webkit-mask-size:0% 0%;mask-size:0% 0%;322 transition:-webkit-mask-size var(--fd-dur-3) var(--fd-ease-in-out),mask-size var(--fd-dur-3) var(--fd-ease-in-out);323}324.fd-ht-blob-lead{z-index:1;background:var(--fd-ht-accent)}325.fd-ht-blob.fd-ht-reveal{z-index:2}326.fd-ht[data-on] .fd-ht-blob{327 -webkit-mask-size:340% 300%;mask-size:340% 300%;328 transition:-webkit-mask-size 900ms var(--fd-ease-out),mask-size 900ms var(--fd-ease-out);329}330.fd-ht[data-on] .fd-ht-blob.fd-ht-reveal{transition-delay:90ms}331.fd-ht:not([data-on]) .fd-ht-blob-lead{transition-delay:80ms}332 333/* 2 · diagonal */334.fd-ht[data-fx="diagonal"] .fd-ht-reveal{335 clip-path:polygon(0 100%,0 100%,0 100%);336 transition:clip-path var(--fd-dur-4) var(--fd-ease-in-out);337}338.fd-ht[data-fx="diagonal"][data-on] .fd-ht-reveal{clip-path:polygon(0 100%,0 -100%,200% 100%)}339.fd-ht-sheen{340 position:absolute;left:-50%;top:50%;width:200%;height:2px;z-index:2;pointer-events:none;341 background:linear-gradient(90deg,transparent,rgba(255,255,255,.9) 50%,transparent);342 box-shadow:0 0 18px 4px var(--fd-ht-accent);343 transform:translate3d(-70%,70%,0) rotate(-51deg);opacity:0;344 transition:transform var(--fd-dur-4) var(--fd-ease-in-out),opacity var(--fd-dur-4) var(--fd-ease-in-out);345}346.fd-ht[data-on] .fd-ht-sheen{transform:translate3d(30%,-60%,0) rotate(-51deg);opacity:0;animation:fd-ht-sheen var(--fd-dur-4) var(--fd-ease-in-out)}347@keyframes fd-ht-sheen{0%,100%{opacity:0}20%,70%{opacity:1}}348 349/* 3 · strips */350.fd-ht-strips{position:absolute;inset:0;z-index:3;display:grid;grid-template-columns:repeat(5,1fr);pointer-events:none}351.fd-ht-strip{352 display:block;margin:0 -1px 0 0;height:100%;background:var(--fd-ht-accent);353 transform:translate3d(0,101%,0);354 transition:transform var(--fd-dur-4) var(--fd-ease-in-out) calc(var(--i) * 45ms);355}356.fd-ht[data-on] .fd-ht-strip{transform:translate3d(0,-101%,0)}357.fd-ht[data-fx="strips"] .fd-ht-reveal{opacity:0;transition:opacity 1ms linear 380ms}358.fd-ht[data-fx="strips"][data-on] .fd-ht-reveal{opacity:1}359 360/* 4 · pixels */361.fd-ht-pix{position:absolute;inset:0;z-index:3;display:grid;grid-template-columns:repeat(${PIX_COLS},1fr);grid-template-rows:repeat(${PIX_ROWS},1fr);pointer-events:none}362.fd-ht-tile{display:block;margin:0 -1px -1px 0;background:var(--fd-ht-ink);opacity:0}363.fd-ht-tile:nth-child(3n){background:var(--fd-ht-accent)}364.fd-ht-tile:nth-child(5n+1){background:color-mix(in srgb,var(--fd-ht-accent) 45%,var(--fd-ht-ink))}365.fd-ht-tile:nth-child(7n+2){background:color-mix(in srgb,var(--fd-ht-accent) 70%,#fff)}366/* each tile: on at its delay, all covered at 230ms (swap), off ~240ms later */367.fd-ht[data-touched] .fd-ht-tile{animation:fd-ht-pix-b 480ms linear var(--d) both}368.fd-ht[data-touched][data-on] .fd-ht-tile{animation-name:fd-ht-pix-a}369@keyframes fd-ht-pix-a{0%{opacity:0}1%,50%{opacity:1}51%,100%{opacity:0}}370@keyframes fd-ht-pix-b{0%{opacity:0}1%,50%{opacity:1}51%,100%{opacity:0}}371.fd-ht[data-fx="pixels"] .fd-ht-reveal{opacity:0;transition:opacity 1ms linear 230ms}372.fd-ht[data-fx="pixels"][data-on] .fd-ht-reveal{opacity:1}373 374/* 5 · iris */375.fd-ht[data-fx="iris"] .fd-ht-reveal{376 clip-path:circle(0px at var(--fd-ht-x) var(--fd-ht-y));377 transition:clip-path var(--fd-dur-4) var(--fd-ease-in-out);378}379.fd-ht[data-fx="iris"][data-on] .fd-ht-reveal{clip-path:circle(120% at var(--fd-ht-x) var(--fd-ht-y))}380.fd-ht[data-fx="iris"]:not([data-on]) .fd-ht-reveal{transition-timing-function:var(--fd-ease-in-out);transition-duration:var(--fd-dur-3)}381.fd-ht-ring{382 position:absolute;left:var(--fd-ht-x);top:var(--fd-ht-y);width:40px;height:40px;margin:-20px 0 0 -20px;z-index:2;383 border-radius:50%;border:1.5px solid rgba(255,255,255,.85);pointer-events:none;384 transform:scale(0);opacity:0;385}386.fd-ht[data-on] .fd-ht-ring{animation:fd-ht-ring var(--fd-dur-4) var(--fd-ease-out) both}387@keyframes fd-ht-ring{0%{transform:scale(0);opacity:1}100%{transform:scale(14);opacity:0}}388 389/* 6 · slices */390.fd-ht-slice{position:absolute;left:0;width:100%;height:33.4%;overflow:hidden;z-index:1;background:var(--fd-ht-accent);transform:translate3d(-101%,0,0);391 transition:transform var(--fd-dur-3) var(--fd-ease-in-out)}392.fd-ht-slice[data-i="0"]{top:0}393.fd-ht-slice[data-i="1"]{top:33.3%;transform:translate3d(101%,0,0);transition-delay:60ms}394.fd-ht-slice[data-i="2"]{top:66.6%;transition-delay:120ms}395.fd-ht-slice-art{position:absolute;left:0;top:0;width:100%;height:300%}396.fd-ht-slice[data-i="1"] .fd-ht-slice-art{top:-100%}397.fd-ht-slice[data-i="2"] .fd-ht-slice-art{top:-200%}398.fd-ht-slice-art{transform:translate3d(-14%,0,0);transition:transform var(--fd-dur-4) var(--fd-ease-out)}399.fd-ht-slice[data-i="1"] .fd-ht-slice-art{transform:translate3d(14%,0,0)}400.fd-ht[data-on] .fd-ht-slice,.fd-ht[data-on] .fd-ht-slice .fd-ht-slice-art{transform:none}401 402/* 7 · focus */403.fd-ht[data-fx="focus"] .fd-ht-base{filter:grayscale(.6) blur(10px) brightness(.8);transform:scale(1.12)}404.fd-ht[data-fx="focus"] .fd-ht-reveal{opacity:0;transform:scale(1.08);405 transition:opacity var(--fd-dur-3) var(--fd-ease-out),transform var(--fd-dur-4) var(--fd-ease-out)}406.fd-ht[data-fx="focus"][data-on] .fd-ht-reveal{opacity:1;transform:none}407.fd-ht[data-fx="focus"] .fd-ht-info{408 top:auto;padding:16px 18px 18px;background:rgba(247,247,249,.94);color:#0B0B0F;409 border-radius:0;transform:translate3d(0,100%,0);opacity:1;410 -webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);411}412.fd-ht[data-fx="focus"] .fd-ht-info *{color:#0B0B0F}413.fd-ht[data-fx="focus"][data-on] .fd-ht-info{transform:none;transition-delay:80ms}414 415/* 8 · liquid */416.fd-ht-drop{417 position:absolute;left:var(--fd-ht-x);top:var(--fd-ht-y);width:170px;height:170px;margin:-85px 0 0 -85px;z-index:2;418 border-radius:50%;pointer-events:none;opacity:0;transform:scale(.4);419 background:radial-gradient(circle at 34% 30%,rgba(255,255,255,.55) 0,rgba(255,255,255,0) 22%),420 radial-gradient(circle at 50% 50%,rgba(255,255,255,0) 58%,rgba(255,255,255,.18) 70%,rgba(255,255,255,0) 74%);421 transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring);422}423.fd-ht[data-on] .fd-ht-drop{opacity:1;transform:none}424.fd-ht[data-fx="liquid"] .fd-ht-reveal{opacity:0;transition:opacity var(--fd-dur-3) var(--fd-ease-out)}425.fd-ht[data-fx="liquid"][data-on] .fd-ht-reveal{opacity:1}426 427@media (prefers-reduced-motion: reduce){428 .fd-ht *,.fd-ht *::before{animation:none !important;transition-duration:120ms !important;transition-delay:0ms !important}429 .fd-ht-strips,.fd-ht-pix,.fd-ht-ring,.fd-ht-sheen{display:none}430 .fd-ht-blob{-webkit-mask-image:none;mask-image:none}431 .fd-ht-blob-lead{display:none}432 .fd-ht .fd-ht-reveal{clip-path:none !important;transform:none !important;opacity:0;transition:opacity 120ms linear}433 .fd-ht[data-on] .fd-ht-reveal{opacity:1}434 .fd-ht-slice,.fd-ht-slice-art{transform:none !important;opacity:0;transition:opacity 120ms linear}435 .fd-ht[data-on] .fd-ht-slice{opacity:1}436 .fd-ht[data-on] .fd-ht-slice-art{opacity:1}437}438@media (prefers-contrast: more){.fd-ht-media{box-shadow:0 0 0 1px #F5F5F7 inset}}439`;440 441/* ------------------------------------------------------------------ Demo */442 443type Hair = "short" | "bun" | "long" | "afro" | "bob" | "buzz" | "wave" | "locs";444type PortraitSpec = {445 skin: string;446 hair: Hair;447 hairColor: string;448 top: string;449 collar: "crew" | "turtle" | "shirt" | "hood";450 bg: [string, string];451 glasses?: boolean;452};453 454function shade(hex: string, amt: number) {455 const n = parseInt(hex.slice(1), 16);456 const f = (c: number) => Math.max(0, Math.min(255, Math.round(amt < 0 ? c * (1 + amt) : c + (255 - c) * amt)));457 const r = f((n >> 16) & 255);458 const g = f((n >> 8) & 255);459 const b = f(n & 255);460 return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;461}462 463function Portrait({ p }: { p: PortraitSpec }) {464 const u = useId().replace(/:/g, "");465 const id = (k: string) => `fd-ht-p${u}${k}`;466 const hairBack =467 p.hair === "long" ? (468 <path d="M68 122 C 62 64 100 50 122 50 C 150 50 182 68 176 124 C 178 170 186 214 196 246 L 50 246 C 60 214 70 170 68 122 Z" fill={`url(#${id("h")})`} />469 ) : p.hair === "afro" ? (470 <path d="M58 118 C 50 70 86 42 120 42 C 156 42 192 68 184 120 C 190 148 176 172 162 178 L 80 178 C 64 172 50 146 58 118 Z" fill={`url(#${id("h")})`} />471 ) : p.hair === "locs" ? (472 <g fill={`url(#${id("h")})`}>473 <path d="M70 118 C 64 70 98 52 122 52 C 150 52 178 70 172 118 L 178 232 L 160 232 L 156 170 L 88 170 L 84 232 L 64 232 Z" />474 </g>475 ) : p.hair === "bob" ? (476 <path d="M70 124 C 64 72 98 54 122 54 C 152 54 180 74 174 124 C 176 152 172 172 166 184 L 76 184 C 70 172 66 152 70 124 Z" fill={`url(#${id("h")})`} />477 ) : null;478 479 const hairFront =480 p.hair === "short" ? (481 <path d="M77 126 C 72 82 98 66 122 66 C 150 66 172 84 166 126 C 162 108 150 94 132 92 C 118 100 96 102 84 104 C 80 110 78 118 77 126 Z" fill={`url(#${id("h")})`} />482 ) : p.hair === "bun" ? (483 <g fill={`url(#${id("h")})`}>484 <circle cx="122" cy="60" r="20" />485 <path d="M78 124 C 74 84 100 72 122 72 C 146 72 170 84 166 124 C 158 100 142 90 122 90 C 102 90 86 100 78 124 Z" />486 </g>487 ) : p.hair === "long" ? (488 <path d="M76 132 C 72 84 98 64 122 64 C 150 64 172 84 168 132 C 160 104 146 88 126 86 C 112 98 92 112 76 132 Z" fill={`url(#${id("h")})`} />489 ) : p.hair === "afro" ? null : p.hair === "bob" ? (490 <path d="M76 130 C 74 84 98 66 122 66 C 150 66 172 86 168 130 C 162 104 146 90 122 92 C 104 94 88 106 76 130 Z" fill={`url(#${id("h")})`} />491 ) : p.hair === "buzz" ? (492 <path d="M79 122 C 76 84 100 70 122 70 C 146 70 168 84 165 122 C 158 102 142 92 122 92 C 102 92 86 102 79 122 Z" fill={`url(#${id("h")})`} opacity=".9" />493 ) : p.hair === "wave" ? (494 <path d="M76 128 C 70 80 100 62 124 62 C 156 62 176 84 168 128 C 166 112 160 100 150 94 C 140 104 120 100 108 92 C 98 102 86 112 76 128 Z" fill={`url(#${id("h")})`} />495 ) : (496 <path d="M78 122 C 74 82 100 68 122 68 C 146 68 170 82 166 122 C 160 102 144 90 122 90 C 100 90 86 100 78 122 Z" fill={`url(#${id("h")})`} />497 );498 499 return (500 <svg viewBox="0 0 240 300" preserveAspectRatio="xMidYMid slice" aria-hidden="true" style={{ display: "block", width: "100%", height: "100%" }}>501 <defs>502 <radialGradient id={id("bg")} cx="30%" cy="18%" r="95%">503 <stop offset="0" stopColor={p.bg[0]} />504 <stop offset="1" stopColor={p.bg[1]} />505 </radialGradient>506 <radialGradient id={id("glow")} cx="50%" cy="50%" r="50%">507 <stop offset="0" stopColor="#fff" stopOpacity=".28" />508 <stop offset="1" stopColor="#fff" stopOpacity="0" />509 </radialGradient>510 <radialGradient id={id("s")} cx="38%" cy="32%" r="78%">511 <stop offset="0" stopColor={shade(p.skin, 0.22)} />512 <stop offset=".55" stopColor={p.skin} />513 <stop offset="1" stopColor={shade(p.skin, -0.32)} />514 </radialGradient>515 <linearGradient id={id("n")} x1="0" y1="0" x2="0" y2="1">516 <stop offset="0" stopColor={shade(p.skin, -0.42)} />517 <stop offset="1" stopColor={shade(p.skin, -0.18)} />518 </linearGradient>519 <linearGradient id={id("h")} x1=".2" y1="0" x2=".8" y2="1">520 <stop offset="0" stopColor={shade(p.hairColor, 0.28)} />521 <stop offset=".5" stopColor={p.hairColor} />522 <stop offset="1" stopColor={shade(p.hairColor, -0.45)} />523 </linearGradient>524 <linearGradient id={id("t")} x1=".15" y1="0" x2=".85" y2="1">525 <stop offset="0" stopColor={shade(p.top, 0.22)} />526 <stop offset=".6" stopColor={p.top} />527 <stop offset="1" stopColor={shade(p.top, -0.45)} />528 </linearGradient>529 <linearGradient id={id("rim")} x1="0" y1="0" x2="1" y2="0">530 <stop offset=".6" stopColor="#fff" stopOpacity="0" />531 <stop offset="1" stopColor="#fff" stopOpacity=".45" />532 </linearGradient>533 </defs>534 <rect width="240" height="300" fill={`url(#${id("bg")})`} />535 <circle cx="122" cy="122" r="110" fill={`url(#${id("glow")})`} />536 {hairBack}537 {/* shoulders */}538 <path d="M14 300 C 16 240 58 214 98 206 L 146 206 C 186 214 226 240 228 300 Z" fill={`url(#${id("t")})`} />539 {/* neck */}540 <path d="M102 158 L 102 206 C 110 218 134 218 142 206 L 142 158 Z" fill={`url(#${id("n")})`} />541 {p.collar === "turtle" && <path d="M96 196 C 100 186 144 186 148 196 L 150 222 C 136 230 108 230 94 222 Z" fill={shade(p.top, 0.08)} />}542 {p.collar === "shirt" && (543 <g>544 <path d="M100 204 L 122 238 L 104 246 L 90 214 Z" fill={shade(p.top, 0.35)} />545 <path d="M144 204 L 122 238 L 140 246 L 154 214 Z" fill={shade(p.top, 0.22)} />546 </g>547 )}548 {p.collar === "crew" && <path d="M98 206 C 106 224 138 224 146 206" fill="none" stroke={shade(p.top, -0.3)} strokeWidth="4" strokeLinecap="round" />}549 {p.collar === "hood" && (550 <path d="M74 220 C 84 196 104 196 106 214 C 112 232 132 232 138 214 C 140 196 162 196 170 220 C 156 236 88 236 74 220 Z" fill={shade(p.top, 0.14)} />551 )}552 {/* ears */}553 <ellipse cx="79" cy="130" rx="7" ry="12" fill={shade(p.skin, -0.12)} />554 <ellipse cx="165" cy="130" rx="7" ry="12" fill={shade(p.skin, -0.28)} />555 {/* head */}556 <ellipse cx="122" cy="122" rx="43" ry="53" fill={`url(#${id("s")})`} />557 {/* cheek / jaw shadow and nose hint */}558 <path d="M150 130 C 150 158 138 172 122 175 C 140 176 162 160 164 128 Z" fill={shade(p.skin, -0.35)} opacity=".45" />559 <path d="M126 124 C 128 134 131 141 131 145 C 128 147 125 147 122 146 C 126 145 128 143 127 140 Z" fill={shade(p.skin, -0.3)} opacity=".45" />560 <ellipse cx="122" cy="122" rx="43" ry="53" fill={`url(#${id("rim")})`} />561 {/* features: restrained, editorial */}562 <g opacity=".9">563 <path d="M95 113 C 100 109 108 109 113 112" fill="none" stroke={shade(p.hairColor, 0.05)} strokeWidth="3" strokeLinecap="round" />564 <path d="M131 112 C 136 109 144 109 149 113" fill="none" stroke={shade(p.hairColor, 0.05)} strokeWidth="3" strokeLinecap="round" />565 <ellipse cx="104" cy="126" rx="4.2" ry="3" fill="#1A1412" />566 <ellipse cx="140" cy="126" rx="4.2" ry="3" fill="#1A1412" />567 <circle cx="105.4" cy="125" r="1" fill="#fff" opacity=".8" />568 <circle cx="141.4" cy="125" r="1" fill="#fff" opacity=".8" />569 <path d="M110 157 C 116 161 128 161 134 157 C 128 158 116 158 110 157 Z" fill={shade(p.skin, -0.42)} stroke={shade(p.skin, -0.42)} strokeWidth="2.4" strokeLinejoin="round" />570 <path d="M113 160 C 118 164 126 164 131 160" fill="none" stroke={shade(p.skin, -0.12)} strokeWidth="2" strokeLinecap="round" opacity=".7" />571 </g>572 {hairFront}573 {p.glasses && (574 <g fill="none" stroke="#0F0F12" strokeWidth="2.6" opacity=".9">575 <rect x="90" y="118" width="26" height="18" rx="7" />576 <rect x="128" y="118" width="26" height="18" rx="7" />577 <path d="M116 126 C 120 123 124 123 128 126" />578 </g>579 )}580 </svg>581 );582}583 584const TEAM: {585 name: string;586 role: string;587 effect: HoverEffect;588 label: string;589 accent: string;590 bio: string;591 focus: string;592 p: PortraitSpec;593}[] = [594 {595 name: "Amara Okafor",596 role: "Founder & CEO",597 effect: "blob",598 label: "Morph blob",599 accent: "#FF6B3D",600 bio: "Ex-DeepMind product. Started Northlight to put agents into regulated industries.",601 focus: "London",602 p: { skin: "#7A4A30", hair: "afro", hairColor: "#1B1210", top: "#E9E2D6", collar: "crew", bg: ["#FFB48A", "#B2462A"] },603 },604 {605 name: "Jonas Weber",606 role: "Head of Research",607 effect: "diagonal",608 label: "Diagonal wipe",609 accent: "#4C7DFF",610 bio: "Retrieval and long-context reasoning. 31 papers, 2 that actually shipped.",611 focus: "Berlin",612 p: { skin: "#E8BE9C", hair: "short", hairColor: "#7A5A3A", top: "#2B3A5E", collar: "shirt", bg: ["#A7C0FF", "#2C3F8F"], glasses: true },613 },614 {615 name: "Mei Lin Zhao",616 role: "Principal ML Engineer",617 effect: "strips",618 label: "Vertical strips",619 accent: "#2FBF8F",620 bio: "Builds the eval harness every model has to pass before it reaches a client.",621 focus: "Singapore",622 p: { skin: "#E3B994", hair: "bob", hairColor: "#141018", top: "#1E5A48", collar: "turtle", bg: ["#9EE6C9", "#1E6E55"] },623 },624 {625 name: "Sofia Marquez",626 role: "Design Director",627 effect: "pixels",628 label: "Pixel dissolve",629 accent: "#F2B33D",630 bio: "Designs interfaces for systems that think out loud. Previously at a Lisbon type foundry.",631 focus: "Lisbon",632 p: { skin: "#C98E68", hair: "long", hairColor: "#3A2216", top: "#D9C7A8", collar: "crew", bg: ["#FFD98A", "#B07A1E"] },633 },634 {635 name: "Kwame Mensah",636 role: "Applied AI Lead",637 effect: "iris",638 label: "Circle iris",639 accent: "#B07BFF",640 bio: "Turns prototypes into agents that file claims, reconcile ledgers and don’t hallucinate.",641 focus: "Accra",642 p: { skin: "#5E3A26", hair: "buzz", hairColor: "#120C0A", top: "#2A2440", collar: "hood", bg: ["#D2B6FF", "#5B3AA8"] },643 },644 {645 name: "Elin Lindqvist",646 role: "Product Lead",647 effect: "slices",648 label: "Slice shift",649 accent: "#FF5C8A",650 bio: "Owns the agent console. Believes every AI feature needs an undo button.",651 focus: "Stockholm",652 p: { skin: "#F0CFB4", hair: "bun", hairColor: "#C9A46A", top: "#3A3A44", collar: "turtle", bg: ["#FFB3C8", "#A8325A"] },653 },654 {655 name: "Ravi Sharma",656 role: "Infrastructure",657 effect: "focus",658 label: "Blur to sharp",659 accent: "#3DB8F5",660 bio: "Runs 1.2B tokens a day across three clouds at a 99.98% success rate.",661 focus: "Bengaluru",662 p: { skin: "#A86F4C", hair: "wave", hairColor: "#16110F", top: "#6E8796", collar: "shirt", bg: ["#9FDBFA", "#1F6E96"], glasses: true },663 },664 {665 name: "Noor Haddad",666 role: "Evaluation & Safety",667 effect: "liquid",668 label: "Liquid bulge",669 accent: "#7FD45A",670 bio: "Red-teams every release. Wrote the playbook our clients’ auditors now ask for.",671 focus: "Amman",672 p: { skin: "#C9936E", hair: "locs", hairColor: "#24160F", top: "#2F3B2A", collar: "crew", bg: ["#C6EFA8", "#3F7A2A"] },673 },674];675 676export function Demo() {677 return (678 <div className="fd-ht-demo">679 <style>{demoCss}</style>680 <header className="fd-ht-demo-head">681 <p className="fd-ht-demo-eyebrow">Northlight Labs — Team</p>682 <h2 className="fd-ht-demo-h">Eight people. Every agent we ship.</h2>683 <p className="fd-ht-demo-sub">Hover or tap a portrait — every card uses a different transition.</p>684 </header>685 <div className="fd-ht-demo-grid">686 {TEAM.map((m, i) => (687 <HoverRevealCard688 key={m.name}689 effect={m.effect}690 accent={m.accent}691 name={m.name}692 role={m.role}693 caption={`${String(i + 1).padStart(2, "0")} · ${m.label}`}694 media={<Portrait p={m.p} />}695 info={696 <>697 <span className="fd-ht-demo-loc">{m.focus}</span>698 <span className="fd-ht-demo-bio">{m.bio}</span>699 </>700 }701 />702 ))}703 </div>704 </div>705 );706}707 708const demoCss = `709.fd-ht-demo{display:block;margin:0;padding:clamp(40px,6vw,72px) clamp(16px,4vw,56px) 72px;background:#0B0B0F;color:#F5F5F7;min-height:100vh}710.fd-ht-demo,.fd-ht-demo *{box-sizing:border-box}711.fd-ht-demo-head{max-width:1200px;margin:0 auto 36px;padding:0}712.fd-ht-demo-eyebrow{margin:0 0 12px;padding:0;color:rgba(245,245,247,.5);font:500 0.75rem/1.2 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.06em;text-transform:uppercase}713.fd-ht-demo-h{margin:0;padding:0;color:#F5F5F7;font:650 clamp(2rem,4.4vw,2.75rem)/1.04 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter","Segoe UI",sans-serif;letter-spacing:-0.03em}714.fd-ht-demo-sub{margin:12px 0 0;padding:0;color:rgba(245,245,247,.6);font:400 1.0625rem/1.45 ui-sans-serif,-apple-system,"Inter","Segoe UI",sans-serif}715.fd-ht-demo-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:36px 20px;max-width:1200px;margin:0 auto;padding:0}716@media (max-width:900px){.fd-ht-demo-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:28px 12px}}717.fd-ht-demo-loc{display:block;margin:0 0 4px;font:500 0.6875rem/1.2 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.06em;text-transform:uppercase;opacity:.7}718.fd-ht-demo-bio{display:block;margin:0;font:400 0.8125rem/1.45 ui-sans-serif,-apple-system,"Inter","Segoe UI",sans-serif}719@media (max-width:600px){.fd-ht-demo .fd-ht-cap{display:none}.fd-ht-demo .fd-ht-info{padding:36px 12px 12px}.fd-ht-demo-bio{font-size:0.75rem}}720`;A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.
A manifesto whose words light up as you scroll, with keywords that get a hand-drawn marker underline.