UI

Stack Rolodex

A rotating vertical pill stack that cycles through a list rolodex-style, with depth scaling, edge fading, and an optional side label.

Props

Interval2.0s
Spacing50px

Installation

Stack Rolodex is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/stack-rolodex

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the components page.

Usage

import { StackRolodex } from "@/components/ui/stack-rolodex";
import { SiGithub, SiPrisma, SiSupabase, SiVercel } from "react-icons/si";
 
const STACK = [
  { id: "vercel", name: "Vercel", icon: SiVercel },
  { id: "github", name: "GitHub", icon: SiGithub },
  { id: "supabase", name: "Supabase", icon: SiSupabase },
  { id: "prisma", name: "Prisma", icon: SiPrisma },
];
 
export function StackSection() {
  return <StackRolodex items={STACK} label="in my stack" />;
}

The only required prop is items. Each item needs an id, a name, and an icon, which is any component that accepts a className: react-icons, lucide-react, or your own SVG components all work.

Label

label renders a static line beside the stack. Omit it to render the stack on its own.

<StackRolodex items={STACK} />

Brand colors

An item can carry its brand color. When that item reaches the active slot, its icon takes the color, settled with a CSS transition in step with the motion. The pill itself keeps the same neutral styling in every slot.

const STACK = [
  { id: "supabase", name: "Supabase", icon: SiSupabase, color: "#3ECF8E" },
  { id: "stripe", name: "Stripe", icon: SiStripe, color: "#635BFF" },
];

Items without a color keep the default neutral styling, which suits monochrome brands like Vercel or GitHub.

Timing and depth

interval sets the milliseconds between rotations and step the vertical distance between neighbouring pills in pixels. Three pills stay visible on each side of the active one; anything further out fades to nothing, and the stack itself dissolves at both ends through a mask.

<StackRolodex items={STACK} label="in my stack" interval={1500} step={56} />

Props

StackRolodex

PropTypeDefaultNotes
itemsStackRolodexItem[]requiredPills to cycle through. Needs at least two items to rotate.
labelstring-Static line rendered beside the stack. Omit to render the stack alone.
intervalnumber2000Milliseconds between rotations.
stepnumber50Vertical distance between neighbouring pills, in pixels.
classNamestring-Extra classes for the root.

StackRolodexItem

PropTypeDefaultNotes
idstringrequiredStable key for the pill.
namestringrequiredText inside the pill.
iconComponentType<{ className?: string }>requiredAny icon component, sized by the pill.
colorstring-Brand color the icon takes while the pill is active.

Notes & features

  • Continuous wraparound. The active index grows forever and each pill's offset wraps into a centred window, so the rotation never hits an edge or snaps back to the start. Pills that cross the wrap point teleport with a zero-duration transition instead of sweeping across the stack.
  • Depth by distance. Pills shrink and fade the further they sit from the active slot, and the stack is masked at both ends so the top and bottom pills dissolve instead of clipping.
  • Brand colors on the active pill. An optional per-item color tints the active pill's icon while the pill keeps its neutral styling. The color change rides a CSS transition, so only transform and opacity run through Motion.
  • Reduced motion. When the operating system asks for reduced motion, the transitions collapse to zero duration. The rotation still steps, it just stops animating.
  • Any icon set. Items take icon components rather than image URLs, so brand icons from react-icons, glyphs from lucide-react, or hand-drawn SVG components all drop in.

Manual installation

Rather not use the CLI? Everything Stack Rolodex needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install motion

Source

components/ui/stack-rolodex.tsx177 lines
"use client";

import { cn } from "@/lib/utils";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState, type ComponentType } from "react";

/** Pill height in pixels. The vertical step defaults to the same value. */
const PILL_HEIGHT = 50;
/** Default milliseconds between rotations. */
const DEFAULT_INTERVAL = 2000;
/** Default vertical distance between neighbouring pills, in pixels. */
const DEFAULT_STEP = 50;
/** Number of pills visible on each side of the active one. */
const VISIBLE_COUNT = 3;
/** Seconds one rotation step takes to travel. */
const TRAVEL_SECONDS = 0.5;
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];

/** Fades the stack out at both ends so pills dissolve instead of clipping. */
const EDGE_FADE =
  "linear-gradient(to bottom, transparent, black 30%, black 70%, transparent)";

/**
 * Pill fill. The token carries a literal fallback so a consumer project that
 * lacks this site's muted token still renders the pill.
 */
