EmpireUI
Get Pro
← Blog7 min read#dark-mode#pagination#react

Dark Pagination Component: Page Navigation for Data Tables

Build a dark-themed pagination component for React data tables. Covers Tailwind v4 styling, accessible page controls, and drop-in integration with any dataset.

Dark-themed code editor interface showing a pagination component with numbered page buttons on a deep gray background

Why Dark Pagination Is Harder Than It Looks

Honestly, pagination is one of those UI elements that developers underestimate every single time. You think it's just a row of buttons. Then you actually try to build it against a dark background and suddenly the active state is invisible, the disabled buttons look enabled, and your focus rings have vanished into the void.

The problem is contrast. Light-mode pagination components get away with a lot because the default browser styles already provide enough differentiation. Dark mode strips all of that away. You need intentional color choices at every state: default, hover, active, disabled, and focused. That's five states per button, multiplied by however many page buttons you're rendering.

This article walks through building a dark pagination component that actually works — not just visually, but accessibly. We'll cover the state management, the Tailwind v4 class choices, and the edge cases that bite you in production.

Anatomy of a Pagination Component

A pagination component has more moving parts than it appears. At minimum you need: a previous button, a next button, numbered page buttons, and some ellipsis logic for large page counts. Add a page size selector and a results summary and you've got a full data table footer.

The numbered buttons are where most of the complexity lives. You don't want to render 200 page buttons for a large dataset. The standard pattern is to show the first page, the last page, the current page, one or two siblings around the current page, and ellipsis placeholders in the gaps. Implementing that logic cleanly takes about 30 lines of JavaScript before you've written a single line of CSS.

For dark mode specifically, the active page button needs to stand out without relying on color alone. A good rule: use both a background color change AND a font weight change. That way the active state is distinguishable even for users with color vision deficiencies. Pair that with an accessible label via aria-current="page" and you're in good shape.

Tailwind v4 Dark Mode Classes for Pagination Buttons

With Tailwind v4.0.2 and the new CSS-first configuration, dark mode handling is cleaner than ever. You can scope your dark palette directly in your CSS layer rather than scattering dark: prefixes everywhere. For a pagination button, the base dark surface color sits around bg-zinc-800, borders at border-zinc-700, and text at text-zinc-300.

Here's a production-ready pagination button component with full dark mode states:

type PageButtonProps = {
  page: number;
  isActive: boolean;
  isDisabled?: boolean;
  onClick: (page: number) => void;
};

export function PageButton({ page, isActive, isDisabled, onClick }: PageButtonProps) {
  return (
    <button
      onClick={() => !isDisabled && onClick(page)}
      disabled={isDisabled}
      aria-current={isActive ? 'page' : undefined}
      aria-label={`Go to page ${page}`}
      className={[
        'inline-flex items-center justify-center',
        'h-9 w-9 rounded-md text-sm font-medium',
        'border transition-colors duration-150',
        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900',
        isActive
          ? 'bg-indigo-600 border-indigo-500 text-white font-semibold'
          : isDisabled
          ? 'bg-zinc-900 border-zinc-800 text-zinc-600 cursor-not-allowed'
          : 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700 hover:text-white hover:border-zinc-600',
      ].join(' ')}
    >
      {page}
    </button>
  );
}

Notice the focus-visible ring uses ring-offset-zinc-900 to match the dark background. Without that offset color, the ring appears to float and loses its meaning. Small detail, but it's the difference between a component that looks polished and one that looks unfinished.

The Page Range Calculation Logic

This is the part that trips people up. You need a function that takes the current page, total pages, and a sibling count, then returns an array that can include numbers and ellipsis markers. The ellipsis logic has four cases: no ellipsis needed, left ellipsis only, right ellipsis only, and both.

export function getPageRange(
  currentPage: number,
  totalPages: number,
  siblingCount = 1
): (number | 'ellipsis')[] {
  const totalVisible = siblingCount * 2 + 5; // siblings + current + 2 edges + 2 ellipsis

  if (totalPages <= totalVisible) {
    return Array.from({ length: totalPages }, (_, i) => i + 1);
  }

  const leftSibling = Math.max(currentPage - siblingCount, 1);
  const rightSibling = Math.min(currentPage + siblingCount, totalPages);

  const showLeftEllipsis = leftSibling > 2;
  const showRightEllipsis = rightSibling < totalPages - 1;

  if (!showLeftEllipsis && showRightEllipsis) {
    const leftRange = Array.from({ length: 3 + 2 * siblingCount }, (_, i) => i + 1);
    return [...leftRange, 'ellipsis', totalPages];
  }

  if (showLeftEllipsis && !showRightEllipsis) {
    const rightRange = Array.from(
      { length: 3 + 2 * siblingCount },
      (_, i) => totalPages - (3 + 2 * siblingCount) + i + 1
    );
    return [1, 'ellipsis', ...rightRange];
  }

  const middleRange = Array.from(
    { length: rightSibling - leftSibling + 1 },
    (_, i) => leftSibling + i
  );
  return [1, 'ellipsis', ...middleRange, 'ellipsis', totalPages];
}

Test this function with edge cases before wiring it to your UI. Pages 1 through 5 with totalPages of 5, currentPage of 1, currentPage equal to totalPages, and siblingCount of 2 all produce different outputs. Getting that logic wrong means buttons randomly appear and disappear as the user navigates, which is disorienting.

Connecting Pagination to a Dark Data Table

The pagination component doesn't live in isolation — it sits below a data table, and both need to share the same visual language. If your table uses bg-zinc-900 with zinc-800 row alternation and a zinc-700 header, your pagination bar should use the same zinc-900 background with a border-t border-zinc-800 separator. That 1px border is doing a lot of work: it visually grounds the pagination without adding visual weight.

