blatui
Livewire

Use BlatUI with Livewire

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.

Nothing extra to install

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).

Terminal
composer require livewire/livewire

Bind with 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.

app/Livewire/ProfileForm.php
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');
    }
}
resources/views/livewire/profile-form.blade.php
<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>

Live updates & modifiers

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.

Blade
<x-ui.input wire:model="search" />


<x-ui.input wire:model.live="search" />


<x-ui.input wire:model.live.debounce.400ms="search" />

Validation

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.

Blade
<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>

Numbers & typed properties

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.

app/Livewire/Forms/VariantForm.php
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;
}
Blade
<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.

Work in the browser, decide on the server

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.

Blade
<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.

resources/js/product-form.js
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, '');
resources/js/app.js
import { slug, sku } from './product-form';

document.addEventListener('alpine:init', () => {
    window.Alpine.magic('slug', () => slug);
    window.Alpine.magic('sku', () => sku);
});
Blade
<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.

Loading states

Because the components are real DOM, Livewire's wire:loading and wire:target work directly on them.

Blade
<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>

File uploads

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.

Blade
<x-ui.file-upload wire:model="avatar" accept="image/*" />

What's bindable

wire:model is supported on every value-bearing component:

input textarea select combobox autocomplete checkbox radio-group switch toggle toggle-group segmented-control slider rating knob number-input color-picker date-picker datetime-picker time-field input-otp tags-input phone-input input-mask editable mention-input markdown-editor rich-text-editor signature-pad file-upload

Range modes bind too, to the same shape the component's value prop already takes — so :value="$x" wire:model="x" round-trips:

Blade
<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 vs. Flux Pro

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.

153 components

you own the code

Free · MIT

no per-project license

Livewire + Blade

works with or without Livewire

Get started Browse components