Skip to main content
Copy for LLM

Table

A flexible table component with sub-components for head, body, foot, row, th, and td. Supports striped rows, hoverable rows, compact density, sortable columns, and horizontal scroll on narrow screens.

Wrap your rows in the table.* sub-components — not in raw <thead> / <tbody> / <tr> / <td> HTML. <x-wirekit::table> only styles the outer <table> element. Padding, row dividers, stripe + hover wiring, and sticky-header support live on the sub-components. Dropping plain HTML inside compiles cleanly and renders without errors — but produces a visually-broken table with flush-to-border content and no row separators. The component prints a console.warn in debug mode when it detects plain <thead> / <tbody> / <tr> / <th> / <td> descendants, a raw row around sub-component cells included; a table nested in a cell has rows of its own and is left out. Production stays silent. As a safety net, raw <td> / <th> cells inside <x-wirekit::table> now receive the same readable cell padding the table.td sub-component uses — so a raw table is no longer cramped flush-to-border even before you migrate it to the sub-components.

Basic Usage

Simple table
Name Role Balance
Jane Doe Administrator €1,250.00
John Smith Editor €840.00

Variants

Variant flags on the parent <x-wirekit::table> cascade to all rows via data attributes — no need to configure each sub-component.

Striped Rows

Alternating row tint, useful for dense data.

Striped + hoverable + compact
Product SKU Stock
Keyboard KB-001 42
Mouse MS-014 128
Monitor MN-220 7

Variant flags compose freely — any combination of striped, hoverable, and compact works. Example:

<x-wirekit::table striped hoverable compact>...</x-wirekit::table>

Sortable Columns

WireKit tables support two sorting modes: Livewire (server-side) and Alpine (client-side). Both render the same clickable headers with direction indicators and proper ARIA attributes.

Add sortable to <x-wirekit::table.th> to render a clickable header with a direction indicator. The header wires up automatically: with alpine-sort on the table, clicks cycle direction (unsorted → ascending → descending → unsorted) and reorder rows client-side. With Livewire (next section), bind sort-direction and wire:click to drive sorting server-side.

Sortable headers (Alpine — interactive)
Alpha 2025-01-15 Active
Beta 2025-03-08 Draft
Gamma 2025-02-22 Active

Click any column header above to cycle through ascending → descending → unsorted. The component sets aria-sort="ascending|descending|none" automatically per the WAI-ARIA contract for assistive technologies.

Livewire Integration

In a Livewire component, bind sort-direction dynamically and pass the sort method call to sort-action. Unlike putting wire:click on the header cell itself, sort-action wraps the label in a real <button>, so the sort is operable by keyboard (WCAG 2.1.1) and gets a visible focus ring:

The <button> fills the header cell, so the whole cell is the click target and the padding is not a dead zone. That matters on touch: a button sized to its label alone is only as tall as one line of text, which is under the 24×24 minimum of WCAG 2.5.8. The cell keeps its original height — the padding moves onto the button rather than being added to it — and the focus ring is drawn inside the cell so the table's scroll container cannot clip it on the header row. On a coarse pointer, and inside an element with data-wk-touch, the button takes the 44px height every button takes there, so a header row worked by finger is as tall as its target.

<x-wirekit::table hoverable>
    <x-wirekit::table.head>
        <x-wirekit::table.row>
            @foreach(['name' => 'Name', 'email' => 'Email'] as $field => $label)
                <x-wirekit::table.th
                    sortable
                    :sort-direction="$sortField === $field ? $sortDir : null"
                    sort-action="sortBy('{{ $field }}')"
                >
                    {{ $label }}
                </x-wirekit::table.th>
            @endforeach
        </x-wirekit::table.row>
    </x-wirekit::table.head>
    <x-wirekit::table.body>
        @foreach($users as $user)
            <x-wirekit::table.row>
                <x-wirekit::table.td>{{ $user->name }}</x-wirekit::table.td>
                <x-wirekit::table.td>{{ $user->email }}</x-wirekit::table.td>
            </x-wirekit::table.row>
        @endforeach
    </x-wirekit::table.body>
</x-wirekit::table>

Client-Side Sorting (Alpine)

For static or small tables that don't need a server round-trip, use the alpine-sort prop on the table and column on each sortable <th>. This enables fully client-side sorting powered by Alpine.js — no Livewire required.

