Anatomy of the Stack Badge
A tech-stack wall built from one flex row, a lookup table that normalizes built-in and custom logos to the same shape, and two independent springs — one that enters once, one that only ever plays on hover.
A tech-stack wall usually means dropping in a row of brand SVGs and calling it
done. StackBadge treats that row as two problems: normalizing whatever you
hand it (a built-in name or a custom { name, icon, color }) into one uniform
chip shape, and layering exactly two motions onto that chip — an entrance that
fires once, and a hover that fires forever.
One pill, two tokens
Every chip is the same inline-flex items-center rounded-full row — an icon,
then a label — regardless of whether it came from the built-in registry or a
custom object:
- Icon token
- 24×24 mono SVG, currentColor
- Label token
- font-medium text, same row
function resolveItem(item: StackBadgeItem, size: number): ResolvedItem {
if (typeof item === "string") {
const entry: TechEntry = TECH[item];
return {
key: item,
label: entry.label,
color: entry.color,
glow: entry.mono
? "color-mix(in oklch, var(--foreground) 16%, transparent)"
: `${entry.color}3d`,
icon: <TechIcon path={entry.path} label={entry.label} size={size} />,
};
}
return {
key: item.name,
label: item.name,
color: item.color,
glow: item.color.startsWith("#")
? `${item.color}3d`
: "color-mix(in oklch, var(--foreground) 16%, transparent)",
icon: item.icon,
};
}resolveItem is the whole trick: it runs before render and returns the same
{ key, label, color, glow, icon } shape either way. The chip component below
never branches on "is this a built-in tech or a custom one" — it just
destructures ResolvedItem. Mono logos (Next.js, Rust, Vercel — brands
with no color of their own) get a neutral color-mix glow instead of a hex
suffix, so they still warm up on hover without inventing a brand color that
doesn't exist.
Enter once, hover forever
- Stagger in
- delay index × 40ms · spring 320/32/0.9
- Hover glow
- y −3px snappy 520/32 · glow 240ms
<motion.div
initial={reduce || !animateIn ? false : { opacity: 0, y: 8 }}
whileInView={reduce || !animateIn ? undefined : { opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={
reduce || !animateIn
? undefined
: { delay: index * 0.04, type: "spring", stiffness: 320, damping: 32, mass: 0.9 }
}
whileHover={
reduce
? undefined
: { y: -3, transition: { type: "spring", stiffness: 520, damping: 32 } }
}
>viewport={{ once: true }} means the stagger spring is a one-shot: it plays
the moment the row is 10% into view and Framer detaches the observer —
scrolling past and back never replays it. whileHover is a completely
separate animation living on the same node; it has no viewport, no once,
so it fires every time the pointer enters, indefinitely, for the lifetime of
the chip. Two transition objects, two lifespans, one element.
The glow is a shadow, not a background swap
{glow ? (
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full opacity-0 shadow-[0_10px_24px_-14px_var(--glow)] [transition:opacity_240ms_cubic-bezier(0.22,1,0.36,1)] group-hover/chip:opacity-100"
/>
) : null}The halo is a box-shadow that's already painted at opacity-0 — hovering
only flips its opacity to 1 over 240ms, so the glow itself never repaints or
resizes, just fades in. --glow is set once per chip in inline style (the
3d hex suffix or the color-mix fallback from resolveItem), so the
shadow's color is fixed per-item and the transition only ever touches
opacity.
Reduced motion turns off the trigger, not the transition
initial={reduce || !animateIn ? false : { opacity: 0, y: 8 }}
whileHover={reduce ? undefined : { y: -3, ... }}Under prefers-reduced-motion, initial becomes false (Framer renders the
resting style immediately, no animate-in) and whileHover becomes
undefined (no hover animation is registered at all). The chip still renders,
still links, still shows its aria-label when showLabel is off — only the
two springs are removed.
The result
A lookup table that normalizes every item to one shape, one spring that plays once on scroll-in, and one independent spring that plays forever on hover — scroll past this wall and nothing happens twice, but you can hover the same chip a hundred times.