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

@serhiitupilow/nuxt-table

v1.0.0

Published

Nuxt module with a functional table component (sorting, filtering, column visibility, resize, optional DnD)

Downloads

654

Readme

@serhiitupilow/nuxt-table

A Nuxt module that registers a global NuxtTable component for data tables with:

  • client-side sorting
  • client-side filtering
  • optional drag-and-drop column reordering
  • optional column resize
  • persisted column order/visibility/widths in localStorage
  • configurable cell/header/filter rendering

Requirements

  • nuxt >= 3.11.0
  • vue >= 3.4.0

Installation

npm i @serhiitupilow/nuxt-table
# or
pnpm add @serhiitupilow/nuxt-table
# or
yarn add @serhiitupilow/nuxt-table
# or
bun add @serhiitupilow/nuxt-table

Nuxt setup

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@serhiitupilow/nuxt-table"],
});

Module options

export default defineNuxtConfig({
  modules: ["@serhiitupilow/nuxt-table"],
  nuxtTable: {
    injectDefaultStyles: true,
  },
});

| Option | Type | Default | Description | | --------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------ | | injectDefaultStyles | boolean | true | Injects bundled CSS from the module runtime. Set to false if you fully style classes yourself. |

Quick start

<script setup lang="ts">
import type { NuxtTableColumn } from "@serhiitupilow/nuxt-table/runtime";

type UserRow = {
  id: number;
  name: string;
  status: "active" | "paused";
  createdAt: string;
};

const columns: NuxtTableColumn[] = [
  { key: "id", label: "ID", sortable: true, filterable: true },
  { key: "name", label: "Name", sortable: true, filterable: true },
  { key: "status", label: "Status", sortable: true, filterable: true },
  {
    key: "createdAt",
    label: "Created",
    sortable: true,
    formatter: (value) => new Date(String(value)).toLocaleDateString(),
  },
];

const rows: UserRow[] = [
  { id: 1, name: "Alice", status: "active", createdAt: "2026-02-01" },
  { id: 2, name: "Bob", status: "paused", createdAt: "2026-02-14" },
];

function onColumnOrderChange(payload: {
  order: string[];
  movedKey: string;
  fromIndex: number;
  toIndex: number;
}) {
  console.log("new order", payload.order);
}
</script>

<template>
  <NuxtTable
    :columns="columns"
    :rows="rows"
    storage-key="users-table"
    :enable-column-dnd="true"
    @column-order-change="onColumnOrderChange"
  />
</template>

Public runtime exports

import {
  useNuxtTable,
  type NuxtTableClassNames,
  type NuxtTableColumn,
  type NuxtTableColumnOrderChange,
  type NuxtTableManualFilterChange,
  type NuxtTableManualSortChange,
  type TableRow,
  type UseNuxtTableOptions,
  type ValueResolver,
} from "@serhiitupilow/nuxt-table/runtime";

NuxtTable component API

Props

| Prop | Type | Default | Description | | -------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------------- | | columns | NuxtTableColumn[] | required | Column definitions. | | rows | TableRow[] | required | Data rows. | | enabledColumns | string[] | undefined | Explicitly controls visible columns (in the current ordered sequence). | | storageKey | string | "nuxt-table" | Prefix for persisted table UI state in localStorage. | | rowKey | string \| (row, index) => string \| number | "id" | Unique key resolver for row rendering. | | title | string | "Table" | Legacy prop kept for compatibility (not currently rendered in UI). | | showToolbar | boolean | true | Legacy prop kept for compatibility (toolbar is not currently rendered). | | enableColumnDnd | boolean | false | Enables drag-and-drop header reordering. | | enableColumnResize | boolean | true | Enables resize handle on header cells. | | classNames | Partial<NuxtTableClassNames> | {} | Class overrides for semantic class hooks. |

Events