<x-wirekit::table alpine-sort hoverable>
    <x-wirekit::table.head>
        <x-wirekit::table.row>
            <x-wirekit::table.th sortable column="name">Name</x-wirekit::table.th>
            <x-wirekit::table.th sortable column="role">Role</x-wirekit::table.th>
            <x-wirekit::table.th sortable column="balance" align="right">Balance</x-wirekit::table.th>
        </x-wirekit::table.row>
    </x-wirekit::table.head>
    <x-wirekit::table.body>
        <x-wirekit::table.row>
            <x-wirekit::table.td>Jane Doe</x-wirekit::table.td>
            <x-wirekit::table.td>Administrator</x-wirekit::table.td>
            <x-wirekit::table.td align="right" data-wk-sort-value="1250">€1,250.00</x-wirekit::table.td>
        </x-wirekit::table.row>
        <x-wirekit::table.row>
            <x-wirekit::table.td>John Smith</x-wirekit::table.td>
            <x-wirekit::table.td>Editor</x-wirekit::table.td>
            <x-wirekit::table.td align="right" data-wk-sort-value="840">€840.00</x-wirekit::table.td>
        </x-wirekit::table.row>
        <x-wirekit::table.row>
            <x-wirekit::table.td>Alice Lee</x-wirekit::table.td>
            <x-wirekit::table.td>Viewer</x-wirekit::table.td>
            <x-wirekit::table.td align="right" data-wk-sort-value="2100">€2,100.00</x-wirekit::table.td>
        </x-wirekit::table.row>
    </x-wirekit::table.body>
</x-wirekit::table>

How it works:

  • Clicking a column header cycles through: unsorted → ascending → descending → unsorted
  • The sort component reads cell values from data-wk-sort-value (if present) or falls back to the cell's text content
  • Numeric values are compared numerically; strings are compared in the application's locale (German sorts "Ä" with "A", Swedish after "Z"), with numbers inside text in natural order ("Item 9" before "Item 10")
  • The original row order is preserved and restored when sorting is cleared
  • aria-sort is updated dynamically via Alpine bindings

When to use data-wk-sort-value: Use it when the display value differs from the sort value — for example, formatted currencies (€1,250.00 displays but 1250 sorts), dates, or values with units. If the text content is already sortable (plain text, plain numbers), you can omit it.

Alpine sorting reads a cell's text the way the page's reader reads it: as a number in the application's locale first, and in the machine spelling when the locale could not have written it. A column showing 12.345 or 1.234,56 on a German page, or 1 234,56 on a French one, sorts numerically without a data-wk-sort-value. German formatting never writes 1.5 or 0.125, because a group has three digits and the first one does not start with a zero, so those stay one and a half and an eighth. An ISO date is no number at all and sorts chronologically as text.

A data-wk-sort-value is read in the machine spelling first. On a page whose locale groups digits with a period, a raw value such as 12.345 meaning twelve point three four five belongs there, not in the cell's text, where the reader sees twelve thousand.

Reordering Rows

A list whose order the reader sets, like the images of a product or the steps of a checklist, takes Livewire's wire:sort on the body and wire:sort:item on each row. A table.reorder cell in each row then carries the handle a pointer drags and two arrow buttons for everyone else: nobody can drag with a keyboard or a screen reader, and anybody can press a button. The arrows call the same method a drop calls, so one method serves both:

Rows reordered by dragging or with the arrows
Order Image Role
Front view Main image
Side view Gallery
Detail of the clasp Gallery
// 1. Called by a drop and by both arrows: the row's key and its new position, from 0
public function moveImage(int $id, int $position): void
{
    // 2. Store the new order, then render the rows in it
}

position and count disable the arrows that lead nowhere: the first row cannot move up, the last cannot move down. A keyboard stays where it was: after the server answers, the focus is back on the arrow that was pressed in the row's new place, or on the other one once the row has reached an end, so a row can be moved several places with repeated presses. label names the row in the arrows' names, "Move Front view up", which matters where the row's own cells do not say what it is. table.reorder-head heads the column for a screen reader and draws nothing.

The arrows are neutral icon buttons, ghost at size xs unless the cell says otherwise. arrow-surface and arrow-size take any surface and size a button takes, so an application whose icon buttons are soft draws its arrows the same way:

