GodUIGodUI
129Follow on X

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.

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 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.

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 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.

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 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

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, and still shows its aria-label when showLabel is off. Only the two springs are removed.

Motion Score

Stack BadgeCC: Paint-triggering
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 →