Distance-falloff lift with bouncy return
Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.
/* Transitions.dev — Avatar group hover */
/*
Usage:
<!-- Apply .t-avatar to each item in your group (avatar,
chip, badge, button — anything). Bring your own size,
shape, and stacking; this stylesheet only owns the
hover transform + transition. -->
<div class="t-avatar-group">
<div class="t-avatar"><!-- your item --></div>
<div class="t-avatar"><!-- your item --></div>
<!-- … -->
</div>
Wire-up (vanilla JS):
On `mouseenter` of any .t-avatar, walk every sibling and
set inline:
el.style.setProperty('--shift',
(lift * Math.pow(falloff, distance)).toFixed(3) + 'px');
el.style.setProperty('--scale-active',
i === activeIdx ? scale : 1);
Set transition-timing-function inline BEFORE the
variable writes — use --avatar-ease-in on hover-in and
--avatar-ease-out on the root's `mouseleave` (resets
--shift to 0 and --scale-active to 1).
*/
: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; }
}
Self-contained component; the styles inject themselves on first import.
// Transitions.dev — Avatar group hover (React, self-contained)
// Drop into any React project — no extra CSS file needed.
import { useRef } from "react";
// ── Styles ──────────────────────────────────────────────
// Auto-injected on first import. Idempotent (guarded by
// the element id) and SSR-safe (no-ops without document).
const __TRANSITION_STYLES = `
: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; }
}
`;
if (typeof document !== "undefined" && !document.getElementById("transitions-p11")) {
const __style = document.createElement("style");
__style.id = "transitions-p11";
__style.textContent = __TRANSITION_STYLES;
document.head.appendChild(__style);
}
// Pair with the CSS from the CSS tab.
// `items` is any list of React nodes (avatars, chips, badges, etc.) —
// this hook only owns the hover-spring transition. Each item is wrapped
// in a .t-avatar so it picks up the transform/transition rules from CSS.
//
// On mouseenter of any sibling, we write per-item:
// --shift = lift * pow(falloff, distance from hovered)
// --scale-active = scale on the hovered item, 1 elsewhere
// Direction-aware easing: we set transition-timing-function inline
// BEFORE the variable writes so the lift uses --avatar-ease-in
// (clean) and the return uses --avatar-ease-out (bouncy spring).
export function AvatarGroup({ items }) {
const rootRef = useRef(null);
const setShifts = (activeIdx, phase) => {
if (!rootRef.current) return;
const cs = getComputedStyle(document.documentElement);
const num = (name, fb) => {
const v = parseFloat(cs.getPropertyValue(name));
return Number.isFinite(v) ? v : fb;
};
const ease = (name, fb) =>
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);
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)");
const els = rootRef.current.querySelectorAll(".t-avatar");
els.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}
onMouseLeave={() => setShifts(null, "out")}
>
{items.map((node, i) => (
<div
key={i}
className="t-avatar"
onMouseEnter={() => setShifts(i, "in")}
>
{node}
</div>
))}
</div>
);
}