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.
↓Scroll to step through it
One pill, two tokens
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.
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.
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
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. 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.
{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 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.
- 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,};}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.
- 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,};}One pill, two tokens
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.
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.
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.
- Stagger in
- delay index × 40ms · spring 320/32/0.9
- Hover glow
- y −3px snappy 520/32 · glow 240ms
<motion.divinitial={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 } }}>Enter once, hover forever
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. 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.
{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 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.
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.
Motion Score
opacityFade / cross-fade