State management is usually straightforward. You keep currentPage and pageSize in local state or in a URL search param (preferably the latter for shareable links). When either changes, you re-fetch or re-slice your data array. Pass the derived totalPages to the pagination component, along with an onPageChange callback. Don't forget to reset to page 1 when filters change — that's the bug that makes users think your app lost their data.

If you're building a dark UI and want to see how dark mode interacts with other visual styles, it's worth reading about glassmorphism vs neumorphism — the contrast considerations are surprisingly similar. Both styles demand intentional handling of light and shadow that straight dark mode skips over.

Keyboard Navigation and Accessibility

Can your users navigate through pages without touching a mouse? If not, you've built a broken component. All pagination buttons must be reachable via Tab, activatable via Enter and Space, and your prev/next arrows should ideally respond to ArrowLeft and ArrowRight when focus is inside the pagination bar.

The aria markup is non-negotiable. The wrapping element should be a nav with aria-label="Pagination". Each page button gets an aria-label like "Go to page 3". The active button gets aria-current="page". Ellipsis spans get aria-hidden="true" since they carry no navigational value. The previous and next buttons get aria-disabled="true" (not the HTML disabled attribute, which removes them from the tab order) when you're at the first or last page respectively.

One thing developers often miss: the focus ring offset. In dark mode, a default 2px indigo focus ring against zinc-800 is almost invisible. Setting ring-offset-2 with ring-offset-zinc-900 creates a small dark halo around the ring, making it pop against any button background color. This is a 4-character Tailwind addition that passes WCAG 2.1 AA focus visibility requirements.

Page Size Selector and Results Summary

A complete data table footer pairs the pagination with a page size selector ("10 / 25 / 50 per page") and a results summary ("Showing 21–40 of 847 results"). Both elements need dark mode treatment too. A select element in dark mode is particularly annoying because the browser's native dropdown doesn't inherit your custom styles — you'll need a custom select built from a button and a floating panel, or a headless library like Radix UI's Select primitive.

For the results summary, use text-zinc-400 for the label text and text-zinc-200 for the numbers. The contrast between those two zinc shades is subtle but effective — it draws the eye to the actual numbers without making the surrounding text disappear. The formula is: Showing {(currentPage - 1) * pageSize + 1}–{Math.min(currentPage * pageSize, totalItems)} of {totalItems.toLocaleString()} results.

If you're already using Empire UI's theme toggle for dark/light switching, your pagination component will inherit the mode automatically as long as you're using the dark: Tailwind prefix or a CSS custom property approach. Don't hardcode zinc colors into your pagination if you want it to work in both modes.

Dropping It Into Your Project

The Empire UI dark pagination component ships as a zero-dependency React component. Drop it in, pass totalItems, pageSize, currentPage, and onPageChange, and you're done. No external state library required. The component handles its own page range calculation internally.

What about CSS Modules vs Tailwind for styling this component? Honestly, Tailwind wins here purely on maintainability. Pagination has a lot of conditional classes based on state — active, disabled, hover, first/last. With CSS Modules you end up writing classNames(styles.button, isActive && styles.active, isDisabled && styles.disabled) which isn't terrible, but the Tailwind version is all inline and easier to scan at a glance. There's no right answer, but for component libraries specifically, co-located styles reduce the friction of copy-pasting components between projects.

One last thing: test your pagination at 1 page, 2 pages, and 3 pages total. Those edge cases break the ellipsis logic in subtle ways. At 1 page, the component should render nothing or just a disabled single button. At 2 pages, no ellipsis should appear. Get those right and the rest of the page counts take care of themselves.

FAQ

How do I style a pagination button's active state in dark mode with Tailwind?

Use bg-indigo-600 border-indigo-500 text-white font-semibold for the active state and bg-zinc-800 border-zinc-700 text-zinc-300 for the default state. The key is combining a background color shift with a font-weight change so the active page is distinguishable beyond color alone.

Why does my focus ring disappear in dark mode?

The default focus ring renders directly against the button background, where it can get lost. Add focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 to match the ring offset color to your dark page background. That 2px gap makes the ring pop.

Should I use aria-disabled or the HTML disabled attribute on pagination buttons?

Use aria-disabled="true" on the previous button when on page 1, and on the next button when on the last page. The HTML disabled attribute removes the element from the tab order entirely, meaning keyboard users can't even reach it to understand why it's not working. aria-disabled keeps the button reachable while communicating its state.

How do I reset to page 1 when filters change?

In your filter change handler, call setCurrentPage(1) before or alongside the filter state update. If you're storing page in a URL search param, replace the param rather than push — so the back button doesn't step through every page the user was on while filtering.

What's the right ellipsis logic for a pagination component?

Always show the first and last page. Show the current page plus siblingCount pages on each side (usually 1). Insert an 'ellipsis' marker wherever there's a gap larger than 1 between shown pages. If totalPages is small enough that all pages fit without ellipsis, skip the logic entirely and just render all pages.

Can I use this dark pagination component with React Query or SWR?

Yes. Pass currentPage and pageSize into your query key — useQuery(['table-data', currentPage, pageSize], fetchFn) — and React Query will automatically refetch when either value changes. Wire your pagination's onPageChange to setCurrentPage and the rest is automatic.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Dark UI Patterns for SaaS: Navigation, Sidebars, Data TablesPastel Color Systems: Soft UI That Performs on Light and DarkTailwind Dark Mode: class vs media, system preference, manual toggleCustomizing shadcn/ui: Colors, Radius, and Dark Mode Tokens