Hover Transitions

Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.

  • Scroll & Hover Effects
  • AI agency
  • React only · 0 deps
  • 1 file
Full screen

Browser support · Evergreen browsers. Blob morph needs @property; the liquid lens uses an SVG filter on HTML (Chromium, Firefox; Safari crossfades).

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hover-transitions.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/hover-transitions.tsx
1/**
2 * HoverRevealCard — a profile/media card with eight interchangeable hover
3 * transitions. Pass one piece of artwork as `media`; the card renders a muted
4 * (greyscale) base and a full-colour reveal, and the chosen transition moves
5 * 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 from
10 * alternating sides), "focus" (blur to sharp + info panel), "liquid" (an SVG
11 * 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 instant
17 * 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 follows
20 * keyboard focus; on touch, a tap toggles it. Reduced motion swaps state with
21 * 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 match
43const 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 lens
63 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 <article
157 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 <div
193 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 box
263const 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 <HoverRevealCard
688 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`;

More Scroll & Hover Effects. Same file contract.

Scroll Split Card

A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.

Sticky Storytelling

Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.

Text Reveal on Scroll

A manifesto whose words light up as you scroll, with keywords that get a hand-drawn marker underline.