Start Here
Theming & Customization
Ten levels of customization, from scoping a theme to the admin to publishing a view, and what each one costs you at upgrade time.
On this page
- Scope
- Colors
- Semantic Roles
- Shape
- It is two rules, and the second is the interesting one
- No switch, on purpose
- Density
- It is four changes, not one
- Letting people choose
- Styling Hooks
- Finding a name
- The anchors worth knowing
- What the name promises
- Render Hooks
- The positions
- What a callback may return
- Ordering and scope
- Icons
- Per-Component Tweaks
- Overriding Views
- Localization
- See Also
Wire ships unstyled-but-sensible Tailwind markup with full dark-mode variants. You customize the look at ten levels, from lightest to heaviest:
| Level | Reach | Effort |
|---|---|---|
| Scope | Whether a theme touches your public site too | A second stylesheet |
| Colors | Accent and neutral palette everywhere | Tailwind config |
| Semantic roles | What success/danger/warning/info render as |
wire-core config |
| Shape | Rounded corners, or square ones | wire-core config |
| Density | How much room the interface gives itself | wire-core config, or a switch |
| Styling hooks | One kind of element, anywhere it appears | Your own CSS |
| Render hooks | Adding something that is not there | A closure returning a view |
| Icons | Swap or add icons globally | wire-core config |
| Per-component | One field/column/action | Fluent API |
| Override views | Any component's markup | Publish + edit Blade |
Scope
Decide this before anything else, because it changes where every other level lands. An admin panel usually wants to look different from the public site in front of it — and the way to say so is which stylesheet the admin loads, not a wrapper class or a selector.
The shell's layout does not guess an entry name; it renders whatever you put in
its head slot. So give the admin its own:
{{-- resources/views/components/layouts/admin.blade.php --}}<x-wire-admin::layout :title="$title ?? config('app.name')"> <x-slot:head> @vite(['resources/css/admin.css', 'resources/js/app.js']) </x-slot:head> {{ $slot }}</x-wire-admin::layout>
/* resources/css/admin.css — compiled for admin pages, and nowhere else */@import "tailwindcss";@custom-variant dark (&:where(.dark, .dark *));@source "../../vendor/nyoncode"; @theme { --radius-sm: 0; --radius-md: 0; --radius-lg: 0; /* square corners */ --spacing: 0.2rem; /* tighter everywhere */ --color-primary-500: var(--color-violet-500);}
Your public pages keep loading app.css and see none of it. Nothing here is a
Wire feature — it is Tailwind 4 reading its own theme variables, which is why
every utility this framework writes follows without a single view being
published.
Two things to know:
- The second entry needs its own
@source "../../vendor/nyoncode". Without it Tailwind never scans the package views and the admin renders unstyled, with no error anywhere. - Load one or the other on an admin page, not both.
Tailwind 3. Radius and spacing are compiled values there, not variables, so a
@themeblock sets properties nothing reads: no error, no change. Colors and semantic roles work on both versions.
Colors
Wire components are built on two Tailwind color scales: primary (accent —
buttons, focus rings, active states) and gray (surfaces, borders, text).
primary is required — without it, interactive elements render invisibly.
Define it in your Tailwind config as shown in Getting Started → Primary Color. In short:
// tailwind.config.js (Tailwind 3)const colors = require('tailwindcss/colors') module.exports = { theme: { extend: { colors: { primary: colors.indigo }, }, },}
To restyle neutrals (for example a warmer UI), point gray at another Tailwind
scale such as colors.zinc or colors.slate the same way.
Because the palette is driven entirely by your Tailwind config, a custom theme is a config change — you do not edit package CSS.
Semantic Roles
success, danger, warning and info are roles, not colours. What each one
renders as is a hue you choose, and every surface follows it — buttons, badges,
alerts, modal icons, toasts, table row tints and the audit timeline:
// config/wire-core.php'colors' => [ 'success' => 'teal', // instead of the shipped emerald 'danger' => 'rose', 'warning' => 'amber', 'info' => 'cyan',],
The value is a hue this framework already ships classes for, never a class
name and never a CSS variable. A class named in a config file is not a file
Tailwind scans, so it would silently never compile; a variable would need
Tailwind 4 and a token your application remembers to define. A hue lands in a
match arm whose class strings are already literal, which is why this works on
Tailwind 3 and 4 alike.
A hue nobody recognises keeps the shipped default rather than rendering grey, so a typo costs you the change and not the colour.
primary is deliberately not here: re-point --color-primary-* in your own
@theme block, as Colors shows.
Shape
How the corners are cut. Two settings, rounded and sharp:
// config/wire-core.php'shape' => 'sharp',
Cards, inputs, buttons and the shell all go square together — 34 corners on a users page, from one line.
It is two rules, and the second is the interesting one
Zeroing the radius tokens squares everything Tailwind compiles to
var(--radius-*). It squares none of the pills: rounded-full compiles to
calc(infinity * 1px) and reads no token at all. So badges and tags are squared
by name, through the styling hooks — and avatars are
deliberately left round.
A sharp theme that squares the faces reads as broken rather than sharp, and no
token can tell "round because it is a card" from "round because it is a face".
That distinction is why this framework has both a token layer and a hook layer,
and wire-core::partials.shape is where the two meet. If you want the avatars
square too, that is one more rule in your own stylesheet:
[data-wire="admin-avatar"] { border-radius: 0; }
No switch, on purpose
Unlike density, shape has no per-person toggle. Density is a working preference — one person wants more rows on screen than another. Shape is identity, and an admin that is round for one colleague and square for the next is two products rather than one respected preference.
Tailwind 3. Shape needs
--radius-*, which Tailwind 3 does not emit. See Scope.
Density
How much room the interface gives itself. Two settings, normal and compact:
// config/wire-core.php'density' => 'compact',
That is the whole of the fixed half. A table row goes from 65px to 52px and the fields of a create form from 550px to 446px, while the type stays exactly where it was — font size reads its own tokens, so compact tightens the chrome and leaves the words alone.
It is four changes, not one
Worth knowing, because three of them are not obvious and you would spend an afternoon rediscovering them:
--spacingdrops. Every padding, gap and size utility compiles tocalc(var(--spacing) * n), so this is what actually tightens a row.- The top bar and the icons are pinned back. The same token drives
h-16andw-4. Unpinned, compact takes the shell's top bar from 64px to 44px and an icon from 16px to 10px — 8px in the media manager, where it drags the icon-only buttons down with it. That is not density, it is a defect. - Form controls are addressed by name.
@tailwindcss/formswritespadding: .5rem .75remon every text input, select and textarea as a literal in its base layer, so no token can reach the surface where density matters most. - A phone keeps its chrome. Below
smthe token stops atheader,asideand[role="dialog"]— the top bar, the menu drawer, and every modal, sheet and slide-over. Unpinned, the menu handle, the bell, both switches and a notification row's verbs all came out 27px square, with the drawer at 202px instead of 288: a menu you have to aim at. Compact is a pointer's preference, so on a phone it tightens what you read and leaves what you touch alone.
A table has its own ->compact(), and the two compose rather than replace each
other: an application-wide compact takes a row from 65px to 51px, a table that
also asks for ->compact() goes to 40px. That is a deliberate "this table in
particular is dense", not a setting applied twice by mistake — but it is worth
knowing before you write both.
All of it lives in wire-core::partials.density, which the shell puts in the
head. A custom layout has to include it, the way it has to carry
@wireStackScripts:
@include('wire-core::partials.density')
Letting people choose
The shell ships a switch beside the theme toggle — at the foot of the menu drawer on a phone, where the top bar has no room for it — and the two settings compose rather than fight: your config is the default, a person's choice overrides it. Somebody who never touches the switch keeps what you shipped, including a later change to it.
The choice is per browser, stored in localStorage, applied before the first
paint and re-applied after a wire:navigate — Livewire copies the fetched
document's <html> attributes over the live ones, so without that last part
every page visit would quietly undo the choice.
To drive it yourself:
window.wireDensity.set('compact'); // choosewindow.wireDensity.clear(); // back to the application's default
Tailwind 3. Density needs
--spacing, which Tailwind 3 does not emit — the rules set a property nothing reads, so the page is unchanged and nothing errors. See Scope.
Styling Hooks
The levels above move a value everywhere. This one restyles one kind of element — the sidebar, the table toolbar, every badge — and it is what you reach for instead of publishing a view.
Every meaningful element carries a stable name:
<aside data-wire="admin-sidebar" class="…">
Write CSS against it in your own stylesheet. @apply works, dark variants and
pseudo-classes included:
/* resources/css/admin.css */[data-wire="admin-sidebar"] { @apply bg-gray-50 dark:bg-gray-950; }[data-wire="table-row"]:hover { @apply bg-primary-50; }[data-wire="badge"] { @apply font-mono tracking-tight; }
Nothing is published, so nothing forks. A future release may add markup around the element and your rule keeps working — which is exactly what a published view cannot promise.
Finding a name
Open devtools and look at the element, the same way you would for any site. The names are on the elements themselves. To read them in bulk:
grep -rho 'data-wire="[a-z-]*"' vendor/nyoncode | sort -u
The anchors worth knowing
The framework carries a few hundred; these are the ones most themes start from:
| Name | Element |
|---|---|
admin-sidebar, admin-topbar, admin-content |
The three regions of the shell |
admin-nav-item, admin-nav-label, admin-nav-badge-dot |
One entry in the menu |
table-search, table-toolbar, table-bulk-bar |
The chrome above a table |
form-field |
The wrapper every form field has |
badge, button, callout, dropdown, menu-item, section |
The shared components, anywhere they appear |
What the name promises
- A name is public API. It may be added; it is not renamed or removed in a minor release.
- Names describe the thing, never its appearance —
table-toolbar, nevertable-grey-bar. A name that describes how something looks would have to be renamed the first time it stopped looking that way. - The framework never writes a rule for one. They are bare targets, so your
rule has nothing to out-specify and never needs
!important. data-wireis notdata-testid. The test attribute exists so a test can find an element and has to stay free to change for testing reasons. Where both are present they carry the same name, deliberately — but onlydata-wireis a contract.
Render Hooks
Every level so far changes how something looks. This one puts something on the page that was not there — a badge after a page title, a note under the menu, a button beside a table's search box.
// A service provider.use NyonCode\WireCore\Core\Plugin\RenderHook; RenderHook::add('panels.page.header.end', fn (array $scope) => view('badges.beta', $scope));
The callback runs where that position sits in the markup and its result is rendered there. Nothing is published, so nothing forks.
The positions
| Position | Where |
|---|---|
admin.topbar.end |
After the last control in the shell's top bar |
admin.sidebar.end |
Below the menu, above the bottom of the sidebar |
panels.page.header.end |
After a page's title and description |
table.toolbar.end |
After the search box and filters, before the actions |
Four is not a starter set to be completed. A position exists because something needed it; the framework does not add one for symmetry with a position that already exists, and neither should a package built on it. If you need one that is not here, that is worth saying — a name added is a name that can never quietly go away.
Names read outside-in: panels.page.header.end is the panels package, its page
chrome, the header, at the end.
What a callback may return
| Return | What happens |
|---|---|
View or Htmlable |
Rendered as-is. This is the shape to use. |
string |
Escaped. Text goes in as text. |
anything else, including null |
Ignored — a callback with nothing to add says so by returning nothing. |
A bare string is escaped on purpose: markup has to be a view or an explicit
HtmlString, so nothing injects a tag from a value you did not write.
Ordering and scope
Registration is the same one every lifecycle hook uses, so it takes the same two options:
RenderHook::add('table.toolbar.end', $render, priority: -10); // runs firstRenderHook::add('table.toolbar.end', $render, for: 'invoices'); // this resource only
Callbacks run in priority order and their output concatenates in that order, so two packages adding to one position keep a defined sequence.
A position nobody registered for costs an array lookup and renders an empty string, which is why they can sit in markup that renders on every page.
Icons
Icons resolve through the core IconManager, configured in
config/wire-core.php. The bundled Heroicons set is the unprefixed default;
additional sets are namespaced under a prefix and used together.
Drop in SVGs — point a directory at the icon paths and every SVG becomes an icon named after its filename:
// config/wire-core.php'icons' => [ 'paths' => [ resource_path('icons'), // resource_path('icons/cart.svg') => 'cart' ],],
Add an icon set — register a set class under a prefix; its icons are then used
as prefix:name and render correctly even if they are stroke-based / non-20×20
(Lucide, Feather, Heroicons outline):
'icons' => [ 'sets' => [ 'default' => NyonCode\WireCore\Foundation\Icons\DefaultIconSet::class, // "pencil" 'lucide' => App\Wire\Icons\LucideIconSet::class, // "lucide:home" ],],
Swap the default style — point default_set at another set's key to make it
the unprefixed base:
'icons' => [ 'default_set' => 'lucide', // bare names resolve against Lucide; "default:pencil" still works 'sets' => [ 'lucide' => App\Wire\Icons\LucideIconSet::class, 'default' => NyonCode\WireCore\Foundation\Icons\DefaultIconSet::class, ],],
The bundled DefaultIconSet is the full Heroicons solid set. See
Core → Foundation → Icons for the full API, the
prefix:name model, custom sets, and accessibility.
Per-Component Tweaks
For a single field, column, or action, prefer the fluent API over overriding a view. Every field supports arbitrary HTML attributes and extra classes:
TextInput::make('sku') ->extraAttributes(['class' => 'font-mono tracking-wide', 'data-test' => 'sku']) ->size('lg');
extraAttributes() merges onto the component's outer element, so you can add
utility classes, data-* hooks, or ARIA attributes without touching markup. It
is on every component — fields, infolist entries, display components and
widgets — and takes a closure as readily as an array. When you need genuinely
different markup, build a custom field or a
ViewField.
Overriding Views
When a tweak is structural — different layout, extra elements, a redesigned cell — publish the package views and edit the Blade. Published views take precedence over the package copies.
php artisan vendor:publish --tag=wire-core::viewsphp artisan vendor:publish --tag=wire-forms::viewsphp artisan vendor:publish --tag=wire-table::viewsphp artisan vendor:publish --tag=wire-sortable::views
Each command copies that package's Blade files into
resources/views/vendor/{package}/ — for example
resources/views/vendor/wire-forms/components/text-input.blade.php. Edit the
copy; delete it to fall back to the package default.
The same tag exists on every package that ships Blade — the panel layer, the shell, and each installed module:
php artisan vendor:publish --tag=wire-panels::viewsphp artisan vendor:publish --tag=wire-admin::views # One per installed module (wire-module-audit ships no views of its own)php artisan vendor:publish --tag=wire-module-users::viewsphp artisan vendor:publish --tag=wire-module-auth::viewsphp artisan vendor:publish --tag=wire-module-settings::viewsphp artisan vendor:publish --tag=wire-module-notifications::viewsphp artisan vendor:publish --tag=wire-module-media::views
Publish only what you change. Every overridden view is a file you now maintain across upgrades. For one-off markup, a custom field or
ViewFieldis lower-maintenance than overriding a shared view. Re-check overridden views when you upgrade.
The shared field chrome (label, hint, required marker, helper text, error) lives
in partials/field-wrapper-start.blade.php and field-wrapper-end.blade.php;
override those to restyle every field's wrapper at once.
Localization
All user-facing strings come from publishable translation files. The package
ships English (en) and Czech (cs).
php artisan vendor:publish --tag=wire-core::translationsphp artisan vendor:publish --tag=wire-forms::translationsphp artisan vendor:publish --tag=wire-table::translationsphp artisan vendor:publish --tag=wire-sortable::translations
Files land in lang/vendor/{package}/{locale}/. Edit a published file to change
wording, or add a new locale directory to translate. Date and time formats for
form fields are configured separately in config/wire-forms.php
(date_format, time_format, datetime_format, first_day_of_week).
Every other package answers the same tag, and the modules are usually where an application reaches first — they carry the wording on the screens its people read every day:
php artisan vendor:publish --tag=wire-panels::translationsphp artisan vendor:publish --tag=wire-admin::translations # One per installed modulephp artisan vendor:publish --tag=wire-module-users::translationsphp artisan vendor:publish --tag=wire-module-auth::translationsphp artisan vendor:publish --tag=wire-module-settings::translationsphp artisan vendor:publish --tag=wire-module-notifications::translationsphp artisan vendor:publish --tag=wire-module-audit::translationsphp artisan vendor:publish --tag=wire-module-media::translations
An application's file is merged over the package's, key by key, so a file holding only the lines you disagree with is a complete override — and the keys you leave out keep tracking the package. A published copy of a whole file is the easy start and the thing that quietly stops receiving new wording.
See Also
- Getting Started — Tailwind paths and primary color
- Configuration — all publishable config
- Extending Forms — custom fields when markup must differ
- Upgrade — re-checking overrides after an update