Laravel, Inertia and Vue
Laravel serves the chrome's data, Vue renders it. The Blade components and the Vue components emit the same class names, the same ARIA and the same data attributes, so an application that has half its pages in Blade and half in Inertia does not look like two applications.
Laravel 11 or 12, PHP 8.2 or newer, Vue 3.5, Vite. This assumes Tailwind 4.
laravel new --vue still scaffolds Tailwind 3
As of this writing, Laravel's own Inertia and Vue starter kit installs "tailwindcss": "^3.4.1" and writes @tailwind base; / @tailwind components; / @tailwind utilities; into resources/css/app.css, not the Tailwind 4 @import "tailwindcss"; syntax every code block below assumes. Check with npm ls tailwindcss before continuing. Not npx tailwindcss --version, which this guide used to say: Tailwind 3's CLI has no --version flag and reads the argument as an input file, so it exits non-zero with Specified input file --version does not exist on exactly the version you are checking for.
The WayneUI stylesheet itself does not need Tailwind to render (it ships as built CSS with no Tailwind directives in it), so a page comes out fully styled either way: measured against a real build, 241 unique .wsu- classes land in the compiled stylesheet whether Tailwind is 3 or 4. What actually breaks is everything around it. npm install of the packages below fails outright on Tailwind 3, a peer dependency conflict, nothing installed; pnpm add, the command below, installs anyway with no warning at all and leaves Tailwind 3 in place. And if you choose the daisyUI or shadcn-vue option in step 1, both need Tailwind 4's @plugin syntax: the build still succeeds, silently, with none of the adapter's theme wired in.
Migrate first if npm ls tailwindcss says 3.x:
npm install -D tailwindcss@latest @tailwindcss/vite@latestReplace the tailwindcss/autoprefixer PostCSS plugins in vite.config.ts with the @tailwindcss/vite plugin, and replace the @tailwind triad at the top of resources/css/app.css with:
@import "tailwindcss";
@config "../../tailwind.config.js";The @config line is not optional and this guide used to omit it. The starter kit puts real theme extensions in tailwind.config.js, and Tailwind 4 stops reading that file automatically, so without it the next npm run build fails on the starter's own markup with Cannot apply unknown utility class 'border-border'. Keep the line until you have moved those extensions into @theme blocks, then delete it.
1. Install
composer require waynestate/wayne-ui
php artisan wayne-ui:install --stack=inertia
pnpm add @waynestate/wayne-ui-vue @waynestate/wayne-ui-cssThe install command detects Inertia and turns on the Vite asset strategy. See the Blade guide for what else it does, which is the same.
2. Wire it up
Three lines in resources/js/app.ts. laravel new --vue scaffolds TypeScript today, so the file is app.ts rather than app.js and already has Ziggy and initializeTheme() in it. Add to what is there; do not replace it.
The paths below are the scaffold's own, which are lower case: ./pages/, not ./Pages/. Getting that wrong appears to work on macOS and Windows, whose filesystems ignore case, and throws Page not found the first time the application is deployed onto Linux.
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'
import WayneUI from '@waynestate/wayne-ui-vue'
import '../css/app.css'
createInertiaApp({
resolve: (name) => resolvePageComponent(`./pages/${name}.vue`, import.meta.glob('./pages/**/*.vue')),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
// The sprite sits at a different URL in every application. Provide it
// once here and every <Icon> reads it from there rather than guessing.
.use(WayneUI, { sprite: '/vendor/wayne-ui/icons.svg' })
.mount(el)
},
})resolvePageComponent comes from laravel-vite-plugin, not from WayneUI or Inertia; it already ships with every Laravel application that uses Vite, so there is nothing new to install for that import.
and the stylesheet in resources/css/app.css:
@import "tailwindcss";
@import "@waynestate/wayne-ui-css";Pointing the sprite at the CDN
sprite is the one place icons are configured on this path, and the default above, /vendor/wayne-ui/icons.svg, assumes wayne-ui:install copied the sprite into public/vendor/wayne-ui. To fetch it from the CDN instead:
.use(WayneUI, { sprite: 'https://wayneui.apps.wayne.edu/v2/icons.svg' })This is exactly as safe as moving icons on its own now is on the Blade and raw-PHP paths. The plugin does not just hand sprite to every <Icon>; it also writes the same value to data-wsu-sprite on <html>, which is what @waynestate/wayne-ui-css/theme reads to know where to fetch the sprite from. Both sides always agree, because both read the one option you set here.
That is also why sprite cannot be left unset the way the CDN case can elsewhere. wayne-ui-theme.js normally defaults the sprite to a sibling of its own script tag, worked out from import.meta.url, but this path imports the runtime as source through Vite rather than loading a built file by URL, so import.meta.url is the bundler's fingerprinted module path, not anything useful. Pass sprite explicitly whenever it is not the install command's own default.
Add the CDN host to your Content-Security-Policy's connect-src. The bundled stylesheet and script need nothing extra here, since Vite serves them from your own origin either way, but the sprite is loaded with fetch(), which connect-src governs on its own and style-src or script-src do not cover.
Your policy needs img-src too, and not only if you moved the CDN host. The Vue AppBar, Masthead, Footer and UniversityFooter components all render the shield, wordmark and Warrior Strong marks as real <img> tags, on your own origin by default, so a policy with no img-src of its own falls back to default-src, and every one of those marks comes back broken with nothing louder than a console warning to explain why. See the worked recipe in step 3 below: the same policy has to let the theme script in app.blade.php run too, and a reader building one policy should build it once, correctly, rather than adding directives as things break.
If you use the public template, allow data: on img-src as well. The official masthead's own search icon, styled by the third-party @waynestate/wsuheader stylesheet, is a base64-encoded background image rather than a file the CDN host would cover, and img-src governs a CSS background-image exactly as it governs an <img> tag.
The plugin registers every component globally under a Wsu prefix, so a page can write <WsuButton> without importing anything. Importing the named exports per file works identically and tree-shakes, which is what a Vite application should prefer:
import { AppShell, AppBar, Button, DataTable } from '@waynestate/wayne-ui-vue'The prefix exists because Footer, Dialog, Input and Select all collide with something, either a real HTML element or a component your application already has, and a silent collision with a native element is a confusing thing to debug. Pass prefix: '' to opt out.
Focus after a visit
An Inertia visit replaces the page component without a full page load, so focus stays wherever the link was, inside chrome that has just been replaced. Nothing moves it for you. Add this to app.ts, next to createInertiaApp:
import { router } from '@inertiajs/vue3'
import { nextTick } from 'vue'
router.on('finish', (event) => {
const visit = event.detail.visit
if (visit.method !== 'get' || visit.prefetch) return
nextTick(() => {
// preventScroll because Inertia has already restored the scroll
// position; focusing without it would move the page a second time.
document.getElementById('main')?.focus({ preventScroll: true })
})
})That moves focus to <main>, the same place a full page load would have put it (WCAG 2.4.3), which is why the layout below puts main-id="main" on AppShell: AppShell already sets tabindex="-1" on <main> so it can receive focus programmatically, and the id here has to match whatever main-id you pass it.
finish, not navigate: Inertia skips navigate whenever a visit lands back on the exact URL it started from, on the theory that nothing about the history stack changed. A drawer link pointing at the page already open is exactly that case, and the drawer has just closed out from under the click, the same as removing any other control mid-interaction. finish fires for every visit regardless of where it lands. GET only, and never a prefetch: a form post is an action, not a navigation, and the page it returns to already knows better than <main> does where focus belongs, an error summary or a success message rather than the top of the page.
3. The pre-paint theme script
This one has to go in resources/views/app.blade.php, in the <head>, before anything else:
<script{!! ($nonce = \Illuminate\Support\Facades\Vite::cspNonce()) ? ' nonce="'.e($nonce).'"' : '' !!}>
(function () {
try {
var stored = localStorage.getItem('wsu-theme')
var dark = stored === 'wsu-dark' || (stored !== 'wsu' &&
matchMedia('(prefers-color-scheme: dark)').matches)
var root = document.documentElement
root.setAttribute('data-theme', dark ? 'wsu-dark' : 'wsu')
root.classList.add(dark ? 'dark' : 'light')
root.style.colorScheme = dark ? 'dark' : 'light'
} catch (e) {}
})()
</script>It cannot live in app.ts. A module script is deferred, so by the time the bundle runs the page has already painted light and anyone who chose dark watches it flip.
The stored choice is checked first; prefers-color-scheme is only read when wsu-theme has nothing usable in it, including when it holds a value the script does not recognize. Theming has the full resolution order.
Content-Security-Policy has to let that script run
resources/views/app.blade.php is yours, not this package's, so nothing here guards it for you. Send a script-src with no 'unsafe-inline', which is the point of sending a policy at all, and the inline <script> above is blocked exactly like anyone else's: a securitypolicyviolation on every load, and the flash the script exists to prevent is back, because the DOM is only right once the rest of the page has already painted.
Do not reach for 'unsafe-inline'. It allows every inline script on the page, not just this one, which is the protection the policy exists to provide. Vite::cspNonce(), already on the tag above, is null until something calls Vite::useCspNonce(), so mint one in a middleware, put the same value on the header, and the tag above picks it up on its own:
// app/Http/Middleware/SetContentSecurityPolicy.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class SetContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
// One value per response, held by the facade for the rest of the
// request. Calling this more than once, or hardcoding a value, is not
// a nonce and protects nothing 'unsafe-inline' would not.
$nonce = Vite::useCspNonce();
$response = $next($request);
$response->headers->set('Content-Security-Policy',
"default-src 'self'; "
."style-src 'self' https://wayneui.apps.wayne.edu; "
."style-src-elem 'self' 'unsafe-inline' https://wayneui.apps.wayne.edu; "
."script-src 'self' 'nonce-{$nonce}' https://wayneui.apps.wayne.edu; "
."font-src 'self' https://wayneui.apps.wayne.edu; "
."img-src 'self' data: https://wayneui.apps.wayne.edu; "
."connect-src 'self' https://wayneui.apps.wayne.edu;"
);
return $response;
}
}// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\SetContentSecurityPolicy::class);
})@vite(), further down in the same file, reads the same Vite::cspNonce() and nonces its own tags without being asked. Nothing else needs it: every other script this application loads has a src, and is matched on script-src by host, not by nonce.
style-src-elem is on this recipe for a reason specific to Inertia rather than to WayneUI: Inertia's own client bundles a progress bar for page transitions and writes its CSS with a <style> element it injects into <head> itself, on every page, whether a transition has happened yet or not. 'self' alone does not cover an element with no src to match against a host, the same reason the theme script above needs a nonce rather than being left to script-src's host list; 'unsafe-inline' here is narrower than it looks, because style-src-elem governs <style> elements and <link rel="stylesheet"> only, never the style attribute, which is why 'self' still has to appear on this line too: declaring the directive at all replaces the fallback to style-src rather than adding to it, and the published stylesheet <link> is itself an element this directive now governs.
Notably absent from this recipe is style-src-attr, which both the static HTML and raw PHP paths need for an inline style="..." attribute, and the Blade path needs for the same reason. Vue compiles a template's :style binding, the pattern Registration.vue and Catalog.vue both use for the same spacing registration.blade.php writes as a literal attribute, into calls against the element's CSSStyleDeclaration, el.style.setProperty(...) rather than el.setAttribute('style', ...), and a browser's style-src-attr check governs the style attribute, not that interface. Measured rather than assumed: the same recipe with style-src-attr left off it entirely still resolves margin-block-start: var(--wsu-space-2) on a Vue-rendered element to a real 8px, where the identical markup written as a literal attribute on the Blade path measures 0px under a policy missing that directive. This is not a loophole worth relying on to skip the directive elsewhere: it holds only because Vue never writes the style attribute itself for a :style binding, and a future Vue release, or any code on this page that calls el.setAttribute('style', ...) directly, would need it added back.
Running behind Laravel Octane? Add Vite::class to flush in config/octane.php, the same caveat the Blade guide notes: an Octane worker survives past the response, so a request that never called Vite::useCspNonce() would otherwise inherit the previous request's nonce and header.
4. Share the chrome's data
Add the middleware alias to the web group, or to the routes that render Inertia pages:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\WSU\WayneUi\Http\Middleware\HandleWayneUi::class,
]);
})It is registered as an alias rather than pushed onto web automatically, because a package that silently rewrites every response's shared props is hard to debug from the application's side.
That gives every page one prop, wayneUi, with five keys under it:
const { wayneUi } = usePage().props
wayneUi.app // { name, title, heading, subheading, home, template }
wayneUi.user // { label, name, email } or null
wayneUi.breadcrumbs // [{ label, url, icon, current }]
wayneUi.nav // { primary: [...], sidebar: [...], sidebarEnabled: bool }
wayneUi.environment // { name, production, ribbon, label }sidebarEnabled is the one that is easy to read past. config('wayne-ui.sidebar.enabled') is what Blade's app shell reads to decide whether the sidebar and its drawer exist at all, and an Inertia layout that ignores it renders a sidebar the application has switched off. Measured against the layout above before the v-if was added: an empty 272 by 756 pixel column, identical whether the config value was true or false. That is the defect the prop exists to prevent, in the guide's own reference layout, which is a good reason to bind it rather than to describe it.
Everything is nested under one key rather than scattered at the top level, so your own auth or flash prop cannot be clobbered. The key is configurable:
'inertia' => [
'enabled' => true,
'namespace' => 'wayneUi',
'share' => [
'app' => true, 'user' => true, 'breadcrumbs' => true,
'nav' => true, 'environment' => true,
],
],Turn off anything your application shares itself, so a prop is never written from two places.
wayneUi.user is a label, a name and an email, not the user model. A shared prop is serialised into the HTML of every page, and handing the Vue app the whole record publishes every column on it in the page source, including anything the application adds later.
Each value is a closure, resolved when the response renders. That matters for breadcrumbs: a controller pushes crumbs after the middleware has run, and a closure sees them.
5. A layout
Name it WsuLayout.vue, and put it in the scaffold's existing lower case resources/js/layouts/. This guide used to say resources/js/Layouts/AppLayout.vue, which is a trap on macOS and Windows: laravel new --vue already ships resources/js/layouts/AppLayout.vue, and on a case-insensitive filesystem those are the same file. Following the old instruction overwrote the starter's own layout, which its Dashboard.vue and three settings pages import, with no warning and no error until one of those pages was opened.
<!-- resources/js/layouts/WsuLayout.vue -->
<script setup>
import { computed } from 'vue'
import { Link, usePage } from '@inertiajs/vue3'
import {
AppShell, AppBar, Sidebar, SidebarNav, SkipLink,
Breadcrumbs, PageHeader, Footer, EnvRibbon, Toasts,
} from '@waynestate/wayne-ui-vue'
const wsu = computed(() => usePage().props.wayneUi)
</script>
<template>
<SkipLink target="main" />
<AppShell main-id="main">
<template #ribbon>
<EnvRibbon :environment="wsu.environment.name" :label="wsu.environment.label" />
</template>
<template #bar>
<AppBar
:title="wsu.app.name"
:title-href="wsu.app.home"
mark="/vendor/wayne-ui/marks/wsu-shield.svg"
:nav="wsu.nav.primary"
:user="wsu.user?.label ?? ''"
search
search-action="/search"
/>
</template>
<template v-if="wsu.nav.sidebarEnabled" #sidebar>
<Sidebar label="Sections">
<SidebarNav :items="wsu.nav.sidebar" :link-as="Link" />
</Sidebar>
</template>
<Breadcrumbs :items="wsu.breadcrumbs" :link-as="Link" />
<PageHeader :title="wsu.app.heading" :subtitle="wsu.app.subheading">
<template v-if="$slots.actions" #actions><slot name="actions" /></template>
</PageHeader>
<slot />
<template #footer>
<Footer :links="[
{ label: 'wayne.edu', href: 'https://wayne.edu/' },
{ label: 'Help Desk', href: 'https://tech.wayne.edu/' },
{ label: 'Accessibility', href: 'https://wayne.edu/accessibility/' },
{ label: 'Privacy and University Policies', href: 'https://wayne.edu/policies' },
]" />
</template>
</AppShell>
<Toasts label="Notifications" />
</template>link-as is how the navigation components use Inertia's Link instead of a full page load. Breadcrumbs, SidebarNav and Pagination all take it.
AppShell calls useChromeRuntime(), which wires the same wayne-ui-theme.js the static and Blade paths use. The drawer, the theme toggle and the search shortcut behave identically on all four paths because they are literally the same code, not a Vue reimplementation of it.
6. A page
<script setup>
import { router } from '@inertiajs/vue3'
import { Badge, Card, DataTable, Input, Textarea, Button, ErrorSummary }
from '@waynestate/wayne-ui-vue'
import WsuLayout from '@/layouts/WsuLayout.vue'
defineProps({ courses: Array })
const form = useForm({ course: '', reason: '' })
</script>
<template>
<WsuLayout>
<template #actions>
<Button variant="primary" as="a" href="/courses/new">Add course</Button>
</template>
<DataTable
caption="Available courses, Winter 2027"
:columns="[
{ key: 'number', label: 'Course', sortable: true },
{ key: 'title', label: 'Title' },
{ key: 'status', label: 'Status' },
]"
:rows="courses"
>
<template #cell-status="{ value }">
<Badge :variant="value === 'Open' ? 'success' : 'danger'">{{ value }}</Badge>
</template>
</DataTable>
<form @submit.prevent="form.post('/overrides')">
<ErrorSummary :errors="form.errors" />
<Card title="Request an override">
<Input v-model="form.course" name="course" label="Course number"
:error="form.errors.course" required autocomplete="off" />
<Textarea v-model="form.reason" name="reason" label="Reason"
:error="form.errors.reason" required />
<template #footer>
<Button variant="primary" type="submit" :loading="form.processing">
Submit request
</Button>
</template>
</Card>
</form>
</WsuLayout>
</template>Validation errors without passing them one by one
provideFormErrors puts the whole error object in context and every field picks up its own:
<script setup>
import { provideFormErrors } from '@waynestate/wayne-ui-vue'
import { usePage } from '@inertiajs/vue3'
import { computed } from 'vue'
provideFormErrors(computed(() => usePage().props.errors))
</script>After that, <Input name="course" label="Course number" /> finds errors.course on its own, sets aria-invalid, renders the message and wires aria-describedby.
ErrorSummary takes focus when it appears. That is what gets the errors to a screen reader user who has just submitted, without an assertive live region interrupting them mid-sentence.
7. Toasts
import { pushToast } from '@waynestate/wayne-ui-vue'
router.post('/courses', data, {
onSuccess: () => pushToast({
variant: 'success',
title: 'Course added',
message: 'CSC 2110 is on your schedule.',
}),
})The container pauses its timers on hover and on focus, because a message that disappears while someone is reading it or tabbing to its dismiss button fails WCAG 2.2.1. Do not put anything the user has to act on in a toast. If it matters, it belongs on the page.
8. A Vue application with no Laravel
The Vue package does not require Inertia or Laravel. A plain Vite single-page application uses the same components and supplies the data itself. See the Vue guide for that path from an empty directory. There is also a complete one in this repository:
pnpm --filter @waynestate/wayne-ui-example-vue-spa devapps/examples/vue-spa has both templates as two routes, and its index.html shows where the pre-paint script goes when there is no Blade view.
Check it before you ship it
- Navigate between pages with the keyboard. Focus should land in
<main>after an Inertia visit, not stay on the link you clicked. - Submit a form with everything empty. The summary takes focus and links to each field.
- Narrow to 320px, open the drawer, and Tab. Focus must stay inside it, Escape must close it, and focus must return to the button that opened it.
- Read a page in dark.
Next
- Components for every component and its props
- Theming for shadcn-vue and the registry at
/r - Accessibility for what the gates check