Channel switcher
An overlapping avatar stack with circular hit-testing and one floating label that springs between channels, rolling its text up or down.
Loading preview…
Show code
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
export type Channel = {
slug: string;
label: string;
/** Cover image for the avatar; falls back to a muted disc. */
thumb?: string | null;
};
/**
* Which channel is under the pointer: the floating label first (it sits on top),
* then a circular hit inside each overlapping avatar, in paint order. Rects alone
* would hand the corners of one circle to the avatar below it.
*/
function pickChannelIndexFromPoint(clientX: number, clientY: number, wrap: HTMLElement): number | null {
for (const node of document.elementsFromPoint(clientX, clientY)) {
if (!wrap.contains(node)) continue;
const badge = node.closest('[data-hit="badge"]');
const badgeIndex = badge?.getAttribute("data-channel-index");
if (badgeIndex != null) return Number(badgeIndex);
const btn = node.closest("button[data-channel-index]");
if (btn instanceof HTMLButtonElement) {
const r = btn.getBoundingClientRect();
const dx = clientX - (r.left + r.width / 2);
const dy = clientY - (r.top + r.height / 2);
const radius = r.width / 2;
if (dx * dx + dy * dy <= radius * radius) return Number(btn.getAttribute("data-channel-index"));
}
}
return null;
}
const TEXT_EASE = [0.32, 0, 0.25, 1] as const;
/** Cover or fallback disc: fills the button and leans in 7% on hover. */
const FILL =
"block size-full transition-transform duration-[220ms] ease-[cubic-bezier(0.2,0,0,1)] group-hover:scale-[1.07] group-active:scale-100 motion-reduce:transition-none motion-reduce:group-hover:scale-100";
export function ChannelSwitcher({
channels,
activeIndex,
onSelect,
className,
}: {
channels: Channel[];
activeIndex: number;
onSelect: (index: number) => void;
className?: string;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [badgePos, setBadgePos] = useState<{ left: number; bottom: number } | null>(null);
// Flips each time the label changes, so the roll alternates up and down.
const [shufflePhase, setShufflePhase] = useState(0);
const reduceMotion = useReducedMotion() === true;
const displayIndex = hoveredIndex ?? focusedIndex ?? activeIndex;
const displayChannel = channels[displayIndex];
const updateBadgePosition = useCallback(() => {
const wrap = wrapRef.current;
const btn = buttonRefs.current[displayIndex];
if (!wrap || !btn) {
setBadgePos(null);
return;
}
const wr = wrap.getBoundingClientRect();
const br = btn.getBoundingClientRect();
setBadgePos({ left: br.left + br.width / 2 - wr.left, bottom: wr.bottom - br.top + 4 });
}, [displayIndex]);
useLayoutEffect(() => {
updateBadgePosition();
}, [updateBadgePosition]);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const ro = new ResizeObserver(updateBadgePosition);
ro.observe(wrap);
return () => ro.disconnect();
}, [updateBadgePosition]);
const prevDisplayIndex = useRef(displayIndex);
useLayoutEffect(() => {
if (prevDisplayIndex.current === displayIndex) return;
prevDisplayIndex.current = displayIndex;
setShufflePhase((p) => p + 1);
}, [displayIndex]);
const spring = reduceMotion
? ({ type: "tween", duration: 0.2, ease: "easeOut" } as const)
: ({ type: "spring", bounce: 0.18, duration: 0.55 } as const);
const upFirst = shufflePhase % 2 === 0;
const enter = { y: upFirst ? 13 : -13, rotate: upFirst ? -2.2 : 2.2 };
const exit = { y: upFirst ? -11 : 11, rotate: upFirst ? 2.2 : -2.2 };
// Rendered right to left: the first channel sits on top of the stack.
const ordered = channels.map((ch, i) => ({ ch, i })).reverse();
return (
<div
ref={wrapRef}
role="group"
aria-label="Channels"
className={cn("relative flex w-max items-end", className)}
onPointerMove={(e) => {
if (e.pointerType !== "mouse" || !wrapRef.current) return;
setHoveredIndex(pickChannelIndexFromPoint(e.clientX, e.clientY, wrapRef.current));
}}
onPointerLeave={() => setHoveredIndex(null)}
>
{ordered.map(({ ch, i }, fromLeft) => (
<button
key={ch.slug}
type="button"
ref={(el) => {
buttonRefs.current[i] = el;
}}
data-channel-index={i}
aria-label={ch.label}
aria-pressed={i === activeIndex}
onClick={() => onSelect(i)}
onFocus={(e) => {
if (e.currentTarget.matches(":focus-visible")) setFocusedIndex(i);
}}
onBlur={() => setFocusedIndex(null)}
style={{ zIndex: fromLeft + 1 }}
className={cn(
// The background-coloured ring keeps overlapping discs apart, cover or not.
"group relative size-10 shrink-0 cursor-pointer overflow-hidden rounded-full bg-muted p-0 outline-none ring-2 ring-background [&+&]:-ml-3",
"transition-transform duration-[220ms] ease-[cubic-bezier(0.2,0,0,1)] active:scale-[0.96] active:duration-150 motion-reduce:transition-none motion-reduce:active:scale-100",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{ch.thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={ch.thumb}
alt=""
draggable={false}
className={cn("object-cover", FILL)}
/>
) : (
<span aria-hidden className={cn("bg-muted", FILL)} />
)}
</button>
))}
{badgePos && displayChannel ? (
<motion.div
aria-hidden
className="pointer-events-none absolute z-50 -translate-x-1/2"
initial={false}
animate={{ left: badgePos.left, bottom: badgePos.bottom }}
transition={spring}
>
<motion.div
layout
transition={{ layout: spring }}
onClick={() => onSelect(displayIndex)}
className="pointer-events-auto inline-block whitespace-nowrap rounded-xl bg-foreground px-1.5 py-1 text-[10px] font-medium leading-[14px] text-background"
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={displayChannel.slug}
data-hit="badge"
data-channel-index={displayIndex}
className="inline-block"
initial={reduceMotion ? { opacity: 0 } : { ...enter, opacity: 0, filter: "blur(3px)" }}
animate={reduceMotion ? { opacity: 1 } : { y: 0, rotate: 0, opacity: 1, filter: "blur(0px)" }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.08 } }
: {
...exit,
opacity: 0,
filter: "blur(3px)",
transition: {
y: { duration: 0.1, ease: TEXT_EASE },
rotate: { duration: 0.1, ease: TEXT_EASE },
opacity: { duration: 0.09, ease: TEXT_EASE },
filter: { duration: 0.09, ease: TEXT_EASE },
},
}
}
transition={
reduceMotion
? { duration: 0.08, ease: TEXT_EASE }
: {
y: { duration: 0.12, ease: TEXT_EASE },
rotate: { duration: 0.12, ease: TEXT_EASE },
opacity: { duration: 0.11, ease: TEXT_EASE },
filter: { duration: 0.1, ease: TEXT_EASE },
}
}
>
{displayChannel.label}
</motion.span>
</AnimatePresence>
</motion.div>
</motion.div>
) : null}
</div>
);
}