MynduiMyndui
0

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.

01/03One pill, two tokens
Icon token
24×24 mono SVG, currentColor
Label token
font-medium text, same row
tsx
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,};}
01

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.

02/03Enter once, hover forever
Stagger in
delay index × 40ms · spring 320/32/0.9
Hover glow
y −3px snappy 520/32 · glow 240ms
tsx
<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 } }}>
02

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.

tsx
 
{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}
03/03Result
React
TypeScript
Tailwind CSS
Next.js
Node.js
Figma
Rust
PostgreSQL
03

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

tsx
 
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

Stack BadgeSSCompositor-only
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →