luvi-ui/laravel-luvi
Laravel Luvi provides a polished set of Blade UI components and layouts for Laravel, making it easy to build responsive dashboards, admin panels, and web interfaces with a consistent, modern design and minimal setup.
Prerequisites
Where to look first
resources/views/components (or stubs/) directory. Read one component end-to-end before installing anything — you're evaluating the conventions, not the feature list. You want to see:
@props([...]) defaults and how variants are resolved (array map vs. match),$attributes->merge(['class' => ...]) usage,$id, uniqid()) or hard-coded.--background, --primary, --ring, --radius). If this doesn't land in your app.css, everything renders with transparent borders and no focus rings and you'll spend an hour blaming Tailwind.Minimal setup
composer require luvi-ui/laravel-luvi # often --dev is enough; it's mainly the artisan command + stubs
php artisan luvi:init # publishes theme tokens + base components (check docs for exact name)
Then verify your Tailwind entrypoint actually contains the token block:
/* resources/css/app.css */
@import 'tailwindcss';
@import './luvi.css'; /* the published theme variables */
@source '../../resources/views/components/ui/**/*.blade.php';
First use case: a modal or a dropdown. Pick one deliberately, because it exercises the whole pipeline at once — Tailwind tokens (does the backdrop render?), Alpine (does x-show/x-transition fire?), and, if you use Livewire, state ownership. If the modal opens with a grey-but-unstyled panel, your CSS variables didn't land. If it doesn't open at all, Alpine didn't boot. Two very different failures, one test page.
Render it:
<x-ui.modal name="confirm-delete">
<x-slot:title>Delete project?</x-slot:title>
This cannot be undone.
</x-ui.modal>
If that works, you've validated theme + Alpine + Blade component resolution. Everything after that is repetition.
Treat the repo as a source, not a dependency. After scaffolding, the pragmatic move is to keep luvi-ui/laravel-luvi in require-dev (so php artisan luvi:add button still works) but consider the copied files your code. Put a header comment in each file recording the upstream release you copied from:
{{-- luvi-ui v1.4.2 — customized 2025-04-02 --}}
That single line is the difference between a painful upgrade and a five-minute diff six months from now.
Keep ui. pristine, wrap above it. Copy components into resources/views/components/ui/. Never edit them for app-specific behaviour; create a wrapper in components/:
{{-- resources/views/components/forms/submit.blade.php --}}
<x-ui.button type="submit" {{ $attributes->merge(['variant' => 'primary']) }}>
{{ $slot }}
</x-ui.button>
When upstream changes ui.button, you re-copy and your app-wide button still behaves. If you'd forked the internals for every page, you wouldn't.
Extend variants through the map, not through if. The components resolve variants from an array (shadcn-style). Add a key rather than branching:
@props(['variant' => 'default'])
@php
$variants = [
'default' => 'bg-primary text-primary-foreground',
'danger' => 'bg-destructive text-white',
'ghost' => 'hover:bg-muted',
];
@endphp
<button {{ $attributes->merge(['class' => $variants[$variant] ?? $variants['default']]) }}>
Compose with slots, not props. Headers, footers, triggers:
<x-ui.dropdown>
<x-slot:trigger>
<x-ui.button variant="ghost">Options</x-ui.button>
</x-slot:trigger>
<x-ui.dropdown-item wire:click="archive">Archive</x-ui.dropdown-item>
</x-ui.dropdown>
Slots keep the markup flexible without a prop explosion, and wire:click on a slotted item works because the element ends up inside the Livewire component's DOM.
Pick one owner per piece of state. The most common integration bug is dual ownership: Alpine holds open, Livewire holds open, and they drift after a re-render. Choose:
x-data. No Livewire involvement. Fastest, works everywhere.@entangle or read with $wire.<div x-data="{ open: @entangle('showPanel') }">
Don't mix x-model and wire:model on the same input — one of them will silently win depending on render order and you'll debug a "the value resets" bug for an afternoon.
Bridge forms with a field wrapper. Validation markup is the repetitive part; wrap it once instead of repeating @error blocks in every component:
<x-ui.field name="email" label="Email">
<x-ui.input wire:model.blur="email" />
</x-ui.field>
The field reads @error($name) internally and renders aria-invalid plus the message. Do this on day one; it's the highest-ROI wrapper in the whole set.
Use wire:key on every list item. Tables and lists built from @foreach ($items as $item) must carry wire:key="{{ $item->id }}" on the root element of each row, otherwise Livewire morphs nodes in place and open dropdowns / focused inputs jump to the neighbouring row.
Testing. Interactive behaviour needs a browser. Pest + Livewire's assertSee covers markup and server round-trips; Dusk/Playwright covers the Alpine bits. For the latter, ->waitFor('[role=dialog]') rather than a fixed pause() — Alpine transitions are the flakiest thing in the stack.
No upstream updates, by design. composer update will not patch copied files. This is the trade you make for full control. Budget a periodic manual diff against the repo when you bump the dev dependency — and note the version in the file header so you know what you're diffing against.
Re-running the add command overwrites. Commit before scaffolding any new component, then git diff. Anything you'd customised in that file is gone; the diff is how you notice.
Tailwind isn't scanning the new path. v4 uses @source, v3 uses content. If components render completely unstyled while the rest of the app is fine, this is almost always it — especially if you copied files somewhere other than the default resources/views/components.
Missing design tokens look like broken components. Transparency, no focus ring, square corners: check that the published theme block made it into app.css and that --ring, --border, --radius are actually defined for both light and dark.
Dark mode needs a strategy declaration. darkMode: 'class' in v3, @custom-variant dark (&:where(.dark, .dark *)); in v4. Without it every dark: class in the components is dead weight and dark mode silently does nothing.
Don't install Alpine twice. Livewire 3 ships Alpine. If you also import Alpine from 'alpinejs' in app.js, you get two instances and components initialise erratically — some work, some don't, and the console looks clean. Register plugins against Livewire's instance before it boots:
import { Livewire, Alpine } from '../../vendor/livewire/livewire/dist/livewire.esm';
import collapse from '@alpinejs/collapse';
Alpine.plugin(collapse);
Livewire.start();
If x-collapse or x-trap is "not defined" in the console while other directives work, the plugin was registered after boot.
Hard-coded IDs break in loops. aria-controls="dropdown-menu" is fine once, catastrophic in a @foreach. Prefer x-ref and Alpine's $refs (scoped to the component root) over global IDs, or generate with $id / uniqid().
Alpine state is lost on Livewire re-render. Livewire morphs the DOM; x-data on a morphed node resets. Fixes, in order of preference: wire:key on the element, x-preserve for third-party widgets you must not touch, and keeping transient UI state out of Livewire entirely.
Dropdowns get clipped inside overflow-hidden containers (cards, table wrappers, sidebars). Options: x-teleport="body" plus fixed positioning, or move the trigger out of the clipping ancestor. This one bites in tables every time.
$attributes->merge() only merges class and style. Every other attribute is last-wins. When you customize a component, make sure you're still forwarding {{ $attributes }} on the root element — dropping it silently kills wire:model, name, disabled, and every data-* your tests rely on.
Keep the a11y attributes when restyling. role="dialog", aria-expanded, aria-controls, and the @keydown.escape handlers are the behaviour. Deleting "noise" to clean up the markup is how modals stop closing on Escape and dropdowns stop being keyboard-navigable.
Debugging order when an interactive component "does nothing":
Alpine is not defined / directive errors — usually a JS load-order problem, not the component.x-data actually present? If Tailwind's build or Blade's attribute forwarding dropped it, the element is inert.resources/views folder. That is the entire point of the copy-paste model, and it's why the smaller ecosystem (fewer blog posts, no StackOverflow trail) costs less than it looks: you own the source, you can read the source.How can I help you explore Laravel packages today?