npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

testyuakagi

v1.0.19

Published

A lightweight, accessible vanilla JavaScript table with server-side pagination

Readme

Paginated Table

A tiny, zero-dependency JavaScript helper for server-side paginated tables.

Paginated Table gives plain HTML tables production-ready pagination, search, loading states, error handling, and accessibility — without shipping CSS, requiring a framework, or taking over your design system.

It is designed for server-rendered applications such as Django, Rails, Laravel, Phoenix, ASP.NET, or any backend that can return JSON.

Contents


Quick start

1. Add a table

<input
  id="user-search"
  type="text"
  class="form-control"
  placeholder="Search users..."
>

<table
  class="table table-hover align-middle"
  data-role="paginated-table"
  data-fetch-url="/api/users/"
  data-fetch-method="GET"
  data-per-page="10"
  data-q-input-id="user-search"
  data-page-button-class="btn btn-sm btn-outline-primary"
  data-page-active-button-class="btn btn-sm btn-primary"
  data-prev-button-class="btn btn-sm btn-outline-secondary"
  data-next-button-class="btn btn-sm btn-outline-secondary"
  data-prev-button-content="&laquo;"
  data-next-button-content="&raquo;"
  data-pagination-container-class="d-flex gap-1 justify-content-end"
>

This example uses Bootstrap classes, but Bootstrap is not required. Any CSS class names work.

For all supported attributes, see the Data attribute reference.

  <thead>
    <tr>
      <th data-header-key="id">ID</th>
      <th data-header-key="name">Name</th>
      <th data-header-key="email">Email</th>
    </tr>
  </thead>
</table>

2. Import and initialize

import { initPaginatedTable } from "./paginated-table.js";

initPaginatedTable();

If the script is loaded after the document is ready, Paginated Table can also auto-initialize tables with:

data-role="paginated-table"

3. Return JSON from your backend

{
  "data": [
    {"id": 1, "name": "Alice", "email": "[email protected]"},
    {"id": 2, "name": "Bob", "email": "[email protected]"}
  ],
  "total_pages": 8
}

The library sends these request parameters:

page=1
per_page=10
q=alice

For GET, they are sent as query parameters. For POST, they are sent as FormData.

For request-related options, see Request attributes.


Basic backend contract

Your endpoint should accept:

| Parameter | Description | | ---------- | ------------------------ | | page | 1-based page number | | per_page | Number of rows requested | | q | Optional search query |

Your endpoint should return:

| Field | Type | Description | | ------------- | ----------------- | ------------------------- | | data | array | Rows for the current page | | total_pages | integer, optional | Total number of pages |

total_pages is recommended. If omitted, Paginated Table infers the last page when the returned row count is smaller than per_page.


Default row rendering

By default, rows are rendered using the table header keys.

<th data-header-key="name">Name</th>

maps to:

{"name": "Alice"}

If data-header-key is omitted, the header text is normalized:

<th>Full Name</th>

becomes:

full_name

Example:

<table data-role="paginated-table" data-fetch-url="/api/users/">
  <thead>
    <tr>
      <th data-header-key="id">ID</th>
      <th data-header-key="name">Name</th>
      <th data-header-key="created_at">Created</th>
    </tr>
  </thead>
</table>

Response:

{
  "data": [
    {"id": 1, "name": "Alice", "created_at": "2026-04-28"}
  ],
  "total_pages": 1
}

Custom row renderers

Use a custom row renderer when you need links, badges, buttons, icons, or custom formatting.

import { rowRenderers } from "./paginated-table.js";

rowRenderers.userRow = (row) => {
  const tr = document.createElement("tr");

  const nameTd = document.createElement("td");
  nameTd.textContent = row.name;
  tr.appendChild(nameTd);

  const statusTd = document.createElement("td");
  const badge = document.createElement("span");
  badge.className = row.is_active ? "badge text-bg-success" : "badge text-bg-secondary";
  badge.textContent = row.is_active ? "Active" : "Inactive";
  statusTd.appendChild(badge);
  tr.appendChild(statusTd);

  const actionTd = document.createElement("td");
  const link = document.createElement("a");
  link.className = "btn btn-sm btn-outline-primary";
  link.href = `/users/${row.id}/`;
  link.textContent = "Open";
  actionTd.appendChild(link);
  tr.appendChild(actionTd);

  return tr;
};

Then attach it to the table:

<table
  data-role="paginated-table"
  data-fetch-url="/api/users/"
  data-row-renderer="userRow"
>
  <thead>
    <tr>
      <th>Name</th>
      <th>Status</th>
      <th>Action</th>
    </tr>
  </thead>
</table>

A custom renderer receives one row object and must return a <tr> element.

For renderer-related options, see Rendering attributes.


Search

Add an input and connect it with data-q-input-id.

<input id="search" type="text" placeholder="Search...">

<table
  data-role="paginated-table"
  data-fetch-url="/api/items/"
  data-q-input-id="search"
>
  <thead>
    <tr>
      <th data-header-key="name">Name</th>
    </tr>
  </thead>
</table>

Search is debounced and resets pagination to page 1.

See data-q-input-id for details.


