marrow / ui
A ready-to-use Tailwind + Alpine.js component library for Marrow — 30+ components (Button, Card, Select, Breadcrumb, a Form bridge to marrow/form-builder, and more) built on the framework's existing Component/ComponentRegistry primitives.
Requires
- php: >=8.2
- marrow/framework: ^2.2
Requires (Dev)
None
Suggests
- marrow/form-builder: Enables the `form` component's automatic field rendering for a marrow/form-builder Form instance
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-03 07:01:01 UTC
README
A ready-to-use Tailwind + Alpine.js component library for
Marrow — 25 components (Button, Alert, Card, Modal, Tabs, a
Sonner-style toaster, and more) built entirely on the framework's own Marrow\Template\Component /
ComponentRegistry primitives, which core already shipped but nothing had ever actually used.
Install
composer require marrow/ui
php forge ui:install # wires Alpine.js into resources/js/app.js + resources/css/app.css
npm install alpinejs
npm run dev
That's it — components register themselves the moment the package is required (UiModule::boot()), no
publishing, no config. Unlike marrow/warden, there is nothing to "own": this is a real shared dependency,
upgraded with composer update like any other.
Usage
{{ component('button', {variant: 'primary', slot: 'Save'}) }}
{{ component('alert', {type: 'success', title: 'Saved', slot: 'Your changes were saved.'}) }}
{{ component('badge', {variant: 'success', slot: 'Active'}) }}
Every component is a PHP class (props + a little logic) paired with a Twig template, resolved by name through
the framework's component() function. That function call is the one form that always works; three more
exist for ergonomics, each suited to a different shape of content.
1. Function call — {{ component(...) }}
The base form, usable everywhere, for any component:
{{ component('button', {href: '/posts', variant: 'ghost', slot: 'Cancel'}) }}
{{ component('alert', {type: 'danger', dismissible: true, slot: errors('email')[0]}) }}
Multi-line content is built with Twig's own {% set %}...{% endset %} capture block first:
{% set body %}<p>Manage your account settings below.</p>{% endset %}
{{ component('card', {title: 'Account', slot: body}) }}
2. One slot — {% component %}...{% endcomponent %}
Sugar over the function call for a component with one content prop (Card, Alert, Badge, Tooltip's
slot, ...): the tag's body is captured and passed as slot automatically, so there's no {% set %} to
write by hand.
{% component 'card' with {title: 'Account'} %}
<p>Manage your account settings below.</p>
{% endcomponent %}
{% component 'badge' with {variant: 'success'} %}Active{% endcomponent %}
with {...} is optional, and the tag nests freely — a {% component %} body can contain another
{% component %}.
3. Two slots (trigger + a body) — Twig's native {% embed %}
Modal and Dropdown take two pieces of pre-rendered content, not one — {% component %} above only
captures a single default slot, so it doesn't fit them. Rather than a second, more elaborate tag with nested
named blocks, both components' templates declare real Twig blocks ({% block trigger %} / {% block body %}),
which Twig's own {% embed %} tag can already override — no custom parser needed:
{% embed '@ui/components/modal.html.twig' with {title: 'Confirm deletion', maxWidth: 'lg'} %}
{% block trigger %}
{{ component('button', {variant: 'danger', slot: 'Delete account'}) }}
{% endblock %}
{% block body %}
<p>Are you sure you want to delete your account?</p>
{% endblock %}
{% endembed %}
Note the full filename (modal.html.twig, not just modal) — unlike component()/view(), Twig's own tags
don't go through this package's .html.twig auto-suffixing. Any prop that affects markup (maxWidth,
align, ...) is resolved to its Tailwind class inside the template itself (a plain Twig dict lookup), not
computed in the PHP Component class — {% embed %} never instantiates that class, so a value computed only
in PHP would silently be missing.
4. HTML-like — <mui-button>...</mui-button>
For templates that read more naturally as markup than as Twig tags — rewritten to the {% component %} form
above before Twig's own lexer ever sees the template (there is no native HTML-tag syntax in Twig; this is
a small, self-contained source-to-source rewrite — see Twig\HtmlComponentTag):
<mui-button variant="primary">Save</mui-button> <mui-separator /> <mui-avatar initials="JD" size="lg" /> <mui-card title="Account"> <mui-badge variant="success">Active</mui-badge> </mui-card>
Attributes follow an HTML-ish, slightly Vue/Alpine-flavored convention:
{# whole value = one expression, not stringified: #} <mui-button variant="{{ isDanger ? 'danger' : 'primary' }}">Save</mui-button> {# mixed text/expression -> Twig string interpolation: #} <mui-card title="Hello {{ name }}!">...</mui-card> {# :attr -- raw expression, no quotes, no {{ }} needed (Vue/Alpine-style bind): #} <mui-tabs :tabs="myTabsArray">...</mui-tabs> {# boolean attribute -> disabled: true : #} <mui-button disabled>Save</mui-button> {# kebab-case -> camelCase: maxWidth #} <mui-modal max-width="lg">...</mui-modal>
<mui-x> nests freely, including another <mui-x> of the same name. It does not give you a second way
to fill Modal/Dropdown's two named slots — the tag it compiles down to only has one default slot, same as
{% component %} above; use {% embed %} for those instead.
Accepted limitations, not bugs: the rewrite has no awareness of Twig's own {# comments #} or
{% verbatim %} blocks, so literal <mui-...> text inside either is rewritten anyway — avoid the <mui-
prefix in a template for anything other than an actual component. Malformed markup (an unterminated tag, a
stray /> with no opening match) is left as literal text rather than guessed at, so a typo shows up as stray
text on the page rather than corrupting the rest of the template.
slot/trigger/footer props — and a captured {% component %}/<mui-x> body — are rendered with |raw
in every template here; they're pre-built HTML, not auto-escaped. Compose them from trusted template
fragments (as shown above); never feed raw user input into one directly. table's rows is the one
exception — see the components table below.
Components
Standalone components keep one PHP class per component, flat under Components/ (e.g.
Components/ButtonComponent.php). A component with sub-pieces (Card, Breadcrumb, Select, Form) groups its
whole family under Components/{Family}/ instead, mirrored by Views/components/{family}/ for the matching
templates — e.g. Components/Card/CardHeaderComponent.php renders Views/components/card/card-header.html.twig.
This only affects where the PHP/Twig files live; the registered component name you actually call
(card-header) is the same either way.
| Component | Props | Notes |
|---|---|---|
button |
variant (primary/secondary/danger/ghost/outline), size (sm/md/lg), type, href, disabled, loading, slot |
Renders <a> instead of <button> when href is set. |
alert |
type (info/success/warning/danger), title, dismissible, slot |
dismissible needs Alpine (x-show/x-cloak). |
badge |
variant (default/success/warning/danger/info), slot |
Plain, no JS. |
modal |
title, trigger, slot, maxWidth (sm/md/lg/xl) |
Self-contained Alpine open state per instance — safe to use more than once per page. |
dropdown |
trigger, slot, align (left/right) |
Closes on outside click (@click.outside). |
input |
name, type, value, placeholder, disabled, autofocus, error, attrs |
Plain styled <input>, no JS. attrs — arbitrary HTML attribute passthrough (autocomplete, inputmode, ...), see its own docblock. |
textarea |
name, value, placeholder, rows, disabled, autofocus, error, attrs |
Plain, no JS. |
label |
for, required, slot |
Plain, no JS. |
checkbox |
name, value, checked, disabled, label |
Native checkbox, browser-styled accent color. |
radio |
name, value, checked, disabled, label |
Native radio. |
switch |
name, value, checked, disabled, label |
Pure CSS (peer/peer-checked) toggle — no Alpine needed. |
separator |
orientation (horizontal/vertical) |
Plain divider, no JS. |
avatar |
src, alt, initials, size (sm/md/lg) |
Falls back to initials when src is empty. |
spinner |
size (sm/md/lg) |
The same spinner button[loading] uses internally, usable standalone. |
progress |
value, max |
Plain, no JS. |
skeleton |
class |
Sizing is entirely via class (e.g. h-4 w-32). |
tooltip |
trigger, slot, side (top/bottom/left/right) |
Pure CSS (group-hover) — fixed side, no Floating-UI-style auto-flip near viewport edges. |
tabs |
tabs (array of {id, label, content}) |
content is pre-rendered HTML per tab, passed as one structured array (no compound <Tabs.Trigger>/<Tabs.Content> API). |
accordion |
items (array of {title, content}) |
Single-open (classic accordion), not multi-open. |
pagination |
currentPage, lastPage, urlPattern (must contain {page}), siblings |
Windowed with ellipses; builds URLs from a pattern string, not a Router — no Router dependency in a presentational class. |
table |
headers (strings), rows (array of string arrays) |
Cell values are auto-escaped (the one component here with no raw filter) — plain tabular data is the common case. |
toaster |
(none — render it once) | Sonner-style toast stack. See Toasts below. |
Card family (Components/Card/)
| Component | Props | Notes |
|---|---|---|
card |
title, footer, slot, composed |
Plain shortcut (title/footer strings) by default; pass composed: true to assemble the pieces below instead — see CardComponent's docblock. |
card-header |
class, slot |
Compositional sub-piece. |
card-title |
class, slot |
Compositional sub-piece. |
card-description |
class, slot |
Compositional sub-piece. |
card-content |
class, slot |
Compositional sub-piece. |
card-footer |
class, slot |
Compositional sub-piece. |
{% component 'card' with {composed: true} %}
{% component 'card-header' %}
{% component 'card-title' %}Account{% endcomponent %}
{% component 'card-description' %}Manage your account settings.{% endcomponent %}
{% endcomponent %}
{% component 'card-content' %}...{% endcomponent %}
{% endcomponent %}
Breadcrumb family (Components/Breadcrumb/)
| Component | Props | Notes |
|---|---|---|
breadcrumb |
items (array of {label, href?}), slot |
items is the plain shortcut; omit it and nest breadcrumb-item instead for the compositional form. |
breadcrumb-item |
href, class, slot |
Renders a link when href is given, plain current-page text otherwise. |
{% component 'breadcrumb' %}
{% component 'breadcrumb-item' with {href: '/'} %}Dashboard{% endcomponent %}
{% component 'breadcrumb-item' %}Settings{% endcomponent %}
{% endcomponent %}
Separators between compositional items are pure CSS (a :not(:first-child) sibling selector on the <ol>) —
there's no shared loop context between independently-rendered items to ask "am I first?".
Select family (Components/Select/)
| Component | Props | Notes |
|---|---|---|
select |
name, options (dict), value, placeholder, disabled, error, attrs, slot |
Styled native <select>, not a custom searchable listbox. options is the plain shortcut; slot also accepts raw <option>/<optgroup> markup, including nested select-item calls. |
select-item |
value, selected, disabled, slot |
One <option>. selected isn't auto-computed from select's value (items render independently) — pass it explicitly, e.g. selected: role == 'admin'. |
{% component 'select' with {name: 'role'} %}
{% component 'select-item' with {value: 'admin', selected: role == 'admin'} %}Administrator{% endcomponent %}
{% component 'select-item' with {value: 'editor', selected: role == 'editor'} %}Editor{% endcomponent %}
{% endcomponent %}
Deliberately not included (would need real JS state/positioning beyond what Alpine's core directives give you for free): a searchable Combobox/Command palette, a DataTable with client-side sort/filter, a date-picker Calendar, a Carousel. Each is a meaningfully bigger undertaking than the components above and would pull in either a positioning library (Floating UI) or a dedicated Alpine plugin.
Forms (Components/Form/)
| Component | Props | Notes |
|---|---|---|
field |
name, type, label, required, value, placeholder, help, multiline, rows, options, disabled, autofocus, errorMessages, attrs, class |
Composite label + input/textarea/select + inline error + help text. Wires old()/has_error()/errors() automatically; never prefills a password-type field. |
form |
form (a marrow/form-builder Form instance, optional), action, method, class, slot |
A styled <form> with CSRF + method-spoofing built in; see below for the form-builder bridge. form: null still gives you a plain styled form around whatever you put in slot. |
{{ component('field', {name: 'email', type: 'email', label: 'Email', required: true}) }}
{{ component('field', {name: 'role', label: 'Role', options: {admin: 'Administrator', editor: 'Editor'}}) }}
The marrow/form-builder bridge
Hand form a real marrow/form-builder Form instance and every declared field renders automatically —
no manual loop over $form->fields():
$form = RegisterForm::fromRequest($request); // marrow/form-builder
{% set submit %}{{ component('button', {type: 'submit', slot: 'Create account'}) }}{% endset %}
{{ component('form', {form: form, action: '/register', slot: submit}) }}
Each form-builder Field subclass maps to the matching ui component: CharField/EmailField/
IntegerField → field (text/email/number), TextareaField → field with multiline: true,
ChoiceField → field with options, PasswordField → field type=password (never prefilled, same
rule form-builder's own PasswordField::render() already follows), BooleanField → checkbox,
HiddenField → a bare hidden input, no label/wrapper. Validation errors come from the Field object's own
$errors (populated by $form->isValid()), via field's errorMessages override — not from the framework's
session-flash has_error()/errors(), which form-builder doesn't use.
This package has no hard dependency on marrow/form-builder (only a composer suggest) — form without
a form prop, and every other component here, works with or without it installed.
Toasts (Sonner-style)
Render the toaster once, anywhere in your layout (typically right before </body>):
{{ component('toaster') }}
From then on, three things can produce a toast, with no further setup:
1. Your own JS, any time:
window.toast.success('Saved!'); window.toast.error('Something went wrong.'); window.toast.info('Heads up.', 6000); // optional duration in ms, default 4000
2. Session flash messages — the exact convention this package's views and marrow/warden's controllers
already use:
return $this->redirectToRoute('home')->with('status', 'Profile updated.'); // -> success toast return $this->back()->withErrors(['email' => ['Invalid link.']]); // -> shown inline by error()/has_error(), not toasted
Only the status and error flash keys are bridged to a toast (success/error respectively) — everything
else (validation errors) stays as the inline errors()/has_error() rendering this package's form components
already support.
3. Marrow\Notifications\NotificationManager's database channel, for the current authenticated user —
the link to the framework's own notification system:
class InvoicePaid extends Notification { public function via(object $notifiable): array { return ['database']; } public function toDatabase(object $notifiable): array { return ['message' => 'Your invoice was paid.', 'type' => 'success', 'invoice_id' => $this->invoice->id]; } } $notifications->send($user, new InvoicePaid($invoice));
The toaster component queries the user's unread rows on every page load
(Notifications\ToastNotificationSource) and marks only the ones it actually toasted as read. Opt in with
a message key — a notification whose toDatabase() payload has no message (e.g. a purely structured
{invoice_id: 4}) is left alone entirely: not toasted, not marked read, since this bridge has no way to guess
how your app wants a structured payload worded. Build a proper notification-center UI on the same table if
you need one.
Not filtered by notifiable_type by default: AuthManager::user() returns a plain stdClass (both guards),
never the real model class a controller likely passed to NotificationManager::send() — so comparing class
names would reliably filter out every row. If more than one notifiable type shares the same id space in your
app, register your own ToastNotificationSource instance with an explicit $notifiableType instead of
relying on the default.
Overriding a component
No fork needed — register your own class under the same name from your own module's boot() (whichever
module boots last wins):
use Marrow\Template\ComponentRegistry; $registry->registerAs('button', \Modules\Ui\MyButtonComponent::class);
Requirements
marrow/framework^2.2- Alpine.js — only
alert[dismissible],modal,dropdown,tabs, andaccordionneed it at runtime;button,card,badge,switch,tooltip, and the rest render fine without it.
License
MIT — see LICENSE.