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
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
| Variable | Default | Notes |
|---|---|---|
--avatar-lift | -4px | sourced from --p11-lift |
--avatar-dur | 320ms | sourced from --p11-dur |
--avatar-scale | 1.05 | sourced from --p11-scale |
--avatar-falloff | 0.45 | sourced from --p11-falloff |
--avatar-ease-in | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p11-ease-in |
--avatar-ease-out | cubic-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.