<x-wirekit::table.reorder :item="$image->id" :position="$loop->index" :count="$loop->count"
    action="moveImage" :label="$image->title" arrow-surface="soft" arrow-size="sm" />

For a list that is not a table, Reorder draws the same controls on an element any row can hold.

A drag reorders the rows in the browser before its request leaves, so the order on screen does not tell you whether the server has taken it. Each cell carries the position the server drew in data-wk-reorder-position: a test that waits for those to match the new order waits for the server's answer, not for the browser's guess.

Caption

Add a semantic <caption> to describe the table content for screen readers:

Table with caption
A list of active team members and their roles.
Name Role
Jane Doe Admin

Keep column headers visible while scrolling long tables:

Sticky header
Name Role Balance
Alice Admin €500
Bob Editor €1,200
Carol Viewer €890
Dave Editor €340
Eve Admin €2,100
Frank Viewer €750
Grace Admin €3,400
Hank Editor €620
Ivy Viewer €1,050
Jack Editor €980
Karen Admin €4,200
Leo Viewer €310

The responsive wrapper gets a bounded height and overflow-y-auto when sticky-header is enabled, and the bound is what makes the sticky heading work at all: the wrapper already scrolls horizontally, which makes it a scroll container on both axes — a heading with no height limit above it therefore measures itself against a box that never scrolls vertically, and never sticks.

The pinned head tells the wrapper how much of its top it covers, so a field, a link or a button in a row that takes the focus on the way up comes to rest below the head rather than under it.

The default bound is 24rem. Set sticky-header-max to any CSS length when that is the wrong one — a viewport-relative value is usually what a table of unknown length wants, because the limit then simply never applies to a short table:

{{-- 1. A five-row table never reaches 70vh, so it keeps its natural height and reserves nothing. --}}
{{-- 2. A thirty-row table stops at 70vh and its head stays put while the body scrolls. --}}
<x-wirekit::table sticky-header sticky-header-max="70vh">

The bound reaches the element as an inline max-height, not as a utility class — so it takes any CSS length exactly as written, sticky-header-max="calc(100vh - 4rem)" included, with nothing to escape and nothing for your Tailwind build to have to see first.

Sticky First Column

Set sticky-column to freeze the first column against the start edge while the rest of the table scrolls horizontally — ideal for wide tables where the first column is the row's identity (a name, a date). The edge is logical rather than physical, so under dir="rtl" the column holds the right-hand side without a second rule. The frozen column keeps a solid background so the scrolling cells don't show through; pair it with sticky-header to freeze the leading top corner too.

Frozen first column
Region Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Total
North region €12,400 €11,800 €13,200 €12,900 €14,100 €13,800 €15,200 €14,600 €15,900 €16,300 €15,600 €18,200 €174,000
South region €9,200 €8,800 €9,600 €10,500 €11,300 €10,900 €12,100 €11,700 €12,400 €12,900 €12,100 €14,500 €136,000
East region €7,600 €7,200 €8,100 €8,400 €9,000 €8,700 €9,500 €9,100 €9,800 €10,200 €9,700 €11,800 €109,100
West region €10,800 €10,300 €11,200 €11,600 €12,400 €12,000 €13,100 €12,700 €13,500 €14,000 €13,300 €15,900 €150,800

Only below a breakpoint

sticky-column on its own gives the table its natural width at every size, so its cells never wrap to fit the box: a frozen column needs something to scroll past. A table that needs the frozen column only on a phone can say so instead. sticky-column-below takes a breakpoint and freezes the first column below it; from that width up the table fills its box again and wraps its cells, rather than scrolling sideways past a column it does not need to hold:

<x-wirekit::table sticky-column-below="lg">…</x-wirekit::table>

The prop turns the frozen column on by itself, so sticky-column is not needed alongside it. The breakpoints are the ones your Tailwind theme defines: sm, md, lg, xl and 2xl.

A table the component did not render, such as one your Markdown pipeline produced, gets the same behavior from the attribute data-wk-sticky-column-below="lg". Its rules ship in the stylesheet, so they use Tailwind's default breakpoint widths rather than your theme's. See attribute hooks.