POST requests and CSRF

For backends that require POST requests, configure the method and CSRF header.

Django example

<meta name="csrf-token" content="{{ csrf_token }}">

<table
  data-role="paginated-table"
  data-fetch-url="/api/users/"
  data-fetch-method="POST"
  data-csrf-token-header="X-CSRFToken"
>
  <thead>
    <tr>
      <th data-header-key="id">ID</th>
      <th data-header-key="name">Name</th>
    </tr>
  </thead>
</table>

You may also provide the token directly:

<table
  data-role="paginated-table"
  data-fetch-url="/api/users/"
  data-fetch-method="POST"
  data-csrf-token="{{ csrf_token }}"
  data-csrf-token-header="X-CSRFToken"
>

See data-csrf-token and data-csrf-token-header for details.


Styling

Paginated Table ships no CSS.

This is intentional. The library creates semantic HTML and lets you use any styling system:

  • Bootstrap
  • Tailwind CSS
  • Bulma
  • Foundation
  • Custom enterprise design systems
  • Plain CSS

Example with Bootstrap-style classes:

<table
  class="table table-striped"
  data-role="paginated-table"
  data-fetch-url="/api/orders/"
  data-page-button-class="btn btn-sm btn-outline-primary"
  data-page-active-button-class="btn btn-sm btn-primary"
  data-prev-button-class="btn btn-sm btn-outline-secondary"
  data-next-button-class="btn btn-sm btn-outline-secondary"
  data-pagination-container-class="d-flex gap-1 justify-content-end"
>

Security model

Paginated Table is safe by default for row data because the default renderer uses textContent.

Some configuration attributes intentionally support HTML. This is useful for icons, placeholders, and design-system markup.

These attributes are treated as trusted developer-provided HTML:

  • data-prev-button-content
  • data-next-button-content
  • data-ellipsis-button-content
  • data-placeholder-html

In normal table usage, these values are usually static template values written by the developer. That is the intended use.

Safe:

<table
  data-prev-button-content="&laquo;"
  data-next-button-content="&raquo;"
  data-placeholder-html="<span class='placeholder col-6'></span>"
>

Avoid passing user-provided or database-provided HTML directly:

<!-- Avoid this unless the value is sanitized and trusted. -->
<table data-placeholder-html="{{ user_provided_html }}">

If you need to render dynamic HTML, sanitize it first. A common option is DOMPurify.

const cleanHtml = DOMPurify.sanitize(untrustedHtml);

For untrusted values, prefer textContent in a custom row renderer.


Accessibility

Paginated Table includes accessibility-aware behavior:

  • aria-busy while loading
  • Screen-reader live region updates
  • aria-label on pagination buttons
  • aria-current="page" for the active page
  • role="navigation" on pagination controls
  • Search input role and label normalization

The library does not replace full accessibility testing, but it provides a strong baseline for production interfaces.


Error handling

If configuration or network errors occur, Paginated Table:

  1. Logs a detailed developer-facing error to the console.
  2. Replaces the table body with a user-facing error message.

Customize the error message and class:

<table
  data-role="paginated-table"
  data-fetch-url="/api/items/"
  data-error-message="Could not load items. Please try again."
  data-error-class="text-danger py-4 text-center"
>

Data attribute reference

Required attributes

data-role

Marks the table for auto-initialization.

data-role="paginated-table"

Required for automatic initialization.

data-fetch-url

The endpoint used to fetch table data.

data-fetch-url="/api/users/"

The endpoint should return JSON with a data array and optionally total_pages.


Request attributes

data-fetch-method

HTTP method used for fetching data.

data-fetch-method="GET"

Supported values:

  • GET
  • POST

Default:

GET

data-per-page

Number of rows requested per page.

data-per-page="25"

Default:

10

Allowed range:

1–500

data-q-input-id

ID of the search input associated with the table.

<input id="user-search" type="text">

<table data-q-input-id="user-search">

When the input changes, the table fetches page 1 with the current query.

data-csrf-token

CSRF token for POST requests.

data-csrf-token="{{ csrf_token }}"

Optional if a supported meta tag exists:

<meta name="csrf-token" content="{{ csrf_token }}">

data-csrf-token-header

Header name used to send the CSRF token.

data-csrf-token-header="X-CSRFToken"

Common values:

| Framework | Header | | ----------- | -------------- | | Django | X-CSRFToken | | Rails | X-CSRF-Token | | Laravel | X-CSRF-TOKEN | | Spring Boot | X-CSRF-TOKEN |

Required for POST requests.


Rendering attributes

data-row-renderer

Name of a custom row renderer registered in rowRenderers.

data-row-renderer="userRow"
rowRenderers.userRow = (row) => {
  const tr = document.createElement("tr");
  return tr;
};

If omitted, the default renderer uses header keys.

data-row-tr-class

Classes added to every rendered <tr>.

data-row-tr-class="align-middle"

Multiple classes are supported:

data-row-tr-class="align-middle table-row-hover"

data-no-record-message

Message shown when the first page has no records.

data-no-record-message="No users found."

Default:

No data

data-no-record-td-class