| Event | Payload | Description | | ---------------------- | ----------------------------- | ----------------------------------------------------------------------------------- | | column-order-change | NuxtTableColumnOrderChange | Emitted after successful drag-and-drop reorder. | | manual-sort-change | NuxtTableManualSortChange | Emitted when sort state changes and @manual-sort-change listener is provided. | | manual-filter-change | NuxtTableManualFilterChange | Emitted when filter value changes and @manual-filter-change listener is provided. |

Behavior notes

  • Filtering is applied before sorting.
  • Sorting cycles by click: asc -> desc -> off.
  • If @manual-filter-change is provided, built-in filtering is disabled.
  • If @manual-sort-change is provided, built-in sorting is disabled.
  • Column width has a minimum of 140px.
  • Empty state text: No rows match the current filters.
  • Rendering is table-only (no built-in toolbar/summary controls).
  • DnD headers use cursor states: grab and grabbing.

Column definition (NuxtTableColumn)

type ValueResolver = string | ((row: TableRow) => unknown);

interface NuxtTableColumn {
  key: string;
  label: string;
  sortable?: boolean;
  filterable?: boolean;
  sortAscComponent?: Component;
  sortDescComponent?: Component;
  sortDefaultComponent?: Component;
  sortKey?: ValueResolver;
  filterKey?: ValueResolver;
  formatter?: (value: unknown, row: TableRow) => string;
  cellComponent?: Component;
  filterComponent?: Component;
  headerClassName?: string;
  cellClassName?: string;
}

Field details

  • key: primary accessor path for display value. Supports dot notation through resolvers (for example: user.profile.name) when used by sortKey/filterKey.
  • sortKey: alternate accessor/function used for sorting.
  • sortAscComponent / sortDescComponent / sortDefaultComponent: optional sort button content per state. If not provided, defaults are Asc, Desc, and Sort.
  • filterKey: alternate accessor/function used for default text filtering.
  • formatter: transforms display value for default body rendering (<span>{{ value }}</span>).
  • cellComponent: custom body renderer receives row, column, and value.
  • filterComponent: custom header filter renderer receives modelValue and column, and should emit update:model-value.

Persistence model

State is persisted per storageKey in localStorage with keys:

  • ${storageKey}:order
  • ${storageKey}:enabledColumns
  • ${storageKey}:widths

Persisted values are validated against current columns; unknown keys are ignored.

Styling

The component uses semantic class hooks. You can:

  1. use injected default styles, and/or
  2. override classes via classNames, and/or
  3. provide your own global CSS.

Default class keys (NuxtTableClassNames)

interface NuxtTableClassNames {
  root: string;
  toolbar: string;
  toolbarTitle: string;
  toolbarActions: string;
  toolbarButton: string;
  columnManager: string;
  columnManagerTitle: string;
  columnManagerItem: string;
  tableWrapper: string;
  table: string;
  tableHead: string;
  tableBody: string;
  bodyRow: string;
  emptyCell: string;
  headerCell: string;
  headerCellDragSource: string;
  headerCellDragOver: string;
  headerTop: string;
  headerLabel: string;
  sortButton: string;
  filterInput: string;
  resizeHandle: string;
  bodyCell: string;
}

Some toolbar-related class keys remain in the public type for compatibility, even though the current component template renders only the table.

classNames example

<NuxtTable
  :columns="columns"
  :rows="rows"
  :class-names="{
    table: 'my-table',
    headerCell: 'my-header-cell',
    bodyCell: 'my-body-cell',
    filterInput: 'my-filter-input',
  }"
/>

Advanced examples

Enable / disable visible columns

Use enabledColumns to control what is rendered.

<script setup lang="ts">
const enabledColumns = ref<string[]>(["id", "name", "status"]);

function toggleStatusColumn() {
  if (enabledColumns.value.includes("status")) {
    enabledColumns.value = enabledColumns.value.filter(
      (key) => key !== "status",
    );
    return;
  }

  enabledColumns.value = [...enabledColumns.value, "status"];
}
</script>