Below the breakpoint that attribute also gives the table its natural width, like sticky-column does. A Markdown table with a sentence in a cell then becomes as wide as that sentence on one line. Add data-wk-sticky-column-fit to freeze the first column only and keep the table at the width your own CSS gives it, so its cells wrap into the box, and give the frozen column a floor with --size-wk-table-sticky-column-min:

<!-- 1. Frozen below md, wrapping into its box, first column at least 6rem wide -->
<table data-wk-sticky-column-below="md" data-wk-sticky-column-fit style="--size-wk-table-sticky-column-min: 6rem">…</table>

The frozen cell paints the page background, so the columns that scroll under it stay hidden. A Markdown table whose rows paint their own background, stripes for instance, hands that color to the frozen cell by setting --wk-sticky-column-bg on the row, as the attribute hooks show.

Long labels in the frozen column

The frozen column wraps instead of taking over the width. Its cells are capped at --size-wk-table-sticky-column-max, which is min(40vw, 16rem) by default, so on a phone a long label breaks onto a second line and the columns next to it stay in view. A table with room to spare gives the column its natural width, and nothing wraps.

Frozen column with long labels
Feature Starter Team Enterprise
Projects you can create 3 Unlimited Unlimited
Storage for uploaded files 5 GB 100 GB 1 TB
Single sign-on through your identity provider Not included Not included Included
Support from a named contact Not included By email By phone and email

To set a different cap for one table, set the token on that table:

<x-wirekit::table sticky-column style="--size-wk-table-sticky-column-max: 12rem">
    {{-- … --}}
</x-wirekit::table>

The columns beside it

The columns that scroll are capped as well, at the room beside the frozen column: the table's width less the frozen column's cap. A longer value wraps, so on a phone the first column beside the frozen one is seen whole, and each of the others can be scrolled fully into view. Short values stay on one line, and a header keeps its column as wide as its text.

A tighter cap goes in --size-wk-table-sticky-cell-max, for instance to show two narrow plans beside the frozen column instead of one:

<x-wirekit::table sticky-column-below="sm" style="--size-wk-table-sticky-cell-max: 8rem">
    {{-- … --}}
</x-wirekit::table>

The token only ever tightens: the room beside the frozen column stays the widest a column can get.

Use <x-wirekit::table.foot> for totals, summaries, or pagination rows:

Table with footer
Product Qty Price
WireKit Pro License 2 €198.00
Priority Support 1 €49.00
Starter Kit 1 €79.00
Total €326.00

Row headers

headerScope="row" makes the first cell of a row its header, so a screen reader names the record when it reads the cells beside it (WCAG 1.3.1). A row header takes the text color of the row and wraps like a cell, and it keeps the size and weight of a column head. Where the header is a value of its row, a product name or a budget, variant="cell" gives it the size and weight of the cells beside it:

Row headers that read as values of their row
Product Stock Value
Mechanical keyboard 42 €5,418.00
Wireless mouse 118 €4,602.00
Monitor stand 9 €531.00

variant changes the type alone: align, hideBelow and the scope still do what they say. A font size or weight of your own in class replaces the head's, with or without the variant, so class="font-normal" gives a row header the regular weight rather than leaving it to the order of the two utilities in the stylesheet.

Responsive Behavior

By default the table is wrapped in a horizontal-scroll container with WireKit's themed scrollbar. Disable with :responsive="false" if you handle overflow yourself.

The container scrolls sideways only, so a vertical swipe or mouse wheel that starts on a row scrolls the page. With sticky-header it also scrolls vertically, within the height sticky-header-max sets.

<x-wirekit::table :responsive="false">...</x-wirekit::table>

A responsive table says it scrolls before you touch it. A soft shadow appears at whichever inline edge has more table behind it, and disappears when you reach that end — so a reader on a phone, where there is no scrollbar until a drag is already underway, can see that the row continues instead of taking the cut-off column for the last one.

Nothing to switch on: it comes with responsive. It is driven by two one-pixel sentinels and an IntersectionObserver, so a hint is painted exactly while there is somewhere to scroll to — not on a timer, and not statically like a mask, which would dim the last column even once you had reached it. The pair is named for the start and end of the line rather than left and right, so it follows the reading direction: in a right-to-left document the hints sit on the other side.

The hint is a visual cue only. The scroll container itself carries tabindex="0" and a focus ring, so it is reachable and operable by keyboard whether or not the shadow is visible. Pass tableLabel to also make it a named screen-reader landmark — see Accessibility.

