Tune it live. Every preset is pure CSS variables.
"Default" follows the body font.
Copy your complete theme and paste it as your resources/css/app.css — the Tailwind import, every token and the @theme mapping are all included, so it works as-is.
Add BlatUI to any Laravel app in a few minutes. You install the foundations once, then pull in components on demand — and you own every line.
Install, publish the foundations, wire two entrypoints, then start building.
composer require anousss007/blatui gehrisandro/tailwind-merge-laravel mallardduck/blade-lucide-icons
npm install -D alpinejs @alpinejs/anchor @alpinejs/collapse @alpinejs/focus
php artisan vendor:publish --tag=blatui-foundations
Point your two Vite entrypoints at the published foundations (replace each file's contents):
@import "./blatui.css";
import "./blatui.js";
Verify, then add your first components:
php artisan blatui:init
php artisan blatui:add button card input
Charts are opt-in. ApexCharts (~140kb) isn't installed by default — only when you add a chart:
php artisan blatui:add chart
npm install -D apexcharts
php artisan vendor:publish --tag=blatui-charts
Then wire it in app.js (swap the one-line bootstrap for the explicit form):
import Alpine from 'alpinejs';
import { registerBlatUI } from './blatui-core.js';
import { registerCharts } from './blatui-charts.js';
registerBlatUI(Alpine);
registerCharts(Alpine);
window.Alpine = Alpine;
Alpine.start();
Pull BlatUI in via Composer. It ships the Artisan commands and the component registry.
composer require anousss007/blatui
Two Composer packages power the components — twMerge() (the cn() equivalent) and Lucide icons:
composer require gehrisandro/tailwind-merge-laravel mallardduck/blade-lucide-icons
Then the front-end packages — Alpine, its plugins, and ApexCharts (for the charts):
npm install -D alpinejs @alpinejs/anchor @alpinejs/collapse @alpinejs/focus apexcharts
Publish the theme CSS and the Alpine/chart/calendar engine. They land in resources/css/blatui.css and resources/js/blatui.js — and they're yours to edit.
php artisan vendor:publish --tag=blatui-foundations
Then point your two Vite entrypoints at them — replace the contents of each file:
@import "./blatui.css";
import "./blatui.js";
blatui.css bundles Tailwind, the design tokens and the @theme mapping; blatui.js boots Alpine + its plugins and lazy-loads ApexCharts. Want a custom palette? Open Customize (top-right), tune it, and hit Copy theme CSS for a complete, ready-to-paste app.css.
Run the doctor. It checks every package, the theme tokens and the Alpine bootstrap — and that the foundations are actually imported — then tells you exactly what's missing.
php artisan blatui:init
Copy components — and their dependencies — straight into resources/views/components/ui. They're yours now: edit freely.
php artisan blatui:add button card input
# browse everything that's available
php artisan blatui:list
Every component is a Blade tag under the ui namespace. Compose away:
<x-ui.card class="max-w-sm">
<x-ui.card-header>
<x-ui.card-title>Welcome back</x-ui.card-title>
<x-ui.card-description>Sign in to continue.</x-ui.card-description>
</x-ui.card-header>
<x-ui.card-content class="space-y-3">
<x-ui.input type="email" placeholder="[email protected]" />
<x-ui.button class="w-full">Sign in</x-ui.button>
</x-ui.card-content>
</x-ui.card>
Renders
Already have a Laravel app with Tailwind set up? Everything is additive — you don't replace your files.
@theme inline, oklch tokens). If you're still on Tailwind v3, migrate first:
npx @tailwindcss/upgrade
CSS — add, don't replace
Append the import to your existing app.css (below your @import "tailwindcss"). The foundations layer on top of your own styles.
@import "tailwindcss";
/* …your existing styles… */
@import "./blatui.css";
JS — already running Alpine?
Don't import blatui.js (it would boot a second Alpine). Register BlatUI into your Alpine instance instead, before you start it:
import Alpine from 'alpinejs'
import { registerBlatUI } from './blatui-core.js'
registerBlatUI(Alpine) // plugins + theme store + chart/calendar engines
window.Alpine = Alpine
Alpine.start()
JS — using Livewire or Flux?
Livewire bundles and starts Alpine for you, so don't npm install alpinejs (that's the duplicate-Alpine trap) and don't import blatui.js. Install only the plugins — npm install -D @alpinejs/anchor @alpinejs/collapse @alpinejs/focus (add apexcharts only if you use charts) — and register BlatUI onto Livewire's Alpine via the alpine:init hook:
import { registerBlatUI } from './blatui-core.js'
document.addEventListener('alpine:init', () => {
registerBlatUI(window.Alpine)
})
BlatUI registers a theme store for dark mode. If Flux already drives dark mode, pass registerBlatUI(window.Alpine, { darkMode: false }) so the two don't both toggle .dark.
You own the copied files, so nothing updates behind your back. blatui:update compares what you have against the version this release ships, shows the diff, and lets you decide — file by file.
Command "blatui:update" is not defined) — run composer update anousss007/blatui first.
See what changed first
A dry run writes nothing. Add --diff for the full unified diff of every file that differs.
php artisan blatui:update --dry-run --diff
Then update
Without arguments it walks every installed family; pass names to scope it. Each differing file is confirmed individually.
php artisan blatui:update
# or just these, without prompting
php artisan blatui:update button card --force
Running a formatter over your views?
Pint or Prettier over resources/views rewrites the layout of every component you copied, so all of them read as changed. They're counted separately as same content, reformatted, and this leaves them out:
php artisan blatui:update --dry-run --ignore-whitespace
--force keeps your version next to it as .bak (opt out with --no-backup). Files a family gained in a newer release are simply added.
php artisan vendor:publish --tag=blatui-foundations --force. blatui:init tells you when the engine has fallen behind what your components expect.
Every string a component shows or announces (button text, aria-labels, placeholders, empty states, screen-reader text) goes through Laravel’s __(), with the English text as the key. Translate them in a JSON file per locale. Strings that carry a value use a placeholder, so the value can move.
{
"Search...": "Buscar...",
"No results.": "Sin resultados.",
"Mark all read": "Marcar todo como leído",
"You're all caught up": "Todo al día",
"Remove :name": "Quitar :name",
":selected of :total row(s) selected.": ":selected de :total fila(s) seleccionadas."
}
A key with no translation falls back to the English, so you can translate as you go. Any label you pass as a prop (placeholder, empty-text, label…) is used as given.
A screen built from BlatUI renders hundreds of anonymous components, and on a busy page Blade's per-component overhead is most of the render time. Two changes cut it down without touching a component.
Compile the components with Blaze
Blaze compiles anonymous components into plain PHP functions. It works with or without Livewire. Every component in components/ui renders the same HTML under Blaze as under Blade, and the Livewire morph suite passes with it on. Rendering all of the docs examples takes about half as long.
composer require livewire/blaze:^1.0
use Livewire\Blaze\Blaze;
public function boot(): void
{
Blaze::optimize()->in(resource_path('views/components/ui'));
}
Then run php artisan view:clear. Use the default compiler only: the fold and memo strategies bake a component's output in, and many BlatUI components read state at render time.
<x-ui.card>), never with @include('components.ui.…') or view(): a file Blaze compiled renders nothing that way. When you edit your copies, keep to Blaze's limitations. Shared view data such as $errors is not injected into a Blaze component, so pass it in as a prop.
Memoize the tailwind-merge cache
Every $attributes->twMerge() looks its result up in your default cache store. On Redis that is a network round trip per component, over a thousand on a large page. A memoized store (Laravel 12.9+) answers repeats from memory for the rest of the request:
use Illuminate\Support\Facades\Cache;
use TailwindMerge\Contracts\TailwindMergeContract;
use TailwindMerge\TailwindMerge;
public function register(): void
{
$this->app->singleton(TailwindMergeContract::class, fn () => TailwindMerge::factory()
->withConfiguration(config('tailwind-merge', []))
->withCache(Cache::memo())
->make());
}
Building a reusable form-field DX layer (a wrapper that re-emits {{ $slot }})? Reach for the foundations utilities on raw elements rather than nesting <x-ui.*> form controls inside that slot.
Footgun: <x-ui.*> as slot content of an @aware anonymous component may not compile.
When a <x-ui.input/.textarea/.select> is passed as the slot content of an anonymous wrapper that re-wraps the slot through another anonymous component using @aware, the inner tag is left literal in the output. A content component leaves an orphan @endif → ParseError (500); a self-closing one renders an unknown <x-ui.input> element that produces no <input> at all — the field is silently absent.
⚠️ This passes every render test. A GET → assertOk() still returns 200, and POST-based feature tests still pass — only a browser test that actually fills the field catches it. (Direct, un-wrapped use compiles fine; the bug only appears through the @aware slot layer.)
The fix: in any DX layer that re-wraps a slot, render raw elements styled by the foundations utilities — zero <x-ui.*> in the slot, and it's just as “native”. The utilities are the twin of the components' class-strings, shipped in blatui.css:
.blat-input
.blat-textarea
.blat-select
.blat-checkbox
.blat-radio
.blat-label
<label class="blat-label">{{ $label }}</label>
<input name="{{ $name }}" class="blat-input" />
<textarea name="{{ $name }}" class="blat-textarea"></textarea>
<select name="{{ $name }}" class="blat-select">...</select>
php artisan blatui:doctor flags this for you — it scans your compiled views for any literal <x-ui.*> tag that leaked into the HTML (a tag that failed to compile).