<template>
  <button type="button" @click="toggleStatusColumn">
    Toggle status column
  </button>

  <NuxtTable
    :columns="columns"
    :rows="rows"
    :enabled-columns="enabledColumns"
  />
</template>

Custom sort state components (ASC / DESC / default)

<!-- SortAsc.vue -->
<template><span>↑ ASC</span></template>

<!-- SortDesc.vue -->
<template><span>↓ DESC</span></template>

<!-- SortIdle.vue -->
<template><span>↕ SORT</span></template>
import SortAsc from "~/components/SortAsc.vue";
import SortDesc from "~/components/SortDesc.vue";
import SortIdle from "~/components/SortIdle.vue";

const columns: NuxtTableColumn[] = [
  {
    key: "name",
    label: "Name",
    sortable: true,
    sortAscComponent: SortAsc,
    sortDescComponent: SortDesc,
    sortDefaultComponent: SortIdle,
  },
];

If these components are not provided, the table automatically uses the default labels.

Detailed manual-filter-change and manual-sort-change flow

When @manual-filter-change / @manual-sort-change listeners are passed, table switches to manual mode and emits these events. You control final dataset in parent component.

<script setup lang="ts">
import { computed, ref } from "vue";
import type {
  NuxtTableColumn,
  NuxtTableManualFilterChange,
  NuxtTableManualSortChange,
} from "@serhiitupilow/nuxt-table/runtime";

type TicketRow = {
  id: number;
  title: string;
  priority: "low" | "medium" | "high" | "critical";
  status: "todo" | "in_progress" | "done";
  createdAt: string;
};

const allRows = ref<TicketRow[]>([
  {
    id: 1,
    title: "Fix auth flow",
    priority: "high",
    status: "in_progress",
    createdAt: "2026-02-18",
  },
  {
    id: 2,
    title: "Write docs",
    priority: "low",
    status: "done",
    createdAt: "2026-02-10",
  },
  {
    id: 3,
    title: "Release v1",
    priority: "critical",
    status: "todo",
    createdAt: "2026-02-20",
  },
]);

const columns: NuxtTableColumn[] = [
  { key: "id", label: "ID", sortable: true },
  { key: "title", label: "Title", sortable: true, filterable: true },
  { key: "status", label: "Status", filterable: true },
  { key: "priority", label: "Priority", sortable: true },
];

const manualStatusFilter = ref<string>("");
const manualSort = ref<{ key: string; direction: "asc" | "desc" | null }>({
  key: "",
  direction: null,
});

const rows = computed(() => {
  const statusFiltered = allRows.value.filter((row) => {
    if (!manualStatusFilter.value) {
      return true;
    }

    return row.status === manualStatusFilter.value;
  });

  if (manualSort.value.key !== "priority" || !manualSort.value.direction) {
    return statusFiltered;
  }

  const rank: Record<TicketRow["priority"], number> = {
    low: 0,
    medium: 1,
    high: 2,
    critical: 3,
  };

  const directionMultiplier = manualSort.value.direction === "asc" ? 1 : -1;

  return [...statusFiltered].sort((left, right) => {
    return (rank[left.priority] - rank[right.priority]) * directionMultiplier;
  });
});

function onManualFilterChange(payload: NuxtTableManualFilterChange) {
  if (payload.columnKey !== "status") {
    return;
  }

  manualStatusFilter.value = String(payload.value ?? "").trim();
}

function onManualSortChange(payload: NuxtTableManualSortChange) {
  manualSort.value = {
    key: payload.columnKey,
    direction: payload.direction,
  };
}
</script>

<template>
  <NuxtTable
    :columns="columns"
    :rows="rows"
    @manual-filter-change="onManualFilterChange"
    @manual-sort-change="onManualSortChange"
  />
</template>

Notes:

  • In manual mode, the table does not transform rows internally; you pass already transformed rows from outside.
  • Use payload columnKey, value, direction, rows, and filters from events to build server/client-side query logic.

