Search by

edulazaro / wiretables

edulazaro

Tables for Laravel, Livewire and Alpine: Blade components that read the same on a phone and a desktop, header sorting and a row actions menu. Pure CSS, themed with the wire* family.

Package info

github.com/edulazaro/wiretables

Language:CSS

pkg:composer/edulazaro/wiretables

Statistics

Installs: 6

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

1.0.0 2026-10-03 16:45 UTC

This package is auto-updated.

Last update: 2026-10-03 16:49:38 UTC


README

Wiretables

wiretables

Tables for Laravel, Livewire and Alpine: Blade components that read the same on a phone and on a desktop, header sorting, and a row actions menu that opens above everything, so no scrolling table or modal cuts it off. Pure CSS, no Tailwind or Bootstrap needed. Part of the wire* family: themeable through the shared data-wire-theme attribute, visually coherent with wiremodal, wiretoast, wirepicker, wirecookies and wirebug.

Blade markup you write · columns that hide or stack into cards · sorting · load more · row actions menu · 11 themes · 0 runtime deps.

It is not a table engine configured from PHP arrays: each table is written in Blade, cell by cell, so a cell that needs something unusual is just Blade. The package brings the pieces, their look, and the behaviour every list repeats.

Install

composer require edulazaro/wiretables
php artisan vendor:publish --tag=wiretables-assets

Add to your layout, the core and, if you want one, a theme:

<link rel="stylesheet" href="{{ asset('vendor/wiretables/css/wiretables-core.css') }}">
<link rel="stylesheet" href="{{ asset('vendor/wiretables/css/themes/studio.css') }}">

Or import into your Vite bundle:

@import "../../vendor/edulazaro/wiretables/resources/css/wiretables-core.css";
@import "../../vendor/edulazaro/wiretables/resources/css/themes/studio.css";

wiretables.css holds the core and every theme in one file, for when you would rather not choose. Adding themes to the package never makes wiretables-core.css bigger.

The row menu needs Alpine with its anchor plugin, both bundled with Livewire 3 and 4.

A table

<x-wiretable>
    <x-slot:head>
        <x-wiretable.th>Event</x-wiretable.th>
        <x-wiretable.th hide="sm" shrink>Status</x-wiretable.th>
        <x-wiretable.th hide="md" shrink>Date</x-wiretable.th>
        <x-wiretable.th actions />
    </x-slot:head>

    @forelse ($projects as $project)
        <x-wiretable.row wire:key="project-{{ $project->id }}">
            <x-wiretable.td>
                <x-wiretable.primary :title="$project->name" :subtitle="$project->email" :href="route('projects.show', $project)">
                    {{-- What the hidden columns show on a phone. --}}
                    <p class="md:hidden">{{ $project->status }} · {{ $project->date }}</p>
                </x-wiretable.primary>
            </x-wiretable.td>
            <x-wiretable.td hide="sm" shrink>{{ $project->status }}</x-wiretable.td>
            <x-wiretable.td hide="md" shrink>{{ $project->date }}</x-wiretable.td>
            <x-wiretable.td actions>
                <x-wiretable.menu :label="'Actions of '.$project->name">
                    <x-wiretable.menu-item :href="route('projects.show', $project)">Open</x-wiretable.menu-item>
                </x-wiretable.menu>
            </x-wiretable.td>
        </x-wiretable.row>
    @empty
        <x-wiretable.empty colspan="4">Nothing yet.</x-wiretable.empty>
    @endforelse
</x-wiretable>

