Channel switcher

An overlapping avatar stack with circular hit-testing and one floating label that springs between channels, rolling its text up or down.

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>
  );
}