Server-side manual-filter-change / manual-sort-change example

Use manual events to request data from backend and pass ready rows back to table.

<script setup lang="ts">
import { ref } from "vue";
import type {
  NuxtTableColumn,
  NuxtTableManualFilterChange,
  NuxtTableManualSortChange,
} from "@serhiitupilow/nuxt-table/runtime";

type UserRow = {
  id: number;
  name: string;
  status: "active" | "paused";
  createdAt: string;
};

const rows = ref<UserRow[]>([]);
const loading = ref(false);

const query = ref<{
  page: number;
  pageSize: number;
  status: string;
  sortKey: string;
  sortDirection: "asc" | "desc" | "";
}>({
  page: 1,
  pageSize: 20,
  status: "",
  sortKey: "",
  sortDirection: "",
});

const columns: NuxtTableColumn[] = [
  { key: "id", label: "ID", sortable: true },
  { key: "name", label: "Name", sortable: true, filterable: true },
  { key: "status", label: "Status", filterable: true },
  { key: "createdAt", label: "Created", sortable: true },
];

async function fetchRows() {
  loading.value = true;

  try {
    const data = await $fetch<UserRow[]>("/api/users", {
      query: {
        page: query.value.page,
        pageSize: query.value.pageSize,
        status: query.value.status,
        sortKey: query.value.sortKey,
        sortDirection: query.value.sortDirection,
      },
    });

    rows.value = data;
  } finally {
    loading.value = false;
  }
}

function onManualFilterChange(payload: NuxtTableManualFilterChange) {
  if (payload.columnKey === "status") {
    query.value.status = String(payload.value ?? "").trim();
    query.value.page = 1;
  }

  fetchRows();
}

function onManualSortChange(payload: NuxtTableManualSortChange) {
  query.value.sortKey = payload.columnKey;
  query.value.sortDirection = payload.direction ?? "";
  query.value.page = 1;

  fetchRows();
}

await fetchRows();
</script>

<template>
  <NuxtTable
    :columns="columns"
    :rows="rows"
    @manual-filter-change="onManualFilterChange"
    @manual-sort-change="onManualSortChange"
  />

  <p v-if="loading">Loading...</p>
</template>

What happens here:

  • Passing @manual-filter-change and @manual-sort-change switches the table to manual mode.
  • Table emits manual-filter-change / manual-sort-change instead of transforming rows internally.
  • Parent updates query params, calls API, and passes backend result as new rows.

Custom filter component

<!-- StatusFilter.vue -->
<script setup lang="ts">
const props = defineProps<{
  modelValue: unknown;
  column: { key: string; label: string };
}>();

const emit = defineEmits<{
  "update:model-value": [value: string];
}>();
</script>

<template>
  <select
    :value="String(props.modelValue ?? '')"
    @change="
      emit('update:model-value', ($event.target as HTMLSelectElement).value)
    "
  >
    <option value="">All</option>
    <option value="active">Active</option>
    <option value="paused">Paused</option>
  </select>
</template>
const columns: NuxtTableColumn[] = [
  {
    key: "status",
    label: "Status",
    filterable: true,
    filterComponent: StatusFilter,
  },
];

Custom cell component

<!-- NameCell.vue -->
<script setup lang="ts">
const props = defineProps<{
  row: Record<string, unknown>;
  value: unknown;
}>();
</script>

<template>
  <strong>{{ props.value }}</strong>
</template>
const columns: NuxtTableColumn[] = [
  {
    key: "name",
    label: "Name",
    cellComponent: NameCell,
  },
];

Troubleshooting

  • DnD does nothing: ensure enableColumnDnd is true.
  • Filters do nothing: ensure column has filterable: true or a filterComponent that emits update:model-value.
  • Unexpected row keys: set a stable rowKey function for datasets without id.
  • Style conflicts: disable injectDefaultStyles and provide full custom CSS.

License

MIT