A printed table fits the sheet as far as its words allow. Paper cannot scroll sideways, so on paper the column headers wrap between words, as the cells do, and a table with a sticky first column takes the sheet's width. The same holds for any other table on the page. Words stay whole, so a table whose words alone are wider than the sheet prints the columns that fit. A landscape sheet holds more of them; to print a page on one, give that page this rule:

@page {
    size: landscape;
}

Hiding a Column on Small Screens

When the window is the wrong thing to measure

hide-below asks the viewport, and beside a sidebar that is the wrong question. In an app shell the sidebar steps beside the content at lg, so the table has less room at a 1024px viewport than at 768px, and a column that appears at md appears exactly where the table has least room.

hide-below-container asks the table instead:

<x-wirekit::table>
    <thead>
        <tr>
            <x-wirekit::table.th>Name</x-wirekit::table.th>
            <x-wirekit::table.th hide-below-container="3xl">Terms accepted</x-wirekit::table.th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <x-wirekit::table.td>Ada</x-wirekit::table.td>
            <x-wirekit::table.td hide-below-container="3xl">Yes</x-wirekit::table.td>
        </tr>
    </tbody>
</x-wirekit::table>

The two props do not share a scale, and the names overlap. Tailwind's container sizes are their own ladder, so hide-below-container="3xl" is 48rem of table while hide-below="md" is 48rem of window — and hide-below-container="md" is 28rem of table, not 48rem of anything. Both spellings are valid, so passing the one you meant for the other prop cannot be caught for you. Pick the question first: does this column depend on the size of the screen, or on the space the table has?

Give the header and every cell of the column the same value, exactly as with hide-below — a header hidden without its cells puts every value under the wrong heading. The two can be combined: a column may be hidden on a phone and when the table itself is narrow.

A long column name

A column header stays on one line, which is right for the usual one-word head. It is wrong when the name is long and the content is not — a permissions table with a column per legal document holds those columns as wide as their names while the checkboxes under them need a fraction of it, and the table runs past its frame rather than the heads taking two lines.

wrap on the header lets it break:

<x-wirekit::table.th wrap>Data protection agreement</x-wirekit::table.th>

It breaks inside a word where there is no space to break at, which is what a long compound noun needs. A header without wrap is unchanged, so no existing table moves.

Over a column of numbers that breaks too eagerly: nothing in the column is wider than a digit, so a head allowed to break anywhere falls to that width. wrap="words" breaks only between words and evens the lines up, so the longest word is the narrowest the head gets:

{{-- 1. A band head over a number column: "bis" / "10 %", never "bis 10" / "%" --}}
<x-wirekit::table.th wrap="words" align="right">bis 10 %</x-wirekit::table.th>

The evening-up is text-wrap: balance, which newer browsers support. Where it is missing the head still breaks between its words and only the last line is not balanced.

Neither helps when the columns beside a long head hold a mark each and another column takes all the room that is left: whatever width the heads give up goes to that column, and with wrap they fall to one letter per line. sideways turns the label on its side instead. It reads from bottom to top on one line, so the column is as wide as one line of text in any language, and the row grows to the longest name:

Consent columns beside a name that takes the rest
Name Terms of service Privacy policy Cookie policy Accessibility statement
Ada Lovelace
Grace Hopper
Alan Turing

Every head in a row that holds a turned one stands on the row's floor, so the upright heads line up with the turned labels and the data starts right under all of them. A sortable head keeps its sort indicator upright under the label, where its arrow still points the way the order runs. A turned head stays on one line, so it takes precedence over wrap.

A column that matters less can step aside on a narrow screen. Give hide-below a breakpoint on the column's header and on each of its cells, and below that width the whole column is gone, so the columns the screen is for stay in view:

The last-active column steps aside below sm
Name Last active Roles
Ada Lovelace 3 minutes ago Admin
Grace Hopper Yesterday Editor

The breakpoints are the ones your Tailwind theme defines: sm, md, lg, xl and 2xl. Put the prop on the header and on every cell of the column. A header hidden without its cells would leave each value in that column under the wrong heading, and cells hidden without their header would do the same the other way round.

A sticky-column table freezes the first cell of every row, so hide a later column there. Hiding the first one leaves nothing frozen below the breakpoint.

