Integration Guide
The complete setup reference: required dependencies, browser baseline, asset publishing, optional features, and a verification checklist. The Getting Started recipe gets you to a working install in the common case — come here when you need to understand the moving parts in detail or debug a specific step.
Browser Support
WireKit's supported-browser baseline is pinned to Tailwind CSS v4's official requirements — whenever Tailwind raises its baseline, WireKit follows in the same release. There is no separate "WireKit browser matrix" to track.
| Browser | Minimum version | Released |
|---|---|---|
| Chrome | 111 | March 2023 |
| Edge | 111 | March 2023 (Chromium-based) |
| Safari | 16.4 | March 2023 |
| Firefox | 128 | July 2024 |
What this means for your app:
- If your application already runs on Tailwind CSS v4 (which is a required dependency), you are automatically within WireKit's supported range. No separate compatibility check is needed.
- WireKit ships no polyfills, no vendor-prefix fallbacks, and no shims for browsers that Tailwind has dropped. Components may visually degrade or break in older browsers.
- WireKit freely uses modern CSS features that are covered by the Tailwind baseline — including
@property,color-mix(),contain: inline-size, andmin()/max(). You can rely on these in your own code inside WireKit components. - Not every modern feature is covered.
@starting-style,field-sizing: content,text-wrap: balance, native CSS nesting andround()each first shipped above one of the minimum versions in the table — they are progressive enhancement, not floor. Where WireKit uses one, it sits behind an@supportstest and adds polish that nothing depends on, so a browser at the minimum version renders the component without it. Detect them the same way in your own code instead of assuming them. - The same holds for
hidden="until-found", which lets the browser's find in page open a closed accordion, collapsible or findable tab panel. WireKit checks for it at runtime before using it; a browser without it hides the closed content exactly as before, and its search simply does not look inside. - If your audience still includes users on older browsers (e.g. corporate intranets pinned to Chrome 108), WireKit is not the right fit. Consider staying on Tailwind v3 + a compatible UI kit.
The only officially supported configuration is Tailwind v4 + WireKit on a browser from the table above. Any other combination is unsupported and untested.
Required Setup
1. Composer
# 1. Pull WireKit + Livewire v4 (a hard dependency — Alpine.js is bundled
# with Livewire, so no separate npm install for Alpine is needed). The
# two icon packages ensure <x-wirekit::icon> renders the heroicons set
# used internally by buttons / dropdowns / overlays.
composer require pushery/wirekit blade-ui-kit/blade-icons blade-ui-kit/blade-heroicons
2. Blade Directives
Add both directives to your app layout, @wirekitScripts before @livewire:
<head>
@wirekitStyles {{-- Design tokens, CSS variables, keyframe animations --}}
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
{{ $slot }}
@wirekitScripts {{-- WireKit Alpine component registrations (BEFORE @livewireScripts) --}}
@livewireScripts {{-- Boots Alpine + Livewire's runtime --}}
</body>
The invariant is about EXECUTION, not tag order. WireKit's Alpine factories have to be registered before Alpine.start() runs, and @wirekitScripts emits its tag with defer — a deferred script runs after parsing and before DOMContent, wherever its tag sits. In a Livewire application the requirement is therefore met either way, and swapping the two directives changes nothing measurable.
The order above is still the one to write: it says what depends on what, and it is the arrangement every other page here assumes.
Where it genuinely breaks is anything that runs the bundle LATE — an async attribute instead of defer, a file injected at runtime, a bundler that drops the defer, or a non-Livewire setup that starts Alpine earlier. If every wirekit* factory is missing at once, look there before you look at the order of two lines.
@wirekitStyles loads wirekit.min.css — contains:
- ~70 CSS custom properties (colors, spacing, radius, shadows, fonts)
- Dark mode token switching via
.darkclass on<html> - Keyframe animations (
wk-skeleton,wk-progress-indeterminate) prefers-reduced-motionguard for all animations[x-cloak] { display: none !important }— see below
x-cloak is covered — the rule ships with WireKitAlpine hides nothing for x-cloak; it removes the attribute once it has initialized, and the hiding has to come from CSS. Many WireKit components rely on it to stay invisible until Alpine boots, so wirekit.css ships the rule and @wirekitStyles is what delivers it.
Worth knowing if you go looking for it: @wirekitStyles emits its own <link> to wirekit.min.css. The rule therefore lives in that file — not in your Vite bundle, and grepping public/ for it finds nothing whether everything is wired up correctly or not. Check the served wirekit.min.css instead, or the published copy at public/.
wirekit.min.css is what your visitors download; wirekit.css is the same rules with every comment intact, published beside it so you can read what a token means. When you are debugging a value, read wirekit.css. When you are asking why something is or is not on the page, read wirekit.min.css — that is the one the browser got.
You only need to write the rule yourself if you deliberately load neither — no @wirekitStyles and no import of wirekit.css. In that case you have no design tokens either, and cloaked elements flashing on load is the smaller of the two symptoms.
@wirekitScripts loads wirekit.js — contains:
- All Alpine.js component registrations (
wirekitModal,wirekitDropdown, etc.) - Floating UI positioning (tooltips, popovers, dropdowns)
- Focus trap utility (modals, drawers, alert dialogs)
- Overlay scroll lock (reference-counted)
The overlay container
Every teleported panel — modal, drawer, dropdown, tooltip — lands inside one labeled region, <div id="wk-overlay-root" role="region" aria-label="…">. Without it those panels sit outside every landmark, which an accessibility audit flags and a screen-reader user experiences as content that belongs nowhere.
You do not have to do anything. @wirekitScripts creates the container, names it in the page's language, and re-creates it after a wire:navigate.
Writing it yourself is supported, and it is the sturdier of the two routes. Put it in your layout and it sits in the markup Livewire morphs, so it is never briefly absent and no navigation timing has to be right:
{{-- 1. Optional. WireKit adopts this container instead of creating its own, and
fills in the role and the localized name if you left them out. --}}
<div id="wk-overlay-root"></div>
{{-- 2. The scripts still come after it. --}}
@wirekitScripts
A role or an aria-label you write yourself is left alone — including aria-labelledby, so a container pointed at a heading keeps its name. Only what is missing gets filled in, because a label you wrote is one you translated.
To supply the name yourself — for a locale WireKit does not ship, or wording your product prefers — put it on any <script> tag in the document:
{{-- 3. Read once when the container is built. Any script tag works; this one
exists only to carry the attribute. --}}
<script data-wk-overlay-label="@lang('Overlays')"></script>
The routes that serve WireKit's own files
@wirekitStyles and @wirekitScripts fall back to package routes when the assets are not published. Two more families of routes work the same way: /wirekit/ serves a bundled font family that was never published, and /wirekit/ serves the artwork of <x-wirekit::. All of these routes run no middleware, and that is deliberate rather than an oversight.
They serve a static file from the package directory: no session is read, no user is looked at, and the reply declares public, max-age=31536000, immutable. Under Laravel's web group every one of those replies also carried a session cookie — and a shared cache invited to keep a response for a year may hand it to the next visitor with the cookie still attached. Removing the group is what makes the year-long directive honest and the files genuinely cacheable by a CDN.
If you need middleware on them, name it in wirekit.:
// 1. config/wirekit.php — an empty array (the default) means no middleware.
'assets' => [
'middleware' => ['throttle:asset-serving'],
],
A bare string works too and is cast for you, so 'middleware' => 'web' does not silently produce a route with none.
Before you add web back: it is the one value that reintroduces the cookie. If what you actually want is a rate limit, a geo-block or a log line, name that middleware instead — the session is almost never the thing you were after.
Publishing the assets (vendor:publish --tag=wirekit-assets) makes the question moot for production: the files are then served by your web server and never reach Laravel at all.
3. Tailwind CSS v4 Source Scanning
CRITICAL: WireKit components use Tailwind utility classes internally. Your app's Tailwind build MUST scan WireKit's Blade templates, or the component styles will be missing.
In your resources/:
/* 1. Pull in Tailwind v4 — provides `@source`, `@theme`, and the
utility-class compiler. */
@import "tailwindcss";
/* 2. Register WireKit's published Blade templates as a Tailwind source
directory. Without this line, the utility classes WireKit components
emit at runtime never reach the compiled CSS — buttons render
without backgrounds, inputs without padding. */
@source "../../vendor/pushery/wirekit/resources/views/**/*.blade.php";
Without this line, components render as unstyled HTML — tables have no borders, inputs have no padding, buttons have no backgrounds.
Tip: Both setup paths work in v1.3.0+:
@wirekitStylesBlade directive (the path documented above) — emits a<link>tag straight to the package's compiled CSS. Fastest path, no Tailwind compile step required.@import '../../vendor/inpushery/ wirekit/ dist/ wirekit. css' resources/— Tailwind v4 picks up the variables on its next compile. Useful when you want a single bundled CSS file from Vite.css/ app. css Either path resolves; pick whichever fits your build pipeline. Pre-v1.3.0 versions used Tailwind's
@theme {}compiler directive, which browsers skipped as an unknown at-rule when loaded via<link>— that's now fixed (the file ships with:root {}/.dark {}blocks directly).
Scanning only the components you use
The line above scans every template of the package, so your build carries the utilities of the whole catalog whatever your pages render. If your application uses a handful of components, point Tailwind at one generated source per component instead:
/* 1. Pull in Tailwind v4. */
@import "tailwindcss";
/* 2. One line per WireKit component your views render, named as its
tag names it: card.txt for <x-wirekit::card>. Each source covers the
component's parts and everything it renders, so the modal brings its
close button along. */
@source "../../vendor/pushery/wirekit/resources/tailwind/card.txt";
@source "../../vendor/pushery/wirekit/resources/tailwind/modal.txt";
@source "../../vendor/pushery/wirekit/resources/tailwind/table.txt";
Use it instead of the templates line, not beside it: with both, the templates line scans everything anyway.
The sources are regenerated from the templates for every release, so a component that starts rendering another brings that one's utilities along, and your list stays right across an upgrade. What the list cannot know is a component your views start using later. php artisan wirekit:doctor reads your views, compares what they render with the sources you declared, and names every missing line; it also flags a source path that resolves to no file, which Tailwind would skip without a word. With this form, run the doctor in CI.
4. Alpine.js — comes via Livewire v4
WireKit declares livewire/ as a hard composer dependency. Livewire v4 bundles Alpine.js and starts it for you when @livewire runs — so a separate Alpine install is not needed and not recommended:
- ❌ Do NOT
npm install alpinejs - ❌ Do NOT
import Alpine from 'alpinejs'in yourapp.js - ❌ Do NOT call
Alpine.start()manually - ✅ Just emit
@wirekitScriptsTHEN@livewirein your layout (per Step 2). Livewire's bundle starts Alpine; WireKit's components register onScripts alpine:initbefore that happens because the directives execute in document order.
The full reasoning + a complete copy-pasteable layout file lives on the Getting Started page.
An x-ref on a WireKit component
An x-ref you write on a WireKit component registers in your own Alpine component, the one around the tag, even where the WireKit component runs an Alpine component of its own. It points at the element your other attributes land on: the field of an input, a number input or a combobox, the outer element of a control made of several parts, such as a tags input or a rating, and the <table> element of a table. It registers while Alpine starts the page, the way an x-ref on your own markup does, so your component's init() finds it too.
{{-- 1. The ref registers on this x-data, not inside the number input. --}}
{{-- 2. So the button reaches the field through $refs. --}}
<div x-data>
<x-wirekit::number-input name="quantity" label="Quantity" x-ref="quantity" />
<x-wirekit::button x-on:click="$refs.quantity.focus()">Change the quantity</x-wirekit::button>
</div>
In the rendered markup the name appears as x-wk-ref on that element, and the component's outer element carries data-wk-ref-scope. A plain x-ref there would register inside the WireKit component, which is why the component renames it. A test or a script that finds the element by its ref selects [x-wk-ref="quantity"].
A wire:key on a WireKit component
A wire:key you write on a WireKit component keys its outermost element, the one Livewire compares when the rows of a loop change, even where your other attributes land on a field inside. Write it on the tag, one per row, as you would on your own markup. Without it, removing a row from the middle rebuilds every field below it, and a field someone is typing in loses its focus.
{{-- 1. One key per row, written on the tag: it lands on the component's outermost element. --}}
{{-- 2. The binding and the other attributes still reach the field inside. --}}
@foreach ($guests as $index => $guest)
<x-wirekit::input wire:key="guest-{{ $guest['id'] }}" wire:model="guests.{{ $index }}.name" label="Name" />
@endforeach
An x-model on a WireKit component
An x-model you write on a WireKit component binds the component's value where it has one to hand over, such as the field of an input, a select, a textarea, a checkbox, a radio or a date picker with one date, and the controls that hand their value to Alpine themselves, such as a combobox, a multi-select, a slider, a tags input, an editor or a toggle button.
A component that does not hand its value to Alpine drops the x-model, and while APP_DEBUG is on, or in the console, WireKit logs a warning that names the component. That is the case where the component's own Alpine component takes your attributes, such as a calendar, a rating, a segmented control, tabs or a date picker with a range. It is also the case where your attributes land beside the value rather than on it: around the hidden fields of an OTP input or a range slider, and on the number box of a phone field, which holds the digits typed rather than the number the field sends. Alpine would otherwise give the binding the value of whichever field inside the component was edited last: the month of a calendar, one box of a code, or one end of a range. Where such a component holds a value, bind it with wire:model.
{{-- 1. A select hands its value to your x-model. --}}
{{-- 2. A range holds two dates, so it binds with wire:model: stay.start and stay.end. --}}
<div x-data="{ size: 'm' }">
<x-wirekit::select x-model="size" label="Size" :options="['s' => 'Small', 'm' => 'Medium']" />
</div>
<x-wirekit::date-picker range wire:model="stay" label="Stay" />
An x-show or wire:show on a WireKit component
An x-show or wire:show you write on a WireKit component shows and hides the whole component: the label, the description and the error of a field go with it, not only the field your other attributes land on. An x-transition or wire:transition on the tag goes to that same outermost element, and so does an x-cloak or wire:cloak written next to a show directive, which keeps the whole component hidden until Alpine has read your expression.
{{-- 1. The whole field hides, its label and its error included. --}}
{{-- 2. x-cloak keeps it hidden until Alpine has read the expression. --}}
{{-- 3. x-transition fades and scales the whole field in and out. --}}
<div x-data="{ company: false }">
<x-wirekit::checkbox x-model="company" label="I am ordering for a company" />
<x-wirekit::input name="vat_id" label="VAT ID" x-show="company" x-cloak x-transition />
</div>
A cloak written without a show directive stays with your other attributes. Alpine removes a cloak only from an element inside an Alpine component, and the outer element of a field is one only where your page puts it inside one.
A listener on a WireKit component
A listener you write on a WireKit component, such as x-on:click or @keydown.escape, runs alongside the component's own listener for the same event. An attribute can stand in a tag only once, so where the component already listens to that event on the element your attributes land on, your listener renders in the other spelling: @click instead of x-on:click, or the other way around. Alpine reads both spellings as the same listener.
{{-- 1. The button still copies, and your listener runs as well. --}}
{{-- 2. The count is called copies: copied is a name the button uses itself. --}}
<div x-data="{ copies: 0 }">
<x-wirekit::clipboard-button value="composer require pushery/wirekit" x-on:click="copies++" />
</div>
Which names an expression on a WireKit component reads
A listener, a show or a binding you write on a WireKit component runs where your attributes land, and on a component with its own Alpine data that is inside the component. Alpine looks a name up there first, so a name the component also uses reads and changes the component's value, not yours. That is what lets an expression on the tag call a method of the component, such as flush() on an assistant message, and it is why your own state needs a name the component does not use. The clipboard button above keeps a copied flag of its own, so the example counts in copies.
A role or a name on a WireKit component
A role you write on a WireKit component replaces the role the component gives the element your attributes land on, and an aria-label or aria-labelledby you write replaces the name the component would give it, in either form. The element then carries one role and one name, yours. Where a form control has a visible label, that label stays the field's name, so the name a reader hears matches the text they see.
The component's keyboard model stays as it is, so a role that promises a different one, menu on a toolbar for example, promises something the component does not do.
A few attributes stay with the component, because it sets them from its own state: the type of its own fields and buttons, the tabindex that makes a scroll area reachable by keyboard, the aria-expanded and aria-controls of a combobox, and the aria-hidden of a purely decorative element. A value you write for one of these is not rendered. The politeness of a live region (aria-live) and the purpose of a field (autocomplete) are yours to set, and your value replaces the component's.
{{-- 1. Two breadcrumbs on one page, each with a name of its own. --}}
<x-wirekit::breadcrumb aria-label="Folder" :items="$folderTrail" />
<x-wirekit::breadcrumb aria-label="Category" :items="$categoryTrail" />
A list on a WireKit component
A prop that takes a list, such as the items of a breadcrumb, the steps of a wizard or the value of a tags input, takes an array or a Collection, so the result of a query or a pluck() goes in as it is. A lazy Collection and a paginator work as well; a paginator hands over the items of its page.
The keys of a list do not matter. A Collection that has been through filter(), where() or sortBy() keeps the keys it had, and a component reads its items in the order the Collection has them: the steps of a stepper are numbered by their place, and the labels and the series of a chart are drawn in that order. Where a prop takes a map, such as the options of a select, the keys are the values it submits, and they stay as the Collection has them.
A map and a record go in the same way. pluck('label', 'value') returns a Collection, and the options of a segmented control, the intervals of a pricing table and the marks of a slider take it as it is. A record, such as the author of a message or the avatar of a profile, may be a model: the component reads the keys it documents and writes nothing else of the model into the page.
The items of a list may be models as well, so the result of a query goes into the steps of a stepper or the items of a breadcrumb as it is. A component reads a model through its toArray(): it sees what the model's JSON would carry, an accessor only when it is in $appends, and nothing the model hides.
Some components hand their list to a script and write it into the page for that: the rows of a data table, the events and day markers of an event calendar, the markers of a map and the notifications of a notification center. Each record goes into the page with the keys the component reads and no others, so the columns of a query that nothing on the page shows stay on the server. Two lists go in whole, because something you write reads them: the rows of a data table that has a rowActions slot, where the slot has the row in scope, and the datasets of a chart, which the chart library reads as you wrote them. The columns of a data table and the fields of a filter builder are configuration and go in as written too. For those, pass the fields the page may show, as the data table page shows.
{{-- 1. The skills a user has, as the selected values of a multi-select. --}}
<x-wirekit::multi-select name="skills" label="Skills" :options="$skills->pluck('name', 'id')" :value="$user->skills->pluck('id')" />
A value echoed into a WireKit tag
A value you echo into a WireKit tag reads the same as one you bind: label="{{ $name }}" and :label="$name" both show Tom's orders & returns, although Blade escapes an echoed value before the component receives it. WireKit reads the references that escape writes, &, <, >, " and ', as the characters they stand for. That holds for every prop, for a value a component of yours hands on through its attribute bag, and for a value written as HTML in the tag, such as label="Terms & conditions". A bound text that holds one of those references as literal text shows the character as well.
The value of an editor is HTML. Bound, it is used exactly as you pass it; echoed, Blade's escape is undone once.
{{-- 1. Echoed or bound, the label reads "Tom's orders & returns". --}}
<x-wirekit::input name="orders" label="{{ $label }}" />
<x-wirekit::input name="orders" :label="$label" />
Dark-mode dark: utilities in your own markup
WireKit's components switch their colors automatically via the .dark class — the --color-wk-* tokens flip in the .dark {} block of wirekit.css, so you never write dark: utilities on a WireKit component.
But if your own markup uses Tailwind dark: utilities (e.g. <div class="dark:bg-slate-800">), add one line to resources/ so they follow the .dark class your theme toggle sets on <html> — not the OS prefers-color-scheme:
/* 1. Pull in Tailwind v4. */
@import "tailwindcss";
/* 2. Make Tailwind's `dark:` variant follow the nearest `.dark` or `.light`
class above an element (the class WireKit + your toggle set on <html>,
or a panel that switches on its own), not the OS-level
prefers-color-scheme. Without this, your own `dark:` utilities react
only to the OS setting, so a manual light/dark switch appears to do
nothing for them. The long selector is what "nearest" takes in CSS;
it resolves up to four nested mode classes. */
@custom-variant dark (&:where(:where(.dark, .dark *):not(.dark .light, .dark .light *), :where(.dark .light .dark, .dark .light .dark *):not(.dark .light .dark .light, .dark .light .dark .light *)));
WireKit ships its own @custom-variant dark inside wirekit.css, but that file arrives as a pre-compiled <link> (via @wirekitStyles) and never reaches your Tailwind build — so your build still defaults dark: to prefers-color-scheme. This only affects dark: utilities you author yourself; WireKit components are unaffected (they don't use dark:).
Styles not applying?
If a component renders correct HTML but looks unstyled, the cause is your app's asset loading, not WireKit. Check, in order:
@sourceline present (Step 3) — Tailwind must scan WireKit's Blade templates, or the utility classes the components use are never generated. Verify withgrep @source resources/. With per-component sources, every component your views render needs its line, or has to be rendered inside one that has.css/ app. css @wirekitStylesin your layout (Step 2) — it emits the<link>towirekit.css, which defines the--*-wk-*tokens. Without it, components render against undefined tokens.- Fresh build — Tailwind only generates classes it has scanned. After adding the
@sourceline (or any new arbitrary-value token class), restartnpm run devor runnpm run build; a dev server started before the change serves stale cached CSS. - Stale published assets — if you ran
php artisan vendor:publish --tag=wirekit-assetsonce,@wirekitStyleskeeps serving that published copy as long as its file timestamp is newer than the packagedist/. Re-publish after a WireKit upgrade, or deletepublic/to fall back to the always-current package copy.vendor/ wirekit/
php artisan wirekit:doctor checks the first three automatically.
Dark Mode (Optional)
WireKit's tokens switch on a .dark class on <html>. If your app offers dark
mode, add one directive and one component rather than writing the plumbing:
<head>
{{-- 1. BEFORE your stylesheet: applies the reader's stored theme before the
first paint. Without it the page paints light and then turns dark in
front of them. It is inline and synchronous for exactly that reason —
moving it into a bundle brings the flash straight back. --}}
@wirekitThemeScript
{{-- 2. Your WireKit styles, as usual. --}}
@wirekitStyles
</head>
<body>
{{-- 3. The control, wherever it belongs in your chrome. --}}
<x-wirekit::theme-controller />
</body>
Running a Content Security Policy? Pass your nonce: @wirekit. Under a policy that allows no inline script at all, load it as a file.
Full options, variants and the storage key: theme controller.
Optional Dependencies
WireKit's core has no front-end dependencies, but four components light up when you add a peer dependency. Install each only if you use the component it powers — every one degrades gracefully when its dependency is absent (the chart and map show an in-place advisory, the editor falls back to a plain textarea, the QR code renders a labeled placeholder of the same size).
| Component | Peer dependency | Install |
|---|---|---|
<x-wirekit::chart> |
Chart.js or ApexCharts | npm install chart.js |
<x-wirekit::qr-code> |
bacon/bacon-qr-code | composer require bacon/bacon-qr-code |
<x-wirekit::editor> |
Tiptap | npm install @tiptap/core @tiptap/starter-kit |
<x-wirekit::map> |
MapLibre GL or Leaflet | npm install maplibre-gl |
php artisan wirekit:doctor lists these too — Chart.js by config + bacon-qr-code by package detection, and the editor/map engines as a reminder (they're browser globals a PHP command can't probe).
Chart.js (for <x-wirekit::chart> )
npm install chart.js
// resources/js/app.js
//
// 1. Pull Chart.js + the registerables bundle (the controllers, scales,
// plugins, and elements that ship in the chart.js package).
import { Chart, registerables } from 'chart.js';
// 2. Register everything globally so any chart type (bar, line, doughnut,
// radar, …) works without per-call imports.
Chart.register(...registerables);
// 3. Expose Chart on window so WireKit's `wirekitChart` Alpine factory
// can find the constructor at mount-time. The factory looks at
// `window.Chart`; if it's not there, charts render the "Chart.js is
// not loaded" placeholder.
window.Chart = Chart;
Also set in config/:
'charts' => [
'library' => 'chartjs',
],
Chart.js dark mode works automatically — WireKit's Alpine component uses a Mutation to re-read CSS variables when the .dark class toggles.
QR Code (for <x-wirekit::qr-code> )
composer require bacon/bacon-qr-code
No configuration needed — the component auto-detects the package.
Rich-Text Editor (for <x-wirekit::editor> )
npm install @tiptap/core @tiptap/starter-kit
<x-wirekit:: is a thin Alpine adapter around a ProseMirror editor (Tiptap recommended). Expose a window. factory (the legacy window. name still works as a deprecated alias) and the editor mounts the rich-text surface; without it, the editor degrades to a plain <textarea> (still wire:model-bound and submittable) and logs a one-time console hint. The factory contract, toolbar presets, and wire:model wiring live on the dedicated Editor component page.
Map (for <x-wirekit::map> )
npm install maplibre-gl # or: npm install leaflet
<x-wirekit:: renders through whichever engine you hand it — MapLibre GL or Leaflet, either on window (window., window.L) or through register. MapLibre GL 6 is an ES module and sets no global of its own, and only 6.4.1 and newer carry the fix for a critical advisory, so follow the MapLibre GL guide for the setup. Without an engine it degrades to an accessible marker list. Load the engine only on routes that show a map — MapLibre GL is large, so a global import inflates every page's bundle (the Map component page shows the lazy-load pattern).
Stackable Heroicons Extensions
WireKit ships one stackable extension preset with aliases in it — heroicons-marketing, for landing-page words like sparkle and cursor-arrow-rays — which layers on top of a base preset without an extra package install. It resolves to heroicons glyphs, so stack it on a heroicons install rather than on lucide, phosphor or tabler.
A second extension, heroicons-app, is registered but empty since v2.37.0: every word it carried (arrow-up, arrow-down, lock, unlock, link, key, x-circle) moved into all four base presets, so those names now work whichever icon package you installed. The name stays valid so an existing published config keeps booting; stacking it simply adds nothing.
Activation is one config change. The full setup walk-through, alias menu, resolution-order rules, and stacking patterns live on the dedicated Icon component page.
Asset Publishing
Publishing assets is optional but recommended for production — it lets your web server (nginx, Apache) serve the CSS and JS files directly instead of routing every request through PHP.
php artisan vendor:publish --tag=wirekit-assets
This copies wirekit.css, wirekit.js, wirekit.core.js, wirekit-apex.js (the optional ApexCharts adapter glue — non-MIT, see apexcharts.), wirekit-tiptap. (the optional rich-text editor adapter glue — see "Choosing a JS bundle" below), wirekit-optimistic. (the optional Optimistic UI factory — see "Choosing a JS bundle" below), wirekit-theme. (the theme script, for when it loads as a file — see theme controller), wirekit-alpine. (the optional self-contained bundle — see "Choosing a JS bundle" below), and wirekit-alpine. (its Content-Security-Policy twin) from the package into public/. The @wirekitStyles and @wirekitScripts directives detect published assets automatically and prefer them over the route-based fallback.
Choosing a JS bundle
WireKit ships several JS bundles, each with a specific developer story. Pick exactly one primary bundle (the first three — they're mutually exclusive); the adapter bundles below load ALONGSIDE your chosen primary when you opt into the feature they cover.
| Bundle | Size (raw) | When to load |
|---|---|---|
wirekit.js |
~425 KB | Your app already runs Alpine.js and registers WireKit's components yourself. The default for Laravel + Livewire setups. |
wirekit.core.js |
~32 KB | You only need charts, the before/after image compare, and the form primitives — no overlays (no modal, drawer, dropdown, tooltip, popover, command palette). Smallest footprint. |
wirekit-alpine.js |
~479 KB | You want a self-contained drop-in: bundles Alpine.js core + every WireKit component, registers all of them, calls Alpine.start() automatically. Ideal for isolated preview iframes, sample landing pages, or any context where you don't want a separate Alpine pipeline. |
wirekit-apex.js |
~36 KB | Optional ApexCharts adapter glue. Loaded ALONGSIDE one of the above when you've set wirekit.charts.library => 'apexcharts'. |
wirekit-optimistic.js |
~9 KB | Optional Optimistic UI factory (wirekitOptimistic). Loaded ALONGSIDE whichever bundle you picked, when you use a component's optimistic prop. Deliberately in no other bundle: loading this file is how you opt into its announcement behavior, so apps that don't use it pay nothing. |
wirekit-alpine.csp.js |
~495 KB | The same self-contained drop-in, built against Alpine's Content-Security-Policy distribution — for a policy without script-src 'unsafe-eval'. Set wirekit.scripts.bundle to csp and do NOT also load your own Alpine; this bundle brings one. See Content Security Policy below. |
wirekit-tiptap.js |
~18 KB | Optional rich-text editor adapter glue (wirekitEditor factory). Loaded ALONGSIDE wirekit.core.js when you want <x-wirekit::editor> without the full overlay bundle. Redundant with wirekit.js / wirekit-alpine.js — those already include the editor. |
wirekit-alpine. and wirekit.js are mutually compatible, but you should pick exactly one for clarity. If wirekit-alpine. does land on a page that already has an Alpine, it registers WireKit's components on that Alpine rather than starting a second one, and logs a console warning saying so — the components still work. What you lose is the reason to pick that bundle: if you chose it for its Content-Security-Policy build, the Alpine now evaluating your expressions is the page's own, not the one it bundles.
Tip: Add
public/to yourvendor/ wirekit/ .gitignore— these files are rebuilt on deploy viavendor:publishand should not be committed to version control.
Cache Busting
Every URL the directives generate ends in ?v={filemtime} so browsers automatically pick up new content on every deploy. You never need to hard-reload or clear the browser cache manually after a WireKit update.
Example output:
<link rel="stylesheet" href="/vendor/wirekit/wirekit.css?v=1776089069">
<script src="/vendor/wirekit/wirekit.js?v=1776089069" defer></script>
Because every URL is fingerprinted this way, the package's built-in asset route (/wirekit/) responds with Cache-Control: public, max-age=31536000, immutable — standard fingerprinted-asset caching. Content changes always produce a new URL, so a year-long browser cache is safe.
The asset routes run no middleware
The routes serving WireKit's own CSS, JS and fonts are registered with no middleware group. Their handlers read a file from the package directory — they use no session, no CSRF token, no authentication and no route-model binding.
This matters for the header above. Inside a group like web, StartSession runs for every stylesheet hit and the session cookie leaves on the response — so a reply that says public, max-age=31536000, immutable would also say Set-Cookie. A shared cache is invited to keep that reply for a year and may hand it to the next visitor with the cookie attached. Most shared caches refuse a response carrying Set-Cookie, but that is a per-vendor default you do not control rather than a property of the response.
The practical effect is the one the year-long directive was written for: without the cookie, a CDN can actually cache these files. With it, most CDNs decline them — the header would promise what the cookie prevents.
Nothing is required of you here — this is the default. An application that serves the assets from public/ after vendor:publish bypasses these routes entirely, so the change is invisible to it.
If your application genuinely needs middleware on them — a security-header layer or HTTPS enforcement you apply everywhere is the realistic case — name it explicitly:
// config/wirekit.php
// 1. Middleware for the package's own asset routes. Empty by default.
// 2. Both a string and a list are accepted.
// 3. Anything that starts a session brings the cookie back with it, so the
// responses stop being safely cacheable by a shared cache.
'assets' => [
'middleware' => ['secure-headers'],
],
Automatic Staleness Detection
Laravel's vendor:publish is non-overwriting by default — running it a second time without --force silently skips existing files. This means that after a composer update pushery/wirekit, your public/ would normally stay frozen at whatever version you first published.
WireKit handles this automatically. The Blade directives compare the published copy's filemtime() against the package's dist/ and transparently fall back to route-based serving whenever the published copy is older than the package source. Your users always see the latest version, even if you forget to re-publish.
| State | Directive behavior |
|---|---|
| No published copy exists | Routes through /wirekit/wirekit.css (served by PHP from vendor/pushery/wirekit/dist/) |
| Published copy ≥ package source | Fast path: <link href="/vendor/wirekit/wirekit.css?v=…"> (served by web server) |
| Published copy < package source (stale) | Auto-fallback to the route so users see fresh content |
After a composer update pushery/wirekit, you can optionally re-publish to restore the fast path:
php artisan vendor:publish --tag=wirekit-assets --force
This is a performance optimization, not a correctness requirement — if you forget, WireKit still serves the latest version via the route fallback. The --force flag is required because vendor:publish does not overwrite existing files without it.
Deploy Checklist — wire vendor:publish --force into your deploy hook
WireKit's default wirekit:install adds /public/ to your .gitignore (this is the Filament-style pattern — published assets are build artifacts, not source). That means after every deploy the directory is empty until your deploy pipeline runs vendor:publish again. Add php artisan vendor:publish --tag=wirekit-assets --force to your post-deploy hook. Without it, the route-based fallback still serves the assets correctly — but every request goes through PHP instead of being served directly by your web server, so the page-load cost goes up.
A WireKit deploy needs two asset steps: npm run build (compiles your app CSS —
including the WireKit utility classes its @source line pulls in) and
vendor:publish (copies WireKit's own CSS/JS bundles). The exact wiring depends on
your hosting provider; the most common shapes:
# Laravel Forge — Site → Deploy Script
cd $FORGE_SITE_PATH
git pull origin $FORGE_SITE_BRANCH
$FORGE_COMPOSER install --no-interaction --prefer-dist --optimize-autoloader
# 1. Build the frontend. wirekit:install added a Tailwind `@source` for WireKit's
# templates, so this is what compiles WireKit's utility classes into your app
# CSS — skip it and the deploy renders unstyled.
npm ci && npm run build
# 2. Publish WireKit's own asset bundles to public/vendor/wirekit/.
# --force is required because vendor:publish is non-overwriting by default.
$FORGE_PHP artisan vendor:publish --tag=wirekit-assets --force
$FORGE_PHP artisan migrate --force
# Envoyer / Deployer / generic post-deploy script
# 1. Compile app CSS (incl. WireKit's @source utility classes).
npm ci && npm run build
# 2. Publish WireKit's own bundles (--force overwrites stale copies).
php artisan vendor:publish --tag=wirekit-assets --force
# GitHub Actions deploy step
- name: Build assets + publish WireKit
run: |
npm ci && npm run build # 1. app CSS incl. WireKit @source classes
php artisan vendor:publish --tag=wirekit-assets --force # 2. WireKit's own bundles
If you committed your published assets (you used wirekit:install --no-gitignore at install time), you don't need this step — your git pull already restored the files. The deploy hook is only required when public/ is gitignored, which is the default.
php artisan wirekit:doctor ships a check that fails when public/ is empty AND wirekit-assets is published-by-config. The failure message includes the exact vendor:publish --force command to run. Wire the doctor into a post-deploy smoke test (e.g. php artisan wirekit:doctor || exit 1 after the publish step) to catch a missed deploy hook on the next deploy instead of waiting for a user to report unstyled output.
Verification Checklist
WireKit ships an artisan command that checks all integration requirements:
php artisan wirekit:doctor
The aliased php artisan wirekit:verify resolves to the same command — pick whichever reads better in your scripts.
The output is one line per check, a totals line, and All checks passed. — see
wirekit:doctor → Example output
for the annotated transcript.
The doctor runs a package tier and an environment tier. Every check in both, in the
order the command runs them and with the criterion each one passes on, is enumerated once — on
the wirekit:doctor reference.
It is not repeated here on purpose. This page carried its own copy of the list, and the copy
said the command checks fourteen areas while it ran twenty-five: the eleven it never mentioned
included the log scan that surfaces a mistyped prop, the :root/.dark symmetry check for a
token that renders correctly in exactly one color scheme, and the built-CSS check that catches a
@source line which looks right and matches nothing. A second copy of a list is the copy that
goes stale, because it is the one nobody edits when a check is added.
The command returns exit code 1 on failure — use it in CI or as a Claude Code session-start hook:
// .claude/settings.json (in the developer app)
{
"hooks": {
"SessionStart": [
{
"command": "php artisan wirekit:verify --no-ansi 2>&1 || true",
"description": "Check WireKit integration on session start"
}
]
}
}
Responsibility Boundaries
| Concern | Responsible |
|---|---|
| Component Blade templates | pushery/wirekit |
CSS design tokens (wirekit.css) |
pushery/wirekit |
Alpine.js components (wirekit.js) |
pushery/wirekit |
Documentation content (docs/) |
pushery/wirekit |
| Loading wirekit.css / wirekit.js | Developer app |
Tailwind @source scanning config |
Developer app |
| Alpine.js installation | Developer app |
| Chart.js / QR Code installation | Developer app |
| App layout, routing, CSS overrides | Developer app |
| Preview container styling | Developer app |
Rule of thumb: If a component renders correct HTML but looks unstyled or non-interactive, the issue is in your app's asset loading — not in WireKit.
Motion
Motion follows the operating system by default: when a visitor has asked for reduced motion, transitions collapse and components that animate on their own — a carousel advancing, a chart drawing in, a streamed response appearing token by token — switch to instant.
If your application holds its own motion setting, say so on <html> and it wins:
<html lang="en" data-reduce-motion="{{ auth()->user()?->reduceMotion ? 'reduce' : 'no-preference' }}">
| Value | Meaning |
|---|---|
reduce |
Reduce motion whatever the operating system says |
no-preference |
Do not reduce, even though the operating system asks |
| attribute absent | Follow the operating system — the default |
The middle value is the reason the attribute exists. A media query can express two states, and someone who enabled the OS setting for an unrelated reason has no way to say "not here" — previously they would have needed to out-specify the library's own rules and add !important to do it.
Both halves of the library read this one attribute: the stylesheet for transitions and animations, and the components for the decisions CSS cannot make. They cannot disagree on the same page.
Increased contrast
The same three-state shape, for the preference that had no answer here at all. prefers-contrast is an OS setting, and an application that offers its own — an account preference, a toggle in a dialog — needs to be able to override it in both directions:
<!-- 1. The reader asked for more contrast. Wins over the OS setting. -->
<html data-contrast="more">
<!-- 2. The reader declined. Also wins over the OS setting — this is the state a
media query cannot express, and the reason an attribute exists. -->
<html data-contrast="no-preference">
<!-- 3. Neither attribute: follow `prefers-contrast`. -->
<html>
It affects the muted end of the palette — helper text, placeholders, borders — which is what a reader with low vision loses first. Set it on <html> from your own preference UI, exactly as you set .dark.
Both attribute names are fixed. a11y. and a11y. in
config/ record data-reduce-motion and data-contrast so you can read them without
grepping a bundle, rather than to set them. The stylesheet names both literally in its selectors,
and the JavaScript carries the motion one as its own constant. Setting either key to something else
changes neither.
That is worth knowing in one situation: if you write your own reduced-motion or contrast rules, match these attributes and your rules and WireKit's stay on the same page state.
Type size
The third preference, and the one that is a number rather than a state. A reader who needs larger type has the browser zoom — but zoom scales the whole page, and on a dense interface that reflows the layout into something harder to use, not easier. An application that offers its own type-size setting needs to move the type and leave the layout alone.
One custom property does the whole ramp:
<!-- 1. Everything WireKit renders gets 25% larger type. Nothing else moves. -->
<html style="--font-scale-wk: 1.25">
/* 2. Or drive it from your own preference UI, the same way you set `.dark`. */
:root { --font-scale-wk: 1.15; }
Set nothing and the default is 1, which renders byte-identically to before — the
factor multiplies each size, and multiplying by one changes nothing.
It reaches every size because the ramp is rem throughout. The single exception is
deliberate: form controls carry a 16px floor that stops Safari zooming the page when a
field takes focus, and scaling that away would restore the problem it prevents. A build
guard fails if a second absolute size appears, because the way a scale like this breaks
is by omission — one component ships a fixed size, that one surface ignores the reader's
setting, and nothing anywhere goes red.
Three preferences, three mechanisms, one reason each. Motion and contrast are
attributes because each has three named positions, including the "not here" a media
query cannot express. Type size is a custom property because it carries a value, and an
application asking for 1.25 should not have to pick from a list somebody guessed at.
a11y. in config/ records the name, exactly like its
two siblings — it records it, it does not change it.
Arbitrary values ride in an inline style
A prop that takes any CSS value — grid's min and template, and the computed sizes on avatar, image, stats, reading-progress, color-picker, status-matrix, slider, editor, lightbox, image-compare, reading-minimap, indicator and shimmer — is delivered as an inline style attribute. It has to be: Tailwind extracts class names from source text, so a value computed at runtime leaves its scanner nothing to find.
A style-src policy without 'unsafe-inline' drops those attributes. Level 3 gives you style-src-attr if you want to allow inline attributes while still forbidding inline <style> blocks.
For grid the loss is total, not cosmetic — and nothing reports it. Everywhere else a dropped inline style costs a shade or a width and the component still reads correctly. grid's min and template carry the entire column definition, so without them the grid silently stacks into one column: no console error, no failed request, just a layout that is wrong. Do not ship a strict style-src without checking one grid by hand. If you must, pass cols alongside the track prop to declare what it falls back to — see the grid page.
Touch targets
On a coarse pointer, form controls and buttons are lifted to a 44×44 target, and text fields render at 16px or more so mobile Safari does not zoom the page when one takes focus. Text that is already larger keeps its size. Your desktop rendering is untouched — both rules live behind @media (pointer: coarse).
Icon buttons that a component renders internally — the theme control, the code-block copy button, the notification bell — get there a different way. They carry wk-touch-target, which centers a transparent 44×44 hit area inside the control without changing its size. A size floor would have moved layout: an earlier attempt at exactly that widened a toolbar of small icon buttons past its container and had to be withdrawn.
You can use the class on your own icon buttons:
<button type="button" class="wk-touch-target" aria-label="Dismiss">
<x-wirekit::icon name="close" />
</button>
One case where it is the wrong tool. It gives its host a positioned ancestor only when the host does not already declare one, so a fixed or absolute element is safe (on a build older than v2.24.0 such an element is moved rather than enlarged). What remains: in a dense grid, 44px hit areas overlap, so the element painted last takes the taps meant for its neighbor: a small target becomes an unreachable one. WCAG 2.5.8 allows an undersized target that is spaced away from its siblings, which is the case those grids fall under.
A field you build yourself
The 16px floor reaches a field through its text token. Every WireKit field takes its size from a text-[length:var(--text-wk-*)] utility, and on a touch screen the field raises those tokens to at least --text-wk-touch-floor. A select, input or textarea you write yourself gets the same floor from wk-field, as long as its size is one of those tokens: with text-[length:var(--text-wk-sm)] it shows 13px with a mouse and 16px on a touch screen.
A size written as a literal, or inherited from the element around the field, is not a token, and wk-field leaves it as it is. That field stays below 16px on a phone, and mobile Safari zooms the page when it takes focus. Give it wk-touch-text-floor:
{{-- 1. The size is a literal, so the field asks for the floor: 15px with a mouse, 16px on a touch screen. --}}
<textarea name="note" class="wk-field wk-touch-text-floor text-[15px]" aria-label="Note"></textarea>
With the marker the field shows at least --text-wk-touch-floor on a touch screen, and at least the size of the text around it. The rule stands in for the literal while it applies, so leave the marker off a field whose own text is 16px or larger. On a field that names a text token the marker changes nothing, because the token is lifted already.
Before v2.61.0, wk-field set every field to exactly 16px on a touch screen, which also shrank larger text. If you built fields with a literal size below 16px against that behavior, they need the marker now.
Content Security Policy
If your app serves pages under a Content-Security-Policy, there are three things to know before you ship. All are easy; the first two are easy to miss because nothing reports them.
Interactive components need script-src 'unsafe-eval'
The interactive components are Alpine components — x-data, x-on, x-bind. Alpine's standard build evaluates those expressions with new Function(...), so a policy that does not allow 'unsafe-eval' stops them from being evaluated. This is Alpine's requirement rather than something in WireKit's own code, and it applies however Alpine reaches the page — bundled by Livewire, or through the self-contained wirekit-alpine..
Without 'unsafe-eval' the failure is silent. Nothing throws and nothing looks broken. A one-time-code field still accepts typing, it just stops advancing to the next box; a dropdown still renders, it just never opens. There is no console error to search for, so this is normally found by a user report rather than by testing. If your policy omits 'unsafe-eval', assume the interactive components do not work and verify one of them by hand.
A minimal policy that works:
script-src 'self' 'unsafe-eval';
style-src 'self';
'unsafe-eval' is the concession a strict policy gives up most reluctantly, and that is a fair objection rather than a technicality. If you cannot grant it, use the CSP bundle instead.
A Livewire app: keep the default bundle, switch Livewire
If your app is Laravel + Livewire — the setup wirekit.js is the default for —
this is the shortest route to a policy without 'unsafe-eval', and it changes
nothing about how you load WireKit:
// config/livewire.php
// 1. Livewire serves its own CSP distribution, which brings a CSP-safe Alpine.
'csp_safe' => true,
// config/wirekit.php
// 2. Unchanged. The default bundle is already the right one here.
'scripts' => [
'bundle' => 'full',
],
That is enough because wirekit.js carries no evaluator of its own. It does
not bundle Alpine — it registers plugins on the Alpine your app already has. So
in a Livewire app, the 'unsafe-eval' requirement comes entirely from the Alpine
Livewire ships, and switching Livewire to its CSP distribution removes it.
Reaching for wirekit-alpine. here is the wrong turn: it brings a second
Alpine, which is precisely what the warning below tells you not to do. That
bundle is for an app with no Alpine of its own.
Verify rather than assume — the failure is silent, so a policy that is not actually working looks exactly like one that is:
# 3. Every Alpine expression in YOUR views, checked against Alpine's own CSP grammar.
php artisan wirekit:csp-audit
A method named after a JavaScript operator or literal needs index access — on
@alpinejs/csp 3.17.2 and older. There, $wire. does not parse under the
CSP grammar while $wire['delete'](...) does. From 3.17.3 the parser accepts both,
so on a current install there is nothing to rename. wirekit:csp-audit measures the
parser you actually have rather than assuming one, so run it and believe its answer; it
prints the index-access advice only when your parser needs it. The affected names are
these, and this is all of them:
delete · false · in · instanceof · new · null · true · typeof · undefined · void
The list is shorter than "a JavaScript keyword" suggests, and the difference is worth
knowing before you rename anything. Alpine's CSP tokenizer promotes exactly the words
above to operator and literal tokens; every other reserved word — class, for,
function, import, return, this, var and yield among them — is read as an
ordinary identifier and parses after a dot. A method called for or class needs no
change, so renaming one is a cost with nothing bought.
The audit above finds the real cases before a user does.
The CSP bundle: no 'unsafe-eval' at all
WireKit ships a second self-contained bundle built against Alpine's Content-Security-Policy distribution, which interprets directive expressions rather than compiling them with new Function(...).
// config/wirekit.php
# 1. Serve wirekit-alpine.csp.js instead of the standard bundle.
'scripts' => [
'bundle' => 'csp',
],
script-src 'self';
style-src 'self';
Do not load your own Alpine alongside it. This bundle brings its own, and two copies of Alpine on one page fight over the same elements. If your app already runs Alpine — a Livewire app does — then either drop that pipeline or stay on the standard bundle.
On a Livewire page this bundle's parser never runs, so choosing it buys nothing. The order is fixed by the tags rather than by the source: Livewire's script carries no defer and executes while the document is parsed, which sets window.Alpine; this bundle is deferred and runs afterwards. It finds an Alpine already there, registers WireKit's components on that one, and never starts its own — the console says so. The page keeps working and keeps needing 'unsafe-eval', now with about 70 KB more JavaScript than the standard bundle.
The lever on a Livewire page is Livewire's own. Set csp_safe in config/ and leave wirekit. at full. The CSP bundle is the answer for a page without Livewire, where it really is the Alpine evaluating your expressions.
Every expression WireKit itself writes resolves under this bundle. That is a checked claim rather than an aspiration: an audit runs Alpine's own tokenizer, parser and evaluator over every directive in the library on each build, and the budget for expressions that fail is zero — one that stopped resolving would fail the build rather than reach you.
Structured props and non-ASCII copy are covered too. A component that takes a list, a map, or text outside ASCII renders that value into the markup, and the CSP build reads a narrower grammar than the standard one — so how the value is encoded decides whether it arrives. WireKit writes each one as a plain JavaScript literal rather than a JSON.parse(…) call the CSP evaluator cannot resolve, and leaves non-ASCII characters as themselves rather than escaping them into text the tokenizer would mangle.
Both are checked on every build, so a component that regressed would fail rather than reach you quietly.
A Livewire app requires Livewire's CSP build as well. WireKit's bundle covers WireKit's directives. A Livewire app also runs Livewire's own, and its standard build compiles those at runtime exactly as Alpine's does — so loading only WireKit's CSP bundle is not enough. Livewire ships a CSP distribution for this; point your bundler at it, or the page still requires 'unsafe-eval' no matter which WireKit bundle you load.
The remaining trade-off is bundle size — the CSP distribution carries an interpreter the standard build does not need — and the bundle table above lists both.
Every asset directive takes a nonce
Under a 'strict-dynamic' policy the nonce is the only thing that grants a resource, so all three directives accept one and omit the attribute entirely when you pass nothing:
<head>
@wirekitStyles($cspNonce)
@wirekitThemeScript($cspNonce)
</head>
<body>
{{-- … --}}
@wirekitScripts($cspNonce)
@livewireScripts
</body>
Under a 'self'-based policy the files need none of this: they are same-origin and already allowed. The theme script is the exception, because it is written into the page by default. Set wirekit. to external and it loads as a same-origin file as well.
If you render markup of your own that needs the same nonce — an inline <style>
in a custom component, a script tag beside a WireKit one — ask for the value
rather than threading a variable through every view:
use Pushery\WireKit\WireKit;
// The nonce this request runs under, or null when it runs under none.
$nonce = WireKit::cspNonce();
It resolves the same way the directives do: a csp-nonce container binding
first, then Vite's own nonce — which is the value Livewire reads — so an
application that already has one is covered without configuring anything twice.
null is a real answer and means the request has no policy, not that lookup
failed.
A policy with hashes instead of a nonce
A policy that admits inline scripts by hash, script-src 'self' 'sha256-…', has to list every inline script it allows. WireKit writes these: the first-paint seed after a sidebar or app-rail with persist, the seed after a folding sidebar.group or sidebar. with persist, and the theme script while wirekit. writes it into the page. Each body is a constant, so the hashes are the same on every page. Ask for them instead of computing them:
use Pushery\WireKit\WireKit;
// 1. Each entry is a hash source as the header spells it, such as 'sha256-Pwy1…='.
$sources = array_map(fn (string $hash): string => "'{$hash}'", WireKit::inlineScriptHashes());
// 2. Add them to the script-src your middleware sends.
$response->headers->set('Content-Security-Policy', "script-src 'self' ".implode(' ', $sources));
A browser refuses a script whose hash is missing without any sign on the page: the remembered column or section then settles a moment after the first paint, and the theme is applied after it. A policy with a nonce needs none of this, and with wirekit. set to external the theme script loads as a file that 'self' already admits.
Troubleshooting common console errors
Field reports point at three failure modes that produce broken interactive components (Modal / Drawer / Popover / Dropdown / Context-Menu / Clipboard-Button). Each shows up as a different console error; the fix surface is the same in every case.
| Console error | Likely root cause | Fix |
|---|---|---|
Uncaught ReferenceError: wirekitModal is not defined (or any other wirekit* factory name) |
dist/wirekit.js not loaded, or @wirekitScripts missing from the layout. The Alpine factory registration that powers x-data="wirekitModal(...)" lives in that bundle. |
Add @wirekitScripts to the layout (typically right before @livewireScripts at the end of <body>). Run php artisan wirekit:doctor to verify. |
Alpine Expression Error: ... is not a function for an overlay component (Modal / Drawer / Popover) |
The core bundle is configured ('scripts' => ['bundle' => 'core'] in config/wirekit.php) but the page renders an overlay that depends on Floating UI / focus-trap. The core bundle ships only the chart Alpine factory; overlays require the full bundle. |
Switch to 'bundle' => 'full' in config/wirekit.php, or remove the override (full is the default). |
wirekitDropdown is not defined AND every other wirekit* factory is also missing |
The bundle RAN after Alpine.start(), so the Alpine.data(...) registrations arrived once the components had already mounted. Note that this is about execution, not tag position: @wirekitScripts emits defer, so in a plain Livewire layout the order of the two directives does not produce this. |
Look for what runs it late — an async attribute in place of defer, a bundle injected at runtime, a bundler that dropped the defer, or a setup that starts Alpine itself before Livewire does. Run php artisan wirekit:doctor. |
chartjs-plugin-annotation warning when using <x-wirekit-chart annotations="..."> |
Annotations are passed to a Chart.js chart but the optional chartjs-plugin-annotation package isn't installed. The chart still renders without annotations. |
Either install npm install chartjs-plugin-annotation and register it in your app.js, or switch the page to ApexCharts ('library' => 'apexcharts') which has annotations built-in. |
apexcharts is not defined when using <x-wirekit-chart library="apexcharts"> |
ApexCharts adapter is selected but apexcharts npm package isn't installed AND/OR wirekit-apex.js isn't loaded. |
npm install apexcharts, expose globally (window.ApexCharts = ApexCharts; in app.js), and add <script src="{{ asset('vendor/wirekit/wirekit-apex.js') }}" defer></script> BELOW wirekit.js in your layout. See Chart docs → ApexCharts setup. |
[WireKit] Overlay styles are missing, so dialogs, drawers and the command palette will render in normal page flow |
The package stylesheet did not load, or something overrode its overlay geometry. WireKit probes this once, when the first overlay is created, by measuring whether a probe element actually resolves to position: fixed — so a stylesheet that loaded and was then overridden reports here too, which is intended. |
Load it with @wirekitStyles, or import wirekit/dist/wirekit.css. If you build Tailwind yourself, also point it at this package with @source. Check the whole install with php artisan wirekit:verify. |
Two things are worth knowing before you wire a pipeline around it.
It is emitted through console.error. That is deliberate: an unusable dialog is a defect, not a style nit. But it means any check that fails a build on console errors goes red here rather than merely noting it, which is the correct outcome and still a surprise the first time.
And it answers one question only: did the overlay stylesheet arrive? A silent probe is not a promise that every overlay is positioned by it — an individual component could still take its geometry from your Tailwind build alone, and the probe cannot see that. WireKit checks that second question on its own side, in the test suite, so a shipped component cannot quietly depend on your build.
php artisan wirekit:doctor checks the four most common root causes (assets published, directive presence, directive order, bundle configuration) and prints a literal fix snippet for whichever one fails. Always run it before opening a bug report.
Performance — Conditional Asset Loading
WireKit ships syntax-highlighted code blocks (via <x-wirekit::) and chart canvases (via <x-wirekit::). Both pull in non-trivial client-side bundles:
| Bundle | Size (gzip) | Used for |
|---|---|---|
highlight.js engine + 2 themes |
~25 KB | Syntax highlighting in <pre><code> |
Chart.js UMD |
~70 KB | <x-wirekit::chart> Alpine component |
Most documentation pages — landing, about, theming guide, recipes that don't ship code — never need either. Loading both globally costs ~95 KB gzip on every page-view. Doc-sites at scale are noticeably faster on lightweight routes when these bundles are gated to "page actually needs them."
Pattern: server-side flag detection
Detect which bundles the rendered page actually uses by scanning the output HTML once, after rendering, and emit only the relevant tags.
Step 1 — App\:
<?php
declare(strict_types=1);
namespace App\Support;
final class PageAssetHints
{
/**
* @return array{hasCode: bool, hasChart: bool}
*/
public static function detect(string $html): array
{
return [
'hasCode' => str_contains($html, 'class="hljs') // pre-highlighted source-panes
|| str_contains($html, 'data-language='), // your code-block marker
'hasChart' => str_contains($html, 'x-data="wirekitChart')
|| str_contains($html, 'x-data="wirekitChart'),
];
}
}
The detection is conservative: false positives (loading hljs on a page that doesn't actually need it) waste payload but stay correct; false negatives (skipping hljs on a page that does need it) break syntax highlighting visibly. Pick patterns that err on the side of inclusion, then tighten as you observe production traffic.
Step 2 — wire it into the layout:
Compute the hints from the $slot of your layout x-component (Blade renders the slot first, then the layout, so $slot is already a string when the layout body executes):
{{-- resources/views/layouts/app.blade.php --}}
@php
use App\Support\PageAssetHints;
$hints = PageAssetHints::detect((string) $slot);
$hasCode = $hints['hasCode'];
$hasChart = $hints['hasChart'];
@endphp
<!DOCTYPE html>
<html>
<head>
{{-- … other tags … --}}
@if($hasCode ?? true)
<link rel="stylesheet" href="{{ asset('vendor/highlight.js/styles/github.min.css') }}" />
@endif
</head>
<body>
{{ $slot }}
{{-- … other scripts … --}}
@if($hasChart ?? true)
<script src="{{ asset('vendor/chart.js/chart.umd.min.js') }}" defer></script>
@endif
@wirekitScripts
@livewireScripts
@if($hasCode ?? true)
<script src="{{ asset('vendor/highlight.js/highlight.min.js') }}" defer></script>
{{-- + your hljs-setup, copy-code, code-expand scripts --}}
@endif
</body>
</html>
The ?? true fallback preserves the old "always load" behavior for any future render-path that bypasses the slot entirely (e.g. a non-x-component error page).
Why server-side flag (not client-side IntersectionObserver)?
| Approach | Latency | Cache-friendly | Complexity |
|---|---|---|---|
| Server-side flag (this) | First paint correct | ✓ | LOW (1 helper, 2 @if blocks) |
| Client-side IntersectionObserver | Bundle loads on intersect | ✓ but needs careful media handling | MEDIUM (custom JS loader) |
| Always-on (default) | First paint correct | ✓ | NONE — but +95 KB always |
Server-side flag wins on UX (no FOUC on first scroll into a code block because hljs hadn't loaded yet) and cache-key invariance: the page HTML is the same; only the rendered tags differ — so existing per-page HTML cache stays valid.
Why the cache-key doesn't change
Critical detail: the hint computation runs after the cached HTML is fetched, not before. The hint is a function of the already-cached output, not part of the cache key. This means existing per-page HTML caches stay valid through this refactor, and pre-warmer commands need no changes — hints recompute on every render at ~50µs per page. If you do want to skip the hint computation on cache hits, memoize PageAssetHints:: by md5($html) — typically not worth the 50µs you save per request.
Verified results
A reference implementation on docs. (with 8 representative routes including /, /getting-started, /components/, /components/, /blocks, /search/results) cleanly partitions the asset surface: pages without code skip the hljs payload, pages without charts skip Chart.js, pages with both load both. Tested with 7 Pest cases covering every detection pattern + edge cases (entity-encoded attrs, false-positive guards on inline <code> mentions in prose).
WireKit does not ship a built-in helper for this pattern — the 1–2 LOC str_contains() checks are simpler than a coupled API surface, and developer doc-sites typically need to adapt the detection regex to their own DocsParser / class-naming conventions. Re-evaluate as a shared helper if multiple developers converge on the same patterns.
CI / Deploy script discipline
wirekit:install is strict-by-default in v2.1.0+: pre-flight validation aborts when any flag is malformed, a token-clobber is detected, or the project is still on Tailwind CSS v3 (WireKit's CSS is built on the Tailwind v4 engine and cannot run on v3 — the abort message points you at the upgrade), all BEFORE touching the filesystem. Every wirekit:* command uses two exit codes and only two — 0 for success, 1 for every failure including a rejected invocation — so a CI step branches on non-zero and reads the message for which case it is in. See the CLI Reference for the reasoning behind the two-code contract. Wire it into your CI / deploy script with the canonical fail-fast shape:
#!/usr/bin/env bash
# 1. Fail fast on any error, undefined variable, or pipe failure so a
# silent install regression never reaches production.
set -euo pipefail
# 2. Run WireKit's strict-default install. Pre-flight errors abort
# BEFORE the filesystem is touched; runtime failures abort after
# partial work. Both exit 1, so branch on non-zero rather than on
# a specific code — the message says which case you are in.
if ! php artisan wirekit:install \
--preset=cupertino \
--font=inter \
--no-interaction; then
echo "::error::WireKit install failed — aborting deploy"
exit 1
fi
# 3. Re-publish vendor assets if your deploy pipeline strips
# public/vendor/wirekit/ between runs. `--force` overwrites
# existing files so the bundles always match the installed
# package version.
php artisan vendor:publish --tag=wirekit-assets --force --quiet
Distinguishing exit codes
| Exit | Action |
|---|---|
0 |
Install + verify completed cleanly. Continue your pipeline. |
1 |
Every failure, and the message tells you which one. A pre-flight rejection names every problem in one pass and touched nothing — fix the input and retry. A runtime failure happened mid-install — read the install output; the install-log may have rolled back partial state. |
There is no third code. if [ $? -eq 2 ] is a branch that never fires — test for non-zero and read the message instead.
GitHub Actions example
# 1. The job step itself fails on any non-zero exit, and both failure
# cases — a rejected invocation and a runtime failure — exit 1, so
# the workflow aborts without an if-conditional around the step.
- name: WireKit install (strict)
run: php artisan wirekit:install --preset=cupertino --no-interaction
Opt-out for legacy CI scripts
CI scripts that were written against the pre-v2.1.0 "warnings as success" behavior can preserve their semantic by combining the two opt-out flags:
# 1. --no-strict downgrades pre-flight warnings from aborts to
# advisories (the v2.0.0 default).
# 2. --ignore-failed-flags lets per-flag failures (font validation,
# preset application) report-and-continue rather than abort.
# 3. Together they preserve the exact v2.0.0 "best-effort install"
# semantic — useful for legacy CI scripts that depended on the
# pre-strict behavior.
php artisan wirekit:install --font=inter --no-strict --ignore-failed-flags --no-interaction
Use sparingly — the strict-default exists because the legacy mode silently masked install failures that developers were surprised to discover later.
Dry-run preview before committing
wirekit:install --diff reports what WOULD change without writing any files. Useful when reviewing an install change in a code-review setting:
# 1. --diff is read-only: pre-flight validation still runs (so
# invalid input is caught), but no filesystem mutations happen.
# 2. Two exit codes, the same two every wirekit:* command uses: 0
# after printing the would-be state, 1 if pre-flight rejects the
# input. Drop --diff and re-run to actually apply the changes.
php artisan wirekit:install --preset=cupertino --font=inter --diff
Rollback the most recent install
wirekit:install --rollback reverses the most-recent install session by replaying its recorded before-snapshots from .wirekit-install. at your project root. Per-file restore — failed restores reported but non-fatal. An install that stopped partway, at a failed flag or a failed wirekit:verify, is recorded as well, so --rollback reverses what it had already changed.
# 1. Replays the before-snapshots recorded during the most recent
# install session. Restores app.css, the layout file, .gitignore,
# .wirekit-schema.json, config/wirekit.php, and every file under
# public/vendor/wirekit/ to its pre-install byte-for-byte state.
# 2. --rollback is mutually exclusive with every other install flag;
# run it on its own. Files that didn't exist before the install
# are removed; files that existed before are restored from their
# captured snapshot.
php artisan wirekit:install --rollback
The install-log is committed to your repository by default (along with .wirekit-schema.); it records the wirekit:install invocations that --rollback can undo. It keeps the five most recent sessions and drops older ones as new installs arrive — it is a rollback window, not a permanent record, so committing it produces a deletion diff once you pass the fifth install. Add it to .gitignore if you would rather keep installs fresh per environment.