const PILL_BG =
  "color-mix(in oklab, var(--muted, oklch(0.97 0 0)) 30%, transparent)";

export interface StackRolodexItem {
  /** Stable key for the pill. */
  id: string;
  /** Text inside the pill. */
  name: string;
  /** Any icon component that accepts a className. */
  icon: ComponentType<{ className?: string }>;
  /** Brand color the icon takes while the pill is the active one. */
  color?: string;
}

export interface StackRolodexProps {
  /** Pills to cycle through. Needs at least two items to rotate. */
  items: StackRolodexItem[];
  /** Static line rendered beside the stack. Omit to render the stack alone. */
  label?: string;
  /** Milliseconds between rotations. */
  interval?: number;
  /** Vertical distance between neighbouring pills, in pixels. */
  step?: number;
  className?: string;
}

/**
 * A rotating vertical pill stack, rolodex-style. The active index grows
 * forever and each pill's offset wraps into a centred window, so the rotation
 * never hits an edge or snaps back to the start. Pills that cross the wrap
 * point teleport with a zero-duration transition instead of sweeping across
 * the stack.
 */
export function StackRolodex({
  items,
  label,
  interval = DEFAULT_INTERVAL,
  step = DEFAULT_STEP,
  className,
}: StackRolodexProps) {
  const [{ active, previous }, setActive] = useState({ active: 0, previous: 0 });
  const reduceMotion = useReducedMotion();

  const count = items.length;
  const half = Math.floor(count / 2);
  const stackHeight = 2 * VISIBLE_COUNT * step + PILL_HEIGHT;

  useEffect(() => {
    if (count < 2) return;
    const id = setInterval(() => {
      setActive((s) => ({ active: s.active + 1, previous: s.active }));
    }, interval);
    return () => clearInterval(id);
  }, [count, interval]);

  if (count === 0) return null;

  /**
   * Signed distance from the active pill, wrapped into a centred window.
   * Negative means above the centre, positive means below.
   */
  const offsetFor = (index: number, activeStep: number) => {
    const wrapped = (((index - activeStep) % count) + count) % count;
    return wrapped > half ? wrapped - count : wrapped;
  };

  return (
    <div
      className={cn(
        "flex flex-wrap items-center justify-center gap-8",
        className,
      )}
    >
      <ul
        className="relative w-44 shrink-0"
        style={{
          height: stackHeight,
          maskImage: EDGE_FADE,
          WebkitMaskImage: EDGE_FADE,
        }}
      >
        {items.map((item, index) => {
          const d = offsetFor(index, active);
          const was = offsetFor(index, previous);
          const crossed = Math.abs(d - was) > 1;
          const distance = Math.abs(d);
          const Icon = item.icon;
          // The brand color lands on the icon only; the pill itself keeps its
          // neutral styling in every slot.
          const tint = d === 0 ? item.color : undefined;

          return (
            <motion.li
              key={item.id}
              initial={false}
              className={cn(
                "absolute left-0 right-0 mx-auto flex w-40 items-center justify-center gap-3.5",
                "rounded-full",
                d === 0 ? "shadow-xs" : "shadow-none",
              )}
              style={{
                height: PILL_HEIGHT,
                top: "50%",
                marginTop: -PILL_HEIGHT / 2,
                zIndex: count - distance,
                background: PILL_BG,
              }}
              animate={{
                y: d * step,
                scale: 1 - distance * 0.14,
                opacity: distance > VISIBLE_COUNT ? 0 : 1 - distance * 0.45,
              }}
              transition={
                crossed || reduceMotion
                  ? { duration: 0 }
                  : { duration: TRAVEL_SECONDS, ease: EASE }
              }
            >
              {/* The pill text names the item, so the icon is decorative. */}
              <span
                aria-hidden
                className="flex motion-safe:transition-colors motion-safe:duration-500"
                style={tint ? { color: tint } : undefined}
              >
                <Icon className="size-6" />
              </span>
              <span className="truncate text-base font-medium">
                {item.name}
              </span>
            </motion.li>
          );
        })}
      </ul>

      {label ? (
        <motion.p
          initial={{ opacity: 0, y: 8 }}
          animate={{ opacity: 1, y: 0 }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { duration: TRAVEL_SECONDS, ease: EASE }
          }
          className="whitespace-nowrap text-2xl font-medium tracking-tight sm:text-4xl"
        >
          {label}
        </motion.p>
      ) : null}
    </div>
  );
}