Keeping what the hidden column held

Hiding a column takes its value off the screen with it. That is right when the column is secondary, and wrong when it is the thing the reader came for — a permissions table that hides its consent column on a phone has hidden the answer.

Squeezing it into a neighboring cell is the usual response and it is a trap: a cell that holds two values needs the width of both, so the table runs past its frame at exactly the size that made you hide the column.

details is a second row under the record, spanning the columns that are left:

The role moves under the record while the table is narrow
Name Role Last seen
Ada Lovelace Owner Yesterday
Role: Owner
Grace Hopper Viewer In March
Role: Viewer

The preview above is boxed to a narrow width so the details rows are visible on this page; your own table takes the width it is given, and the block above is the part you write.

Four props, and only the first is required:

prop what it does
details-below-container show the row while the table is narrower than this — the mirror of hide-below-container
details-below the same against the window, mirroring hide-below. Both may be given
details-colspan how many columns the row spans. Count the columns that are still visible at that width
details-for the id of the record's row header, written to headers so a screen reader is told which record this row continues

details-below-container must carry the same breakpoint as the columns you hid. The two are opposites of one another — the column disappears below it, the row appears there — so a mismatch leaves a width where the value is shown twice, or at which it is shown nowhere at all.

Under Livewire, the key is derived

Give the record row a wire:key and the details row takes <that key>-details on its own. There is nothing to declare: a details row belongs to exactly one record.

<x-wirekit::table.row wire:key="user-{{ $user->id }}" details-colspan="2">
    …
    <x-slot:details>{{ $user->role }}</x-slot:details>
</x-wirekit::table.row>

Without a key on the record, Livewire pairs the details row by position. That is correct only while every record emits one, always immediately behind itself — and the moment one record has details and its neighbor does not, the pairing goes wrong silently: both rows still render, so nothing fails, and one record's details sit under another record's name.

Key the record and the problem cannot arise. Nothing else from the record row crosses to the details row — its class would overwrite the details classes if it did.

Leave both ladders off and the row is shown at every width. That is a legitimate layout — a permanent sub-line per record — and not a mistake, so it is not warned about.

Three things are handled for you, and each one is a way this looks finished and is not:

  • the rule between a record and its own details is dropped, because they are one record. The rule between one record and the next moves to the details row, so the table still reads as rows.
  • when the details row is hidden, the last record does not keep a rule with nothing under it. A hidden row is still a row as far as the separator is concerned.
  • stripes count records, not rows, so a details row does not shift every stripe below it onto the wrong record. The details row wears the tint of the record it belongs to.

Keeping a control in view while the table scrolls sideways

A row that spans every column begins at the left edge of the table, not of the visible area. On a wide table that means a control in such a row scrolls out of sight exactly when somebody is reading the far columns.

Nothing needs to be added for this. Cap the control to the table's own container and pin it:

<x-wirekit::table.row>
    <x-wirekit::table.td colspan="15">
        <span style="max-width: 100cqi; position: sticky; left: 0; display: inline-block;">
            <x-wirekit::button size="xs">Release</x-wirekit::button>
        </span>
    </x-wirekit::table.td>
</x-wirekit::table.row>

Both halves are required, and each one alone does nothing. The cap without the pin changes no position; the pin without the cap has nothing to pin, because the content is already as wide as the table. On a phone over a wide table, the pair holds the control at the edge, and either half alone leaves it off screen.

100cqi is the inline size of the table's own container, which is the visible width rather than the scrolled one — so the cap is what turns left: 0 into "the edge of what you can see".

position: sticky on the cell does not work here, although it is what sticky-column uses for a first column. A cell spanning every column is already the width of the table, so pinning it pins nothing.

There is no preview for this one, deliberately: what it does only becomes visible while scrolling, and a static render shows the same picture either way. TableHoldsAControlInViewTest measures it instead, in Blink and WebKit, against two controls that let the control scroll away.

Width & Layout

Tables fill their parent width. For narrow tables, constrain the wrapper:

<div class="max-w-2xl">
    <x-wirekit::table>…</x-wirekit::table>
</div>

When responsive is enabled (default), the table scrolls horizontally on small screens. Column widths can be controlled via Tailwind on <x-wirekit::table.th>:

