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.
Every BlatUI form control is Livewire-ready. Bind wire:model to
any of them — inputs, selects, checkboxes, switches, sliders, rich text, date & file pickers —
and get real two-way binding. No wrappers, no second library, and you still own every line.
BlatUI components are plain Blade + Alpine, so they already render inside Livewire components.
Install Livewire as usual, then use BlatUI tags in any Livewire view — the
wire:model bridge activates automatically when Livewire is present and stays
completely inert when it isn't (your non-Livewire pages are unaffected).
composer require livewire/livewire
wire:model
Drop wire:model onto any form control exactly like a native input. Native fields
(input, textarea, native select) bind directly; the rich
Alpine widgets (custom select, combobox, switch, slider, rating, color/date/time
pickers, tags, OTP, knob…) read and write your Livewire property itself — they
keep no second copy of the value, so server and client cannot disagree.
They deliberately do not use @entangle, which Livewire 4
deprecates: it duplicates the value, bakes the component id into an x-data that Alpine
only ever evaluates once, and leaks its sync when the element is removed. The bound property path
travels as a data-blat-model attribute instead, so a re-render that re-points or
re-mounts the component is followed rather than missed.
use Livewire\Component;
class ProfileForm extends Component
{
public string $name = '';
public string $plan = 'pro';
public bool $notify = true;
public array $tags = [];
public int $volume = 30;
public function render()
{
return view('livewire.profile-form');
}
}
<form wire:submit="save" class="space-y-4">
<x-ui.input wire:model="name" placeholder="Full name" />
<x-ui.select wire:model="plan" :options="['free' => 'Free', 'pro' => 'Pro']" />
<x-ui.switch wire:model="notify" />
<x-ui.tags-input wire:model="tags" />
<x-ui.slider wire:model="volume" />
<x-ui.button type="submit">Save</x-ui.button>
</form>
All the usual wire:model modifiers work. By default binding is deferred (synced on
the next request); add .live for real-time updates, or .blur /
.debounce as needed.
<x-ui.input wire:model="search" />
<x-ui.input wire:model.live="search" />
<x-ui.input wire:model.live.debounce.400ms="search" />
BlatUI fields already render aria-invalid styling. Pair Livewire's
@error with the aria-invalid attribute and the
built-in field components to surface messages.
<x-ui.field>
<x-ui.field-label for="email">Email</x-ui.field-label>
<x-ui.input id="email" type="email" wire:model="email"
aria-invalid="{{ $errors->has('email') ? 'true' : 'false' }}" />
@error('email')
<x-ui.field-error>{{ $message }}</x-ui.field-error>
@enderror
</x-ui.field>
number-input only ever sends a number. While the field has focus, what's in it is a
draft: typing over a price passes through an empty field, and that is not reported as a value.
On blur the field settles. An emptied field becomes null, so it has to be bound to a
nullable property, the same as a native <input wire:model>. Livewire leaves a
non-nullable typed property uninitialized when it receives null.
So pick one of two shapes. If the field may be left empty and a rule should reject it, make the
property nullable. If it must always hold a number, keep it non-nullable and pass
:nullable="false": an emptied field goes back to the value it held.
decimals fixes what the field shows (1.90, not 1.9) and rounds
what it sends. The property stays a number, and trailing zeroes are presentation only. A comma
is accepted as the decimal separator.
use Livewire\Attributes\Validate;
use Livewire\Form;
class VariantForm extends Form
{
// May be emptied; `required` is what rejects it.
#[Validate('required|numeric|min:0')]
public ?float $listPrice = null;
// Never empty: the field reverts instead of sending null.
#[Validate('numeric|min:0')]
public float $salePrice = 0.0;
}
<x-ui.number-input wire:model="variantForm.listPrice" :min="0" :decimals="2" />
<x-ui.number-input wire:model.live="variantForm.salePrice"
:min="0" :step="0.1" :decimals="2" :nullable="false" />
For money you total on the server, store integer cents or a decimal column, and
round there as well. decimals keeps the browser from sending
25.499999999999996, but the server should still own the arithmetic.
A bound component writes into Livewire's copy of your data as the user edits, including under a
deferred wire:model. $wire is reactive, so anything derived from it
updates in the browser immediately and sends no request of its own. Don't mirror the values into
Alpine state with watchers. Read $wire directly.
<x-ui.number-input wire:model="variantForm.lastPurchaseCost" :decimals="2" :nullable="false" />
<x-ui.number-input wire:model="variantForm.salePrice" :decimals="2" :nullable="false" />
<p x-data>
Profit:
<span x-text="((+$wire.variantForm.salePrice || 0) - (+$wire.variantForm.lastPurchaseCost || 0)).toFixed(2)"></span>
</p>
Write the same way. $wire.$set('variantForm.sku', value, false) changes the property
without a request, and the value goes with the next one, so a "Generate" button can fill a field
with no round trip. Put helpers you reuse, like slugs or SKUs, in an ES module and register them
once where you register BlatUI. That's an Alpine.magic for a function you call from
templates, or Alpine.data for a component with state. Avoid hanging them on
window, where nothing says who owns the name or when it was defined.
export const slug = (value) => String(value ?? '')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
export const sku = (value) => String(value ?? '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
import { slug, sku } from './product-form';
document.addEventListener('alpine:init', () => {
window.Alpine.magic('slug', () => slug);
window.Alpine.magic('sku', () => sku);
});
<x-ui.input wire:model="variantForm.name"
x-on:input="$wire.$set('variantForm.slug', $slug($event.target.value), false)" />
<x-ui.input-group-button type="button"
x-on:click="$wire.$set('variantForm.sku', $sku($wire.variantForm.name), false)">
Generate SKU
</x-ui.input-group-button>
None of this makes the browser an authority. What it computes is a suggestion that arrives with
the next request. Uniqueness (Rule::unique() scoped to the tenant), authorization,
ownership and every rule still run in save(). Validate derived values like a slug or
SKU as untrusted input, the same as anything else the user sent.
Because the components are real DOM, Livewire's wire:loading and
wire:target work directly on them.
<x-ui.button type="submit">
<span wire:loading.remove wire:target="save">Save</span>
<span wire:loading wire:target="save" class="inline-flex items-center gap-2">
<x-ui.spinner class="size-4" /> Saving…
</span>
</x-ui.button>
The file-upload component forwards wire:model onto its real
<input type="file">, so Livewire's
temporary file uploads
work out of the box (use the WithFileUploads trait). Dropped files go through the
same input, so drag-and-drop uploads too.
The per-file progress bar is Livewire's own upload progress — it appears only when there is an
upload behind it, tracks the bytes actually sent, and turns into an error message if the upload
fails. Without a wire:model the component is a plain file field: it lists what you
picked and draws no bar, because nothing is being uploaded. Removing a row withdraws the
temporary upload server-side, not just the row.
<x-ui.file-upload wire:model="avatar" accept="image/*" />
wire:model is supported on every value-bearing component:
Range modes bind too, to the same shape the component's value prop already takes —
so :value="$x" wire:model="x" round-trips:
<x-ui.date-picker mode="range" wire:model="stay" :value="$stay" />
<x-ui.datetime-picker mode="range" wire:model="slot" :value="$slot" />
<x-ui.slider range wire:model="price" :value="$price" />
A range reports what the user has actually picked, so an in-progress one arrives with
to still null — check it before filtering on the pair. The
name[...] form fields keep working alongside, unchanged. Dragging a
.live slider sends one request when the drag ends, not one per pointer move.
BlatUI ships everything Flux Pro charges for — calendar, charts, command palette, date & time pickers, editor, listbox/combobox, file upload, kanban and more — plus 60+ extra components, blocks and charts. The difference: BlatUI is MIT-licensed and free, and the components are copied into your project, so you own and can edit every line.
you own the code
no per-project license
works with or without Livewire