mm.
← All components

DockRiffle

An icon that fills with photographs while you point at it.

The idea

A gallery's icon should be able to show you the gallery. The hard parts are not the animation: it is not fetching anything until someone has shown interest, and holding the outgoing frame at full opacity underneath the incoming one so the icon never flashes through the gap between two crossfades.

Install

terminal1 line
npm i @mihirmodi/ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

DockRiffle — usage13 lines
import { DockRiffle } from "@mihirmodi/ui";

// Fills its parent, which must be `relative`. It inserts itself inset 2px,
// so a 40px button at radius 12 gets a 36px plate at radius 10 — outer
// radius minus the gap, or the two curves visibly disagree.
<button className="relative h-10 w-10 rounded-[12px]">
  <DockRiffle
    icon={<GalleryIcon />}
    frames={photos}            // { id, src }[]
    play={hovered === "Snaps"} // this icon is the one being pointed at
    armed={dockHovered}        // nothing is fetched before this goes true
  />
</button>

Props

PropNotes
iconReactNodeWhat this covers, and hands back to when you leave.
framesRiffleFrame[]`{ id, src }`. A handful are picked at random per page load, not per hover.
playbooleanTrue while this is the item being pointed at.
armedbooleanNothing is fetched before this goes true. Arm it on interest in the whole dock, not in this icon — that buys the fetches a head start.
countnumberHow many are in the loop.
radiusnumberMatch your button's inner radius: outer radius minus the inset, or the curves disagree.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/DockRiffle.tsx153 lines
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { pickSome } from "../lib/pick";

/** One image in the loop. `id` keys the element; `src` is what gets fetched. */
export type RiffleFrame = { id: string; src: string };

const STEP_MS = 200;
const FADE_MS = 160;

export type DockRiffleProps = {
  /** The icon this covers, and hands back to when you leave. */
  icon: ReactNode;
  /** The collection to draw from. `count` of them are in the loop. */
  frames: readonly RiffleFrame[];
  /** True while this is the item being pointed at. */
  play: boolean;
  /**
   * False until the reader has shown any interest at all. Nothing is fetched
   * before this goes true — see below, it is the whole reason the prop exists.
   */
  armed?: boolean;
  /** How many are in the loop. Default 5. */
  count?: number;
  /** Corner radius of the plate. Match your button's inner radius. Default 10. */
  radius?: number;
};

/**
 * An icon that fills with photographs while you point at it, one giving way to
 * the next, and goes back to being an icon when you leave.
 *
 * **It fills its parent, which must be `relative`.** It inserts itself inset 2px
 * inside the button — which is not an arbitrary number. If your button is 40px at
 * a 12px radius, the plate is 36px at a 10px radius, and that is the nesting rule
 * for concentric corners: an inner radius should be the outer radius minus the
 * gap between them, or the two curves visibly disagree.
 *
 * ── The two things that make it work ─────────────────────────────────────────
 *
 * **Nothing is fetched until `armed`.** A dock is on every page and these are
 * real image files, so a handful per icon is not a cost to pay for chrome nobody
 * has reached for. Arm it on the first sign of interest — the dock being hovered
 * at all, not this icon. That buys the fetches a head start of a few hundred
 * milliseconds before the cursor arrives, which is usually enough. Whatever has
 * not landed simply is not in the loop yet.
 *
 * **The outgoing frame is held at full opacity underneath the incoming one.** Two
 * frames crossfading past each other both sit below 1 in the middle, and the icon
 * flashes through the gap. Only the arriving frame ever animates up; everything
 * older stays opaque and covered.
 *
 * Reduced motion gets the icon and nothing else — not even the fetches.
 */
export function DockRiffle({
  icon,
  frames,
  play,
  armed = true,
  count = 5,
  radius = 10,
}: DockRiffleProps) {
  const reduce = useReducedMotion();
  const box = useRef<HTMLSpanElement>(null);
  const [picks, setPicks] = useState<RiffleFrame[]>([]);
  /** Which have decoded. Only these are in the loop; the rest join as they land. */
  const [loaded, setLoaded] = useState<ReadonlySet<string>>(() => new Set());
  /** Which frame is up. -1 is "none, show the icon". */
  const [tick, setTick] = useState(-1);

  const mark = (src: string) => setLoaded((prev) => (prev.has(src) ? prev : new Set(prev).add(src)));

  // A different handful per page load, chosen once. Re-picking per hover would
  // make every hover a fresh set of downloads. After mount, because the pick is
  // random and the server has no way to agree with it.
  useEffect(() => {
    if (!armed || reduce || picks.length || !frames.length) return;
    setPicks(pickSome(frames, count));
  }, [armed, reduce, picks.length, frames, count]);

  // A file already in cache when it mounts fires its load event before React can
  // hear it, so the ones that are already complete have to be swept up by hand.
  useEffect(() => {
    for (const img of box.current?.querySelectorAll("img") ?? []) {
      const src = img.getAttribute("src");
      if (src && img.complete && img.naturalWidth > 0) mark(src);
    }
  }, [picks]);

  // The loop. Deliberately not keyed on how many have loaded: that number grows
  // over the first hover, and restarting the interval each time a file landed
  // would stutter the riffle back to its first frame. The count is read at render
  // instead, so a photograph that arrives late joins the rotation in progress.
  useEffect(() => {
    if (!play || reduce) {
      setTick(-1);
      return;
    }
    setTick(0);
    const id = window.setInterval(() => setTick((t) => t + 1), STEP_MS);
    return () => window.clearInterval(id);
  }, [play, reduce]);

  const ready = picks.filter((p) => loaded.has(p.src));
  const at = (n: number) => (ready.length ? ready[((n % ready.length) + ready.length) % ready.length] : null);

  const current = tick >= 0 ? at(tick) : null;
  const previous = tick >= 1 ? at(tick - 1) : null;

  return (
    <>
      {icon}
      {picks.length > 0 && (
        <LazyMotion features={domAnimation} strict>
          <span
            ref={box}
            aria-hidden
            className="pointer-events-none absolute inset-[2px] overflow-hidden"
            style={{ borderRadius: radius }}
          >
            {picks.map((frame) => {
              const isCurrent = frame === current;
              return (
                <m.img
                  key={frame.id}
                  src={frame.src}
                  alt=""
                  draggable={false}
                  decoding="async"
                  // Chrome, not content. These must never compete with the page's
                  // own images for bandwidth.
                  fetchPriority="low"
                  onLoad={() => mark(frame.src)}
                  // h-full/w-full as utilities rather than a style rule: Tailwind's
                  // preflight sets `img { height: auto }`, which outranks a plain
                  // `height: 100%` and would let every frame stand at its own
                  // aspect ratio and overflow the button.
                  className="absolute inset-0 h-full w-full object-cover"
                  style={{ zIndex: isCurrent ? 2 : 1 }}
                  initial={false}
                  animate={{ opacity: isCurrent || frame === previous ? 1 : 0 }}
                  transition={{ duration: FADE_MS / 1000, ease: "easeOut" }}
                />
              );
            })}
          </span>
        </LazyMotion>
      )}
    </>
  );
}
packages/ui/src/lib/pick.ts14 lines
/**
 * A random handful, without repeats.
 *
 * Run this on the client, after mount. Picking during render either freezes one
 * handful into the server's HTML or disagrees with it at hydration.
 */
export function pickSome<T>(from: readonly T[], count: number): T[] {
  const pool = [...from];
  const picked: T[] = [];
  while (pool.length && picked.length < count) {
    picked.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]);
  }
  return picked;
}