<x-wirekit::table.th class="w-16">ID</x-wirekit::table.th>
<x-wirekit::table.th class="w-1/2">Name</x-wirekit::table.th>

Props

<x-wirekit::table>

Prop Type Default Description
striped bool false Alternating row backgrounds
hoverable bool false Row highlight on hover
compact bool false Reduced vertical padding
responsive bool true Wrap in horizontal-scroll container
stickyHeader bool false Sticky column headers while scrolling
stickyHeaderMax string '24rem' How tall the body may get before it scrolls, when stickyHeader is on. Any CSS length, delivered as an inline max-height — calc(100vh - 4rem) works as written, with nothing to escape
stickyColumn bool false Freeze the first column while the rest scrolls horizontally. A long label in the frozen column wraps at --size-wk-table-sticky-column-max
stickyColumnBelow string|null null Freeze the first column only below a breakpoint: 'sm', 'md', 'lg', 'xl' or '2xl'. From that width up the table fills its box and wraps its cells. Turns the frozen column on by itself — see Only below a breakpoint
alpineSort bool false Enable client-side Alpine sorting (no Livewire needed)
tableLabel string|null null Accessible name for the focusable scroll wrapper, and the switch that makes it a screen-reader landmark. Without it the wrapper stays keyboard-reachable and simply is not a landmark — see Accessibility
scope string|null null Scoped personalization name

<x-wirekit::table.th>

Prop Type Default Description
sortable bool false Render as clickable sortable header
sortDirection string|null null 'asc', 'desc', or null (Livewire mode)
column string|null null Column identifier for Alpine sort mode (pairs with alpine-sort on table)
sortAction string|null null Livewire-sort mode: the wire:click method call (e.g. "sortBy('name')") that wraps the header label in a keyboard-operable <button> (WCAG 2.1.1 — the cursor-pointer cell alone is mouse-only).
align string 'left' 'left', 'center', 'right'
headerScope string 'col' The <th scope> attribute: 'col' (default), 'row' for a per-row header cell (WCAG 1.3.1), 'colgroup', 'rowgroup'. Distinct from scope below, which is theming.
hideBelow string|null null Hide the column below a breakpoint: 'sm', 'md', 'lg', 'xl' or '2xl'. Every table.td of the column takes the same value — see Hiding a Column on Small Screens
wrap bool|'words' false Let a long header take a second line instead of holding its column as wide as its longest word. true breaks anywhere, 'words' only between words and balances the lines — see A long column name
sideways bool false Turn the label on its side, reading from bottom to top on one line: a column of marks beside a column that takes the rest keeps one line of width — see A long column name
variant string 'header' The type of the head: 'header', the size and weight of a column head, or 'cell', the size and weight of a body cell, for a row header that is a value of its row — see Row headers. A font size or weight in class replaces the head's own either way
hideBelowContainer string|null null Hide the column below a width of the table: 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl' or '5xl' on Tailwind's container ladder. Every table.td of the column takes the same value — see When the window is the wrong thing to measure
scope string|null null Scoped personalization name

<x-wirekit::table.td>

Prop Type Default Description
align string 'left' 'left', 'center', 'right'
hideBelow string|null null Hide the cell below a breakpoint. Use the same value as the column's table.th, so header and cells go away together
hideBelowContainer string|null null Hide the cell below a width of the table. Use the same value as the column's table.th — see When the window is the wrong thing to measure
scope string|null null Scoped personalization name

<x-wirekit::table.reorder>

Prop Type Default Description
item mixed null The row's key, the value its row carries in wire:sort:item
position int 0 Where the row stands, counted from 0
count int|null null How many rows there are. Without it the down arrow is never disabled
action string null The Livewire method the body's wire:sort calls. The arrows call it with the row's key and its new position
label string|null null What the row is, for the arrows' names — see Reordering Rows
arrowSurface string 'ghost' The arrows' button surface: filled, outline, soft, ghost or link
arrowSize string 'xs' The arrows' button size: any size a button takes
scope string|null null Scoped personalization name

<x-wirekit::table.reorder-head>

Prop Type Default Description
scope string|null null Scoped personalization name

<x-wirekit::table.caption>

Prop Type Default Description
scope string|null null Scoped personalization name

<x-wirekit::table.head>

