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. - 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 @livewireScripts:
<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 DOMContentLoaded, 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.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 WireKit
Alpine 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.css. The rule therefore lives in that file — not in your Vite bundle, and grepping public/build/assets/app-*.css for it finds nothing whether everything is wired up correctly or not. Check the served wirekit.css instead, or the published copy at public/vendor/wirekit/wirekit.css.
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. Those 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.assets.middleware:
// 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/css/app.css:
/* 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/pushery/wirekit/dist/wirekit.css'inresources/css/app.css— Tailwind v4 picks up the variables on its next compile. Useful when you want a single bundled CSS file from Vite.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).
4. Alpine.js — comes via Livewire v4
WireKit declares livewire/livewire ^4.0 as a hard composer dependency. Livewire v4 bundles Alpine.js and starts it for you when @livewireScripts 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@livewireScriptsin your layout (per Step 2). Livewire's bundle starts Alpine; WireKit's components register onalpine: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.
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/css/app.css 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 key off a `.dark` CLASS on a parent
(the same class WireKit + your toggle set on <html>), 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. */
@custom-variant dark (&:where(.dark, .dark *));
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/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/vendor/wirekit/to fall back to the always-current package copy.
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: @wirekitThemeScript($nonce).
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 is simply skipped).
| 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/wirekit.php:
'charts' => [
'library' => 'chartjs',
],
Chart.js dark mode works automatically — WireKit's Alpine component uses a MutationObserver 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::editor> is a thin Alpine adapter around a ProseMirror editor (Tiptap recommended). Expose a window.wirekitEditor(config) factory (the legacy window.tiptapEditor 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::map> renders through whichever engine you load on window — MapLibre GL (window.maplibregl) or Leaflet (window.L). 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 two stackable extension presets — heroicons-app (app-state aliases like arrow-up, bell, lock) and heroicons-marketing (landing-page aliases like sparkles, rocket-launch, chart-bar) — that add aliases on top of any base preset without an extra package install.
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.com/license), wirekit-tiptap.js (the optional rich-text editor adapter glue — see "Choosing a JS bundle" below), wirekit-optimistic.js (the optional Optimistic UI factory — see "Choosing a JS bundle" below), wirekit-alpine.js (the optional self-contained bundle — see "Choosing a JS bundle" below), and wirekit-alpine.csp.js (its Content-Security-Policy twin) from the package into public/vendor/wirekit/. 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 |
~219 KB | Your app already runs Alpine.js and registers WireKit's components yourself. The default for Laravel + Livewire setups. |
wirekit.core.js |
~13 KB | You only need the chart component, no overlays. Smallest footprint. |
wirekit-alpine.js |
~264 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 |
~21 KB | Optional ApexCharts adapter glue. Loaded ALONGSIDE one of the above when you've set wirekit.charts.library => 'apexcharts'. |
wirekit-optimistic.js |
~8 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 |
~279 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 |
~7 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.js and wirekit.js are mutually compatible — loading both produces a console warning and the second one skips its own Alpine registration. But you should pick exactly one for clarity.
Tip: Add
public/vendor/wirekit/to your.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/wirekit.css) 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/vendor/wirekit/ 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/vendor/wirekit/wirekit.css 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/wirekit.css 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
Required for production deploys
WireKit's default wirekit:install adds /public/vendor/wirekit 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/vendor/wirekit/ is gitignored, which is the default.
How to check if you forgot
php artisan wirekit:doctor ships a check that fails when public/vendor/wirekit/ 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.
Output:
WireKit Integration Check
✓ wirekit.css published
✓ wirekit.js published
✓ wirekit.css is up to date
✓ wirekit.js is up to date
✓ Tailwind @source includes WireKit templates
✓ config/wirekit.php published
✓ @wirekitStyles directive in layout
✓ @wirekitScripts directive in layout
✓ @wirekitScripts is before @livewireScripts
✓ Alpine.js detected
✓ JS bundle configured: full
✓ Font assets published
✓ Chart.js adapter configured
✓ bacon/bacon-qr-code installed
14 passed, 0 warnings, 0 failed
All checks passed.
The command checks 14 areas:
- wirekit.css published — CSS design tokens in
public/vendor/wirekit/ - wirekit.js published — Alpine.js component registrations
- CSS freshness — published asset matches package source (MD5)
- JS freshness — published asset matches package source (MD5)
- Tailwind @source —
resources/css/app.cssscans WireKit Blade templates - config/wirekit.php — config published for customization
- @wirekitStyles — Blade directive present in layout
<head> - @wirekitScripts — Blade directive present in layout
<body> - Directive order —
@wirekitScriptsbefore@livewireScripts - Alpine.js — detected in JS entry files or layout (CDN)
- Bundle config —
wirekit.scripts.bundleis valid (fullorcore) - Published views — warns if overridden views may be outdated
- Font assets — published if custom fonts are configured
- Optional deps — Chart.js adapter, QR Code package (info only)
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.motion_attribute and a11y.contrast_attribute in
config/wirekit.php 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.font_scale_property in config/wirekit.php 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 so mobile Safari does not zoom the page when one takes focus. 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, and one that used to be. Since v2.24.0 it gives its host a positioned ancestor only when the host does not already declare one, so a fixed or absolute element is safe — it used to be moved rather than enlarged, and on an older build it still is. 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.
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.js.
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.csp.js 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.
$wire.delete(...) does not parse under the CSP grammar, while $wire['delete'](...)
does. 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 with 'unsafe-eval'.
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 you need none of this — the assets are same-origin and already allowed.
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.
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') }}"></script> BELOW wirekit.js in your layout. See Chart docs → ApexCharts setup. |
Run the doctor first
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::code-block>) and chart canvases (via <x-wirekit::chart>). 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\Support\PageAssetHints:
<?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::detect($html) by md5($html) — typically not worth the 50µs you save per request.
Verified results
A reference implementation on docs.wirekit.app (with 8 representative routes including /, /getting-started, /components/button, /components/chart, /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 with exit code 2 (INVALID) 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. 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
# with exit 2 BEFORE the filesystem is touched; runtime failures
# abort with exit 1. The deploy step exits non-zero either way.
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 |
Runtime failure mid-install — investigate the install output; the install-log may have rolled back partial state. |
2 |
Pre-flight validation rejected the input. The error message names every problem in one pass. Fix the input + retry. |
GitHub Actions example
# 1. The job step itself fails on any non-zero exit. Exit 1 (runtime
# failure) and exit 2 (pre-flight validation rejected the input)
# BOTH abort the workflow — no need to wrap in if-conditionals.
- 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. The exit code reflects the would-be state — 0 if the install
# would succeed, 2 if pre-flight would reject. 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.log at your project root. Per-file restore — failed restores reported but non-fatal.
# 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.json); it's an audit trail for wirekit:install invocations. Add to .gitignore if you prefer to keep installs fresh per environment.