bigbullui logobigbullui
Componentnavigation

PagerDots

Navigation page indicators with active dot that stretches and variant styles

Preview

Live Preview

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add pager-dots

Or install the full npm package:

npm install bigbullui

Usage

import { PagerDots } from "@/components/ui/pager-dots"

<PagerDots count={5} value={2} onValueChange={handleChange} variant="dots" />

Source code

pager-dots.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface PagerDotsProps {
  count: number;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  variant?: "dots" | "lines" | "bars";
  className?: string;
}

export function PagerDots({
  count,
  value,
  defaultValue,
  onValueChange,
  variant = "dots",
  className,
}: PagerDotsProps) {
  const [internal, setInternal] = React.useState(defaultValue ?? 0);
  const current = value ?? internal;

  const handleChange = React.useCallback(
    (idx: number) => {
      setInternal(idx);
      onValueChange?.(idx);
    },
    [onValueChange]
  );

  const getItemClass = (index: number) => {
    const isActive = current === index;
    const center = count - 1;

    if (variant === "dots") {
      if (isActive) {
        return cn(
          "rounded-full bg-accent w-6 h-6 transition-all duration-300 ease-out",
          "animate-[stamp_0.4s_ease-out_both]"
        );
      }
      return cn("rounded-full bg-transparent w-4 h-4 border border-border/30 transition-all duration-200");
    }

    if (variant === "lines") {
      if (isActive) {
        const width = (index / (count - 1)) * 100;
        return cn(
          "h-0.5 bg-accent transition-all duration-300 ease-out",
          `w-[${width}%]`
        );
      }
      return cn("h-0.5 bg-transparent w-full transition-all duration-200");
    }

    if (variant === "bars") {
      if (isActive) {
        const height = ((index + 1) / count) * 12;
        return cn(
          "w-1 bg-accent rounded-t-md rounded-b-md transition-all duration-300 ease-out",
          `h-[${height}%]`
        );
      }
      return cn("w-0.5 bg-border/30 rounded-t-md rounded-b-md transition-all duration-200");
    }

    return cn("rounded-full bg-transparent w-4 h-4 border border-border/30");
  };

  return (
    <div
      className={cn(
        "flex space-x-1.5 justify-center",
        variant === "lines" && "items-center",
        variant === "bars" && "items-end",
        className
      )}
    >
      {Array.from({ length: count }, (_, i) => (
        <button
          key={i}
          className={getItemClass(i)}
          onClick={() => handleChange(i)}
          aria-label={`Go to page ${i + 1}`}
          type="button"
        >
          {variant === "dots" && (
            <svg
              className="h-3 w-3"
              viewBox="0 0 24 24"
              fill="currentColor"
              focusable="false"
            >
              <path d="M8 5v14l11-4-11-4z" />
            </svg>
          )}
          {variant === "lines" && (
            <div className="h-full w-full bg-current rounded-md" />
          )}
          {variant === "bars" && (
            <div className="h-4 w-1 bg-current rounded-t rounded-b" />
          )}
        </button>
      ))}
    </div>
  );
}

Props

PropTypeDescription
countnumberTotal number of pages
valuenumberCurrently selected page index (controlled)
defaultValuenumberInitial selected page index (uncontrolled)
onValueChange(value: number) => voidCallback when value changes
variant"dots" | "lines" | "bars"Style variant for the dots