# Pagination Dots Morph — React Pagination Component

> Dot pagination where the active dot stretches into a pill with a spring and, on autoplay, fills like a story timer before handing over to the next dot.

- Category: [Pagination](https://pixelpapa.com/components/pagination.md)
- Tags: pagination, dots, carousel, morph, autoplay
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/pagination/pagination-dots-morph

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/pagination-dots-morph.json"
```

The file is written to `components/pixelpapa/pagination-dots-morph.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ChevronLeft, ChevronRight, Keyboard, Sparkles, Users, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

type DotsPaginationProps = {
  count: number;
  active: number;
  onChange: (index: number) => void;
  /** Milliseconds per page. The timer fill replaces the solid pill; 0 turns autoplay off. */
  autoPlay?: number;
  label?: string;
  className?: string;
};

export function DotsPagination({ count, active, onChange, autoPlay = 0, label = "Slides", className }: DotsPaginationProps) {
  const reduce = useReducedMotion();
  // Autoplay ends for good the first time the user takes over.
  const [playing, setPlaying] = useState(autoPlay > 0);
  const timed = playing && autoPlay > 0 && !reduce;

  function pick(index: number) {
    setPlaying(false);
    onChange((index + count) % count);
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const delta = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
    if (!delta) return;
    e.preventDefault();
    const next = (active + delta + count) % count;
    pick(next);
    e.currentTarget.querySelectorAll<HTMLButtonElement>("button")[next]?.focus();
  }

  return (
    <div role="tablist" aria-label={label} onKeyDown={onKeyDown} className={cn("flex items-center gap-1.5", className)}>
      {Array.from({ length: count }, (_, i) => {
        const on = i === active;
        return (
          <button
            key={i}
            type="button"
            role="tab"
            aria-selected={on}
            aria-label={`Go to ${i + 1} of ${count}`}
            tabIndex={on ? 0 : -1}
            onClick={() => pick(i)}
            className="group flex h-6 items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-card"
          >
            <motion.span
              initial={false}
              animate={{ width: on ? 30 : 8 }}
              transition={{ type: "spring", stiffness: 420, damping: 26 }}
              className={cn(
                "relative block h-2 overflow-hidden rounded-full transition-colors",
                on ? (timed ? "bg-primary/25" : "bg-primary") : "bg-muted-foreground/30 group-hover:bg-muted-foreground/60",
              )}
            >
              {on && timed && (
                <motion.span
                  key={active}
                  initial={{ scaleX: 0 }}
                  animate={{ scaleX: 1 }}
                  transition={{ duration: autoPlay / 1000, ease: "linear" }}
                  onAnimationComplete={() => onChange((active + 1) % count)}
                  className="absolute inset-0 origin-left rounded-full bg-primary"
                />
              )}
            </motion.span>
          </button>
        );
      })}
    </div>
  );
}

const tips: { icon: LucideIcon; title: string; body: string; tint: string }[] = [
  { icon: Sparkles, title: "Ask in plain language", body: "Type “overdue invoices over $500” and Ledgerly builds the filter for you.", tint: "from-chart-1/25" },
  { icon: Keyboard, title: "Everything has a shortcut", body: "Press ⌘K anywhere to jump to a customer, a report or a setting.", tint: "from-chart-3/25" },
  { icon: Users, title: "Share a live view", body: "Send finance a link that stays in sync instead of another CSV export.", tint: "from-chart-5/25" },
  { icon: Sparkles, title: "Rules that learn", body: "Categorise a transaction twice and the third one files itself.", tint: "from-chart-4/25" },
];

export default function PaginationDotsMorphDemo() {
  const [[index, dir], setState] = useState<[number, number]>([0, 1]);
  const [auto, setAuto] = useState(true);
  const tip = tips[index];
  const go = (next: number) => setState([next, next > index || (index === tips.length - 1 && next === 0) ? 1 : -1]);

  return (
    <div className="w-full max-w-sm overflow-hidden rounded-3xl border border-border bg-card shadow-sm">
      <div className="relative h-52 overflow-hidden">
        <AnimatePresence initial={false} custom={dir} mode="popLayout">
          <motion.div
            key={index}
            custom={dir}
            variants={{
              enter: (d: number) => ({ x: `${d * 60}%`, opacity: 0 }),
              center: { x: 0, opacity: 1 },
              exit: (d: number) => ({ x: `${d * -60}%`, opacity: 0 }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{ type: "spring", stiffness: 260, damping: 30 }}
            className={cn("absolute inset-0 flex flex-col justify-end bg-linear-to-b to-transparent p-6", tip.tint)}
          >
            <span className="flex size-11 items-center justify-center rounded-2xl bg-background text-primary shadow-sm">
              <tip.icon className="size-5" />
            </span>
            <h3 className="mt-4 text-lg font-semibold tracking-tight text-card-foreground">{tip.title}</h3>
            <p className="mt-1 text-sm leading-relaxed text-muted-foreground">{tip.body}</p>
          </motion.div>
        </AnimatePresence>
      </div>
      <div className="flex items-center justify-between border-t border-border px-4 py-3">
        <DotsPagination count={tips.length} active={index} onChange={go} autoPlay={auto ? 3200 : 0} label="Product tips" />
        <div className="flex gap-1">
          {[
            { label: "Previous tip", icon: ChevronLeft, to: (index - 1 + tips.length) % tips.length },
            { label: "Next tip", icon: ChevronRight, to: (index + 1) % tips.length },
          ].map(({ label, icon: Icon, to }) => (
            <button
              key={label}
              type="button"
              aria-label={label}
              onClick={() => {
                setAuto(false);
                setState([to, label.startsWith("Next") ? 1 : -1]);
              }}
              className="flex size-8 items-center justify-center rounded-full text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
            >
              <Icon className="size-4" />
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Pagination Dots Morph" component from PixelPapa UI to this project.

What it is: Dot pagination where the active dot stretches into a pill with a spring and, on autoplay, fills like a story timer before handing over to the next dot.
Category: Pagination. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/pagination-dots-morph.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/pagination-dots-morph.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/pagination-dots-morph" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More pagination

- [Pagination Infinite Scroll Sentinel](https://pixelpapa.com/components/pagination/pagination-infinite-scroll-sentinel.md) (Pro): Infinite scrolling driven by an IntersectionObserver sentinel: shimmer rows appear as you near the end, new rows fade up, and a progress hairline tracks how much is loaded.
- [Pagination Jump Input](https://pixelpapa.com/components/pagination/pagination-jump-input.md) (Pro): Pagination built around an editable page field: type a number and press Enter to jump, out-of-range values shake and reset, and a scrubber shows where you are.
- [Pagination Load More](https://pixelpapa.com/components/pagination/pagination-load-more.md) (Pro): A load-more list: the button morphs into a spinner, skeleton rows hold the space, then the new items stagger in and the list scrolls to meet them.
- [Pagination Prev Next Compact](https://pixelpapa.com/components/pagination/pagination-prev-next-compact.md): A compact previous/next pager with a page-of-total readout whose number rolls up or down in the direction you are paging.
- [Pagination Sliding Active](https://pixelpapa.com/components/pagination/pagination-sliding-active.md): Numbered pagination where the active pill springs between pages; deep in the range the numbers reel through the pill instead, and each ellipsis jumps five pages.