Prop Type Default Description
variant string 'filled' 'filled' paints the subtle header fill; 'flush' drops it
scope string|null null Scoped personalization name

flush removes the fill and nothing else — the bottom divider and the sticky-header rules stay, because those are what keep a head readable while the body scrolls under it. Reach for it when the head sits on a surface that already provides its own contrast; leave it alone otherwise.

<x-wirekit::table.body>, .foot

Only scope is available. All styling is driven by the parent table's data attributes.

<x-wirekit::table.row>

Prop Type Default Description
details slot — a second row under this one, spanning the columns left after a hide-below* took some away. Filling it is what makes the row exist
details-below-container string null sm | md | lg | xl | 2xl | 3xl | 4xl | 5xl — show the details row while the table is narrower than this
details-below string null sm | md | lg | xl | 2xl — the same against the window. May be combined with the line above
details-colspan int null how many columns the details row spans
details-for string null the id of the record's row header, written to the details cell's headers
scope string null theming scope

See Keeping what the hidden column held for what the component handles on your behalf — the separator, the last row, and the stripe.

Accessibility

  • <th> elements set scope="col" automatically — screen readers announce column headers as context for each cell

  • Sortable columns expose aria-sort="ascending" / "descending" / "none" — see WAI-ARIA Authoring Practices: Sortable Table

  • A sortable header renders its label inside a <button> that fills the cell, so the sort is operable with Tab + Enter / Space and not only with a pointer (WCAG 2.1.1). That applies to both sort modes — alpine-sort with column, and Livewire sorting with sort-action. aria-sort stays on the <th>, where the column semantics are

  • The sort direction icon is aria-hidden="true" (decorative — the aria-sort value is the semantic signal)

  • Hover tints rely on color, so avoid conveying row state through hover alone — use explicit status indicators (badges, icons with labels)

  • The responsive scroll wrapper carries tabindex="0" and a focus ring in every configuration, so a keyboard user can pan to off-screen columns (WCAG 2.1.1)

  • The wrapper becomes a screen-reader landmark only when you name it with tableLabel. It then gets role="region" and that name and appears in the landmark list. Without one it stays a plain focusable scroller — because role="region" plus a name is a landmark, and every table on a page falling back to one built-in name would be a set of landmarks announcing the same thing, which axe reports as landmark-unique. Name it after the data:

    <x-wirekit::table table-label="Customer list">
        {{-- … --}}
    </x-wirekit::table>
    

Keyboard Interaction

Key Action
Tab Move focus through column-header sort buttons (when sortable) and row action controls
Enter / Space (on a sort button) Toggle the sort direction for that column

Cell content is delegated to the browser's native table semantics; assistive tech announces row / column relationships from the <th> / <td> markup.

Pitfalls

  • Don't put a <x-wirekit::dropdown> inside a <table> cell without testing. Portal-rendered overlays escape the cell; ensure z-index and click-outside-to-close work in your specific layout.
  • Don't use HTML <table> for layout. WCAG 1.3.1 — <x-wirekit::table> adds row/column ARIA semantics; misuse for layout is a screen-reader trap.
  • Give a table a width when its parent sizes to its content. Its frame is a size container, so that a column with hide-below-container can ask how wide the table is, and a size container takes no width from what it holds. In a parent that shrinks to fit its content, a flex column with align-items: center or an inline-block for instance, there is no space to measure, and the table collapses to nothing with no error. Set width: 100% on the table or on the card around it, and constrain the wrapper if it should be narrower.

Design Tokens

Element Token
Table background inherits from parent
Head background --color-wk-bg-subtle
Foot background --color-wk-bg-subtle
Row divider --color-wk-border-subtle
Head divider --color-wk-border
Striped row tint --color-wk-bg-subtle
Hover row tint --color-wk-bg-muted
Cell padding --padding-wk-x-md / --padding-wk-y-md
Compact cell padding --padding-wk-y-sm
Text color --color-wk-text
Header text color --color-wk-text-muted
Frozen column cap (sticky-column) --size-wk-table-sticky-column-max
Scrolling column cap (sticky-column) --size-wk-table-sticky-cell-max

Customization

Override defaults without publishing views via config/wirekit.php:

'components' => [
    'table' => [
        'striped' => true,
        'hoverable' => true,
        'compact' => false,
        'responsive' => true,
    ],
],

Further Reading

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store