Back to Code Bytes
4 min read
Avatar Group Hover

Description

Avatar Group Hover adds a direction-aware spring to any horizontal row of items. When you hover an avatar, it lifts upward and each neighboring avatar lifts proportionally less based on distance, using an exponential falloff. On mouse leave the whole group returns with a bouncy overshoot easing, giving the interaction a physical, springy feel. The key trick is setting transition-timing-function inline before writing the CSS variables: the browser captures the timing function at the moment a property changes, so hover-in uses a clean ease and hover-out uses the springy cubic-bezier without needing two separate transition declarations.

Example

AL
BK
CM
DP
EW
Hover an avatar

CSS

:root {
  --avatar-lift: -4px;
  --avatar-dur: 320ms;
  --avatar-scale: 1.05;
  --avatar-falloff: 0.45;
  --avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
  --avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
}

/* Hover-spring transition only — bring your own avatar/chip
   styling (size, shape, border, stacking, background). */
.t-avatar {
  transform-origin: center;
  /* translateY before scale so scale doesn't amplify the lift offset. */
  transform: translateY(var(--shift, 0px)) scale(var(--scale-active, 1));
  transition: transform var(--avatar-dur) var(--avatar-ease-in);
  will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
  .t-avatar {
    transition: none !important;
    transform: none !important;
  }
}

React

import { useRef } from "react";
import "./avatar-group-hover.css"; // paste the CSS above

const ITEMS = [
  { initials: "AL", color: "#f87171" },
  { initials: "BK", color: "#60a5fa" },
  { initials: "CM", color: "#34d399" },
];

// Pass any list of items; this component owns only the hover-spring behavior.
// Each item is wrapped in .t-avatar to pick up the transform/transition rules.
export function AvatarGroup({ items }: { items: typeof ITEMS }) {
  const rootRef = useRef<HTMLDivElement>(null);

  const setShifts = (activeIdx: number | null, phase: "in" | "out") => {
    const root = rootRef.current;
    if (!root) return;
    const cs = getComputedStyle(document.documentElement);
    const num = (name: string, fb: number) => {
      const v = parseFloat(cs.getPropertyValue(name));
      return Number.isFinite(v) ? v : fb;
    };
    const ease = (name: string, fb: string) =>
      cs.getPropertyValue(name).trim() || fb;
    const lift = num("--avatar-lift", -4);
    const falloff = num("--avatar-falloff", 0.45);
    const scale = num("--avatar-scale", 1.05);
    // Set timing-function BEFORE writing variables so each transition
    // picks up the correct easing at the moment the property changes.
    const tf =
      phase === "out"
        ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")
        : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");
    root.querySelectorAll<HTMLElement>(".t-avatar").forEach((el, i) => {
      el.style.transitionTimingFunction = tf;
      if (activeIdx == null) {
        el.style.setProperty("--shift", "0px");
        el.style.setProperty("--scale-active", "1");
        return;
      }
      const d = Math.abs(i - activeIdx);
      el.style.setProperty(
        "--shift",
        (lift * Math.pow(falloff, d)).toFixed(3) + "px",
      );
      el.style.setProperty(
        "--scale-active",
        i === activeIdx ? String(scale) : "1",
      );
    });
  };

  return (
    <div
      ref={rootRef}
      className="t-avatar-group"
      style={{ display: "flex" }}
      onMouseLeave={() => setShifts(null, "out")}
    >
      {items.map((item, i) => (
        <div
          key={i}
          className="t-avatar"
          style={{ background: item.color }}
          onMouseEnter={() => setShifts(i, "in")}
        >
          {item.initials}
        </div>
      ))}
    </div>
  );
}

Variables

VariableDefaultNotes
--avatar-lift-4pxsourced from --p11-lift
--avatar-dur320mssourced from --p11-dur
--avatar-scale1.05sourced from --p11-scale
--avatar-falloff0.45sourced from --p11-falloff
--avatar-ease-incubic-bezier(0.22, 1, 0.36, 1)sourced from --p11-ease-in
--avatar-ease-outcubic-bezier(0.34, 3.85, 0.64, 1)sourced from --p11-ease-out

Credit

Adapted from Avatar Group Hover on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.