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, and one that plays only 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 every time.
Every chip is the same inline-flex items-center rounded-full row (an icon,
then a label), whether it came from the built-in registry or a
custom object.
resolveItem 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
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 separate
animation on the same node. It has no viewport and no once,
so it fires every time the pointer enters, for the lifetime of
the chip. One element carries two transition objects with two lifespans.
The glow is a shadow instead of 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; it only 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 normalizes every item to one shape. One spring plays once on scroll-in, and an independent spring plays on every hover. Scroll past this wall and nothing happens twice, but the same chip lifts each time you hover it.
- 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, and one that plays only 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 every time.
Every chip is the same inline-flex items-center rounded-full row (an icon,
then a label), whether it came from the built-in registry or a
custom object.
resolveItem 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
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 separate
animation on the same node. It has no viewport and no once,
so it fires every time the pointer enters, for the lifetime of
the chip. One element carries two transition objects with two lifespans.
The glow is a shadow instead of 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; it only 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 normalizes every item to one shape. One spring plays once on scroll-in, and an independent spring plays on every hover. Scroll past this wall and nothing happens twice, but the same chip lifts each time you hover it.
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, and still shows its aria-label when showLabel is off. Only the
two springs are removed.
Motion Score
opacityFade / cross-fade