The first column holds the record and takes the room left; the rest fit their content (shrink). A column with hide="md" shows from that breakpoint up (sm, md, lg, xl, Tailwind's widths), and its th and td take the same value. Nothing scrolls sideways: what a phone cannot show, the first cell repeats in its slot.

Figures and amounts take align="right" on both the th and the td; a sortable header keeps its arrow on the inner side.

A footer

Pagination, totals or anything else under the rows goes in the footer slot, inside the frame:

<x-wiretable>
    …
    <x-slot:footer>
        {{ $projects->links() }}
    </x-slot:footer>
</x-wiretable>

On a phone

Two ways, chosen per table.

Columns that hide (the default): each column shows from its hide breakpoint up, and the first cell repeats what a phone cannot see. Best for tables with a few columns that matter.

Rows that stack into cards: stack="md" turns each row into a card below that width, and each cell becomes a line with its label above it. Best when every value matters on a phone too.

<x-wiretable stack="md" expandable>
    …
    <x-wiretable.row wire:key="…">
        <x-wiretable.td label="Property">…</x-wiretable.td>
        <x-wiretable.td hide="md" label="Price" align="right">…</x-wiretable.td>
        <x-wiretable.td actions>…</x-wiretable.td>
    </x-wiretable.row>
</x-wiretable>

With expandable, the cells that have hide fold away in the card and a button in the actions cell unfolds them, one row at a time. On a desktop nothing changes.

Add compact for a denser card: the cells without hide and the actions share one line, and the cells with hide go below it, folded with expandable. Leave out their label for plain lines.

Inside a card

flush drops the frame (ground, line and radius) and puts the first and last columns against the edges, for a table that already sits in a card of its own, such as a dashboard panel.

Separated rows

rows="separated" draws each row as a card of its own, with a little room between them, instead of one frame with lines. --wtb-row-gap sets the room. It combines with stack.

The first cell

<x-wiretable.primary> is the record: its name as a link (href), as a button running an Alpine expression (action, to open it in place), or as plain text; a muted subtitle; a leading slot for an avatar or a thumbnail; and, in its default slot, whatever the hidden columns show on narrow screens. On a desktop, hovering the row underlines the name.

<x-wiretable.primary :title="$user->name" :subtitle="$user->email" action="$dispatch('user-open', { id: {{ $user->id }} })">
    <x-slot:leading><img src="{{ $user->avatar }}" alt="" width="32" height="32"></x-slot:leading>
</x-wiretable.primary>

Sorting

Give a header a sortable key and the component's sort and direction, and it becomes a button that shows the order it is in:

<x-wiretable.th hide="md" shrink sortable="date" :sort="$sort" :direction="$direction">Date</x-wiretable.th>

The Livewire side is the WithSorting trait: it holds sort and direction (in the URL, so a sorted list can be reloaded or shared), and its sortBy() cycles ascending, descending and back to the list's own order, then goes back to the first page. Only the keys sortable() returns are accepted, since the client can send any string:

use EduLazaro\Wiretables\Concerns\WithSorting;

class Projects extends Component
{
    use WithPagination, WithSorting;

    protected function sortable(): array
    {
        return ['date', 'received'];
    }

    public function render()
    {
        $column = ['date' => 'starts_on', 'received' => 'created_at'][$this->sort] ?? null;

        $projects = Project::query()
            ->when($column, fn ($query) => $query->orderBy($column, $this->direction))
            ->paginate(25);

        return view('livewire.projects', compact('projects'));
    }
}

The query maps the key to a column itself. A header can call another method with method="order".

Load more

For a list that grows instead of paging, WithLoadMore and <x-wiretable.load-more>:

use EduLazaro\Wiretables\Concerns\WithLoadMore;

class Properties extends Component
{
    use WithLoadMore;

    protected function perLoad(): int
    {
        return 30;
    }

    public function render()
    {
        return view('livewire.properties', [
            'properties' => $this->loadMoreFrom(Property::query()->latest()),
        ]);
    }
}
<x-wiretable>
    …
</x-wiretable>
<x-wiretable.load-more :show="$hasMore" />

Each read asks for everything up to the current page from the start, rather than skipping what was loaded: a record added or removed meanwhile would otherwise shift the offset and repeat or lose rows. The page count is locked, so the client cannot ask for a thousand rows at once. Any property update (a filter, the search) and any new order (WithSorting) start again from the first page. The button is busy while the next page comes.

The row menu

<x-wiretable.menu :label="…"> is the row's "⋯": a button and a menu of actions.

<x-wiretable.menu :label="'Actions of '.$project->name">
    <x-wiretable.menu-item :href="route('projects.show', $project)">
        <x-slot:leading><svg>…</svg></x-slot:leading>
        Open
    </x-wiretable.menu-item>
    <x-wiretable.menu-item wire:click="duplicate({{ $project->id }})">Duplicate</x-wiretable.menu-item>
    <x-wiretable.menu-separator />
    <x-wiretable.menu-item wire:click="confirmDelete({{ $project->id }})" danger>Delete…</x-wiretable.menu-item>
</x-wiretable.menu>

A menu-item is a link with href and a button otherwise (wire:click, x-on:click); leading holds an icon (18 px) or a dot before the text, and danger paints what destroys or cannot be undone. menu-separator splits the groups. The menu is moved to <body> and anchored to its button, so a table that scrolls, a card or a modal never clips it; choosing an item, a click outside or Escape closes it. Actions per row live here rather than as loose buttons, which do not fit on a phone.

Lists without column headers

When each record reads as a card rather than a row of columns (a title with badges, a muted line, a status on the right), or for a grid of cards, use wirelist: built on this package, with the same look, menu, load more and themes.

Options

Component Attribute
x-wiretable stack sm, md, lg or xl: below it, rows become cards
expandable With stack, the cells with hide fold behind a button
compact With stack, the main cells and the actions on one line
rows separated: each row a card of its own
flush No frame, for a table inside a card
slot footer Under the rows, inside the frame
x-wiretable.th hide sm, md, lg or xl: the breakpoint from which the column shows
shrink As wide as its content
actions The narrow last column; its text is for screen readers only ("Actions" by default)
align right for figures and amounts
sortable, sort, direction, method Header sorting, above
x-wiretable.td hide, shrink, actions, align The same as its column's header
label What the cell is, shown above it when the table stacks
x-wiretable.primary title, subtitle, href, action, navigate The record, above (navigate adds wire:navigate); slots leading and default
x-wiretable.empty colspan The row shown when the list is empty
x-wiretable.menu label The button's accessible name
x-wiretable.menu-item href, danger A link or a button; slot leading for an icon
x-wiretable.menu-separator A line between groups
x-wiretable.load-more show, method The "Load more" button, while there is more

Any other attribute (class, wire:key, data-*) lands on the element.

Themes

Pick the theme once on <html>, shared with the rest of the family, and import its file:

<html data-wire-theme="studio">
Theme Vibe
default Neutral light/dark, follows the system
soft Tinted with the accent
glass Frosted backdrop blur
gradient The header on the accent's gradient, white text
neon Dark, glowing accent, mono font
minimal No border, a stripe of accent on the left
claude Warm minimal, Anthropic-inspired
chatgpt Clean neutral
studio Gray-900, sharp corners
synthwave Retro 80s purple and magenta
megaflow Flowbite-style: clean white card, gray header
brutalist Black border, hard offset shadow, yellow header

Dark mode: data-wire-theme-mode="dark" or a .dark ancestor. The row menu opens on <body>, so it follows a theme set on <html>.

Custom look

Everything is a CSS variable. The --wire-* tokens are shared with the family, so a theme written for them already reaches the table; --wtb-* are its own:

:root {
    --wtb-text: #1e2a36;
    --wtb-muted: #5e6a77;
    --wtb-line: #e3e6e1;
    --wtb-row-hover: #f9faf8;
    --wtb-radius: 6px;
}
Token
--wtb-bg, --wtb-text, --wtb-muted, --wtb-faint, --wtb-line, --wtb-row-hover Colours: ground, ink, secondary text, the idle sort icon, lines, a hovered row
--wtb-head-bg, --wtb-head-text, --wtb-head-strong The header, and its sorted or hovered column
--wtb-head-font-weight, --wtb-head-text-transform, --wtb-head-letter-spacing Small caps headers: uppercase, 0.05em
--wtb-radius, --wtb-shadow, --wtb-font, --wtb-font-size, --wtb-head-font-size, --wtb-head-line-height Shape and type (--wtb-font inherits the page's by default)
--wtb-head-padding, --wtb-cell-padding, --wtb-actions-head-padding, --wtb-actions-cell-padding, --wtb-empty-padding Spacing
--wtb-menu-bg, --wtb-menu-shadow, --wtb-menu-radius, --wtb-menu-width, --wtb-menu-font-size, --wtb-menu-z-index The row menu
--wtb-menu-item-hover, --wtb-danger, --wtb-menu-icon-size A hovered action, a dangerous one, the size of its icon
--wtb-footer-padding, --wtb-row-gap, --wtb-card-padding, --wtb-label-font-size The footer, separated rows, stacked cards
--wtb-accent, --wtb-accent-text The "Load more" button

The package sets every value it relies on (borders, margins, button resets), so a table looks the same with or without a CSS framework's reset underneath.

Anatomy

.wtb-table                 the frame: radius, line, horizontal scroll as a safety net
                           (.wtb-stack-{sm,md,lg,xl}, .wtb-separated)
  table.wtb-grid
    thead.wtb-head
      th.wtb-th            .wtb-shrink, .wtb-actions, .wtb-from-{sm,md,lg,xl}
        button.wtb-sort    .wtb-sorted, with .wtb-sort-icon
    tbody.wtb-body
      tr.wtb-row
        td.wtb-td          .wtb-nowrap, .wtb-actions, .wtb-right, .wtb-from-*, [data-label]
          .wtb-primary     .wtb-title (-link, -button, -text), .wtb-subtitle
          .wtb-menu        .wtb-menu-trigger
      td.wtb-empty
  .wtb-footer
.wtb-load-more             button.wtb-load-more-button

.wtb-menu-panel            the row menu, on <body>
  .wtb-menu-item           .wtb-danger
  .wtb-menu-separator

Languages

Its few words (the actions column's label for screen readers, "Show more", "Load more") ship in English and Spanish:

php artisan vendor:publish --tag=wiretables-lang

Tests

composer install
vendor/bin/phpunit

After editing wiretables-core.css or a theme, rebuild the bundle with php bin/build-css.php; a test fails while it is out of date.

Sponsors

wiretables is supported by the following sponsors. Thank you for keeping it growing:

Kenodo Kenodo     AndorraDev AndorraDev

Author

Created by Edu Lazaro

License

wiretables is open-sourced software licensed under the MIT license.