Class added to the no-record <td>.

data-no-record-td-class="text-muted text-center py-4"

data-pad-rows

Controls whether empty rows are added to reduce layout shift.

data-pad-rows="false"

Default:

true

When enabled, the table keeps roughly the same height across pages.

data-placeholder-html

HTML shown while the table is initially loading.

data-placeholder-html="<span class='placeholder col-8'></span>"

This value is treated as trusted developer-provided HTML. Do not pass unsanitized user input.


Pagination attributes

data-pagination-container-class

Class added to the pagination container.

data-pagination-container-class="d-flex gap-1 justify-content-end"

data-page-button-class

Class added to normal page buttons.

data-page-button-class="btn btn-sm btn-outline-primary"

data-page-active-button-class

Class added to the active page button.

data-page-active-button-class="btn btn-sm btn-primary"

data-prev-button-class

Class added to the previous-page button.

data-prev-button-class="btn btn-sm btn-outline-secondary"

data-next-button-class

Class added to the next-page button.

data-next-button-class="btn btn-sm btn-outline-secondary"

data-prev-button-content

HTML content for the previous-page button.

data-prev-button-content="&laquo;"

Default:

<<

This value is treated as trusted developer-provided HTML.

data-next-button-content

HTML content for the next-page button.

data-next-button-content="&raquo;"

Default:

>>

This value is treated as trusted developer-provided HTML.

data-ellipsis-button-class

Class added to ellipsis buttons.

data-ellipsis-button-class="btn btn-sm btn-light disabled"

data-ellipsis-button-content

HTML content for ellipsis buttons.

data-ellipsis-button-content="&hellip;"

Default:

...

This value is treated as trusted developer-provided HTML.


Error attributes

data-error-message

User-facing message shown when loading fails.

data-error-message="Failed to load data."

Default:

Failed to load data.

data-error-class

Class added to the error <td>.

data-error-class="text-danger text-center py-4"

Debug attributes

data-verbose

Enables additional console messages during initialization.

data-verbose="true"

Default:

false

Internal state attributes

Paginated Table manages these attributes internally:

  • data-initialized
  • data-current-page
  • data-current-total-pages
  • data-current-q

You usually should not set or modify them manually.


Full example

<input
  id="order-search"
  type="text"
  class="form-control mb-3"
  placeholder="Search orders..."
>

<table
  class="table table-hover align-middle"
  data-role="paginated-table"
  data-fetch-url="/api/orders/"
  data-fetch-method="GET"
  data-per-page="20"
  data-q-input-id="order-search"
  data-row-renderer="orderRow"
  data-row-tr-class="align-middle"
  data-pad-rows="true"
  data-placeholder-html="<span class='placeholder col-8'></span>"
  data-no-record-message="No orders found."
  data-no-record-td-class="text-muted text-center py-4"
  data-page-button-class="btn btn-sm btn-outline-primary"
  data-page-active-button-class="btn btn-sm btn-primary"
  data-prev-button-class="btn btn-sm btn-outline-secondary"
  data-next-button-class="btn btn-sm btn-outline-secondary"
  data-prev-button-content="&laquo;"
  data-next-button-content="&raquo;"
  data-ellipsis-button-class="btn btn-sm btn-light"
  data-ellipsis-button-content="&hellip;"
  data-pagination-container-class="d-flex gap-1 justify-content-end"
  data-error-message="Could not load orders. Please try again."
  data-error-class="text-danger text-center py-4"
>
  <thead>
    <tr>
      <th>Order</th>
      <th>Customer</th>
      <th>Status</th>
      <th>Total</th>
      <th></th>
    </tr>
  </thead>
</table>

<script type="module">
  import { rowRenderers, initPaginatedTable } from "./paginated-table.js";

  rowRenderers.orderRow = (row) => {
    const tr = document.createElement("tr");

    const orderTd = document.createElement("td");
    orderTd.textContent = row.order_number;
    tr.appendChild(orderTd);

    const customerTd = document.createElement("td");
    customerTd.textContent = row.customer_name;
    tr.appendChild(customerTd);

    const statusTd = document.createElement("td");
    const badge = document.createElement("span");
    badge.className = "badge text-bg-secondary";
    badge.textContent = row.status;
    statusTd.appendChild(badge);
    tr.appendChild(statusTd);

    const totalTd = document.createElement("td");
    totalTd.textContent = row.total;
    tr.appendChild(totalTd);

    const actionTd = document.createElement("td");
    const link = document.createElement("a");
    link.className = "btn btn-sm btn-outline-primary";
    link.href = `/orders/${row.id}/`;
    link.textContent = "View";
    actionTd.appendChild(link);
    tr.appendChild(actionTd);

    return tr;
  };

  initPaginatedTable();
</script>

Non-goals

Paginated Table intentionally stays small. It does not include:

  • Client-side sorting
  • Column resizing
  • Inline editing
  • Virtual scrolling
  • Drag and drop
  • Built-in themes
  • Built-in CSS
  • Framework-specific adapters

These features are useful, but they add weight and complexity. Paginated Table focuses on server-side pagination, search, rendering, and accessibility.


License

MIT