malevich / ui
A lightweight variant API for building consistent Blade component libraries
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- illuminate/view: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.30
- orchestra/testbench: ^11.2
- pestphp/pest: ^5.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Stop writing spaghetti class="{{ ... ? ... : ... }}" strings in your Blade components.
Malevich lets you describe every visual option of a component - variant, color, size, or anything else you invent - as a simple, declarative map: "this option -> these classes." Then, right in your markup, you just say which option is active. Malevich does the boring part: picks the right classes, merges them, removes duplicates, and hands you back a clean class string.
That's it. No new templating language, no build step, no runtime JS. Just Blade, PHP arrays, and the $attributes bag you already know.
The problem 👎
Every reusable Blade component ends up looking like this sooner or later:
<div @class([ 'inline-flex items-center font-medium', 'hover:brightness-95' => $variant === 'solid', 'border-2 border-dashed bg-transparent' => $variant === 'outline', 'bg-blue-100' => $color === 'primary' && $variant === 'solid', ... ])> ... </div>
It works - until you need a fourth variant, a second color, or a size prop. Then it's a wall of ternaries nobody wants to touch, buried inside markup that's supposed to be about structure, not logic.
The solution 🙂👍
Declare the class maps once, near the top of the component. Apply them with one fluent chain:
@variant([ '*' => 'inline-flex items-center font-medium', 'solid' => 'hover:brightness-95', 'outline' => 'border-2 border-dashed bg-transparent', ]) @color([ 'primary' => [ 'text-blue-500', 'bg-blue-100' => $variant === 'solid', 'border-blue-200' => $variant === 'outline', ], ]) <span {{ $attributes->variant('solid')->color('primary') }}> {{ $slot }} </span>
No conditionals in the markup. No string concatenation. Just data in, classes out.
Why you'll like it 🤔
- Reads top to bottom. All the "what classes does this option produce" logic lives in one place, separate from the markup that renders it.
- Works with anything, not just Tailwind. Under the hood it's plain
Arr::toCssClasses, so conditional arrays, BEM classes, whatever - all fine. - Multi-element components? No problem. Named "targets" let a single component style its wrapper, icon, and label independently, each with its own variant/color/size, without prop-name collisions.
- Reuse combos with presets. Register a set of directive values once (
@preset), apply it anywhere with->preset('name'). - Zero new concepts to learn. It's just methods on the
ComponentAttributeBagyou already call$attributes->merge()on every day - and it still behaves like one, see Calling nativeComponentAttributeBagmethods. - Add your own directives. Not just
@variant/@color/@size- one config line and@radius,@shadow, whatever you need, gets its own directive and fluent method for free. - Framework-native. No JS, no compiler, no config beyond a single optional file. Install it and it's already working.
If you're building a UI kit or design system in Blade - buttons, badges, alerts, cards - and you're tired of variant logic leaking into your templates, this is exactly the tool you were about to write yourself.
Table of Contents
- The problem
- The solution
- Why you'll like it
- Installation
- Basic usage
- Scaffolding a new component
- Adding your own directives
- Named targets - styling multiple elements in one component
- Presets - reuse common combinations
- Slots - merging classes from named slots
- Calling native
ComponentAttributeBagmethods - Best practices & lifehacks
- Configuration reference
- API summary
- License
Installation
Install the package via Composer:
composer require malevich/ui
Publish the config file (optional, but recommended if you want to customize directives or component paths):
php artisan vendor:publish --tag malevich:config
Basic usage
Here's the smallest complete example - a <x-badge> component with a variant, color and size:
{{-- resources/views/components/ui/badge.blade.php --}}
@props([
'variant' => 'outline',
'color' => 'primary',
'size' => 'md',
])
@variant([
'*' => 'inline-flex items-center font-medium',
'solid' => 'hover:brightness-95',
'outline' => 'border-2 border-dashed bg-transparent',
])
@color([
'primary' => [
'text-blue-500',
'bg-blue-100' => $variant === 'solid',
'border-blue-200' => $variant === 'outline',
],
])
@size([
'md' => 'px-3 py-1 text-sm',
])
<span {{ $attributes->variant($variant)->color($color)->size($size) }}>
{{ $slot }}
</span>
Usage:
<x-ui.badge variant="outline" color="primary" size="md"> New </x-ui.badge>
Renders:
<span class="inline-flex items-center font-medium border-2 border-dashed bg-transparent text-blue-500 border-blue-200 px-3 py-1 text-sm"> New </span>
How it works
@variant([...]),@color([...]),@size([...])- these are Blade directives that register a map of "option name" -> "classes" for the current component. They accept anythingIlluminate\Support\Arr::toCssClassesunderstands, so you can also use the'class' => conditionarray syntax as shown for@colorabove.- The special
'*'key means "always apply these classes, regardless of which option is selected" - handy for base/shared classes. $attributes->variant($variant)->color($color)->size($size)picks the matching classes for each directive based on the value you pass in (e.g.$variant === 'outline'), merges everything together, deduplicates classes, and returns the final class string as part of the attribute bag.
You can chain as many directives as you like, in any order.
Shortcut with use()
If you have several props to apply at once, use() lets you pass them all in a single call instead of chaining every directive manually:
<span {{ $attributes->use(compact('variant', 'color', 'size')) }}>
{{ $slot }}
</span>
This is functionally identical to the chained version above.
Scaffolding a new component
Instead of creating a new component file by hand, generate one with the bundled Artisan command:
php artisan make:malevich badge
This creates badge.blade.php from a starter stub (with a ready-to-edit @variant/@color/@size skeleton) inside your configured components directory - resources/views/components/ui by default, or whatever malevich.components.path is set to.
You don't need the .blade.php suffix - php artisan make:malevich badge and php artisan make:malevich badge.blade.php produce the same file. Nested names create the intermediate directories for you, e.g.:
php artisan make:malevich forms/input
creates resources/views/components/ui/forms/input.blade.php (registered as <x-ui::forms.input>), ensuring the forms directory exists first.
If a component with that name already exists, the command refuses to overwrite it and exits with an error instead - safe to run without worrying about clobbering existing work.
Adding your own directives
Out of the box, three directives are registered: @variant, @color, @size. You can add as many custom ones as you need in config/malevich.php:
'directives' => [
'variant',
'size',
'color',
+ 'radius',
],
Once added, both the Blade directive and the attribute-bag method are available automatically:
@radius([ 'full' => 'rounded-full', ]) <span {{ $attributes->variant($variant)->color($color)->size($size)->radius($radius) }}> {{ $slot }} </span>
Named targets - styling multiple elements in one component
Sometimes a single component renders several HTML elements (a wrapper, an icon, a label...) and each one needs its own set of variant/color/size classes. Every directive supports an optional target name as its first argument. If you don't pass one, it defaults to default (configurable via malevich.default_target).
@props([ 'variant' => ['wrapper' => 'dashed'], 'color' => ['wrapper' => 'black', 'foo' => 'orange'], 'size' => ['wrapper' => 'md', 'foo' => 'md', 'bar' => 'lg'], ]) {{-- Wrapper --}} @variant('wrapper', [ 'dashed' => 'border-2 border-dashed rounded-2xl font-medium', ]) @color('wrapper', [ 'black' => 'border-black', ]) @size('wrapper', [ 'md' => 'p-4', ]) {{-- Foo --}} @size('foo', [ 'md' => 'text-sm', ]) @color('foo', [ 'orange' => 'text-orange-500', ]) {{-- Bar --}} @size('bar', [ 'lg' => 'text-5xl', ]) <div {{ $attributes->for('wrapper')->variant($variant['wrapper'])->color($color['wrapper'])->size($size['wrapper']) }}> <div {{ $attributes->for('foo')->color($color['foo'])->size($size['foo']) }}> Foo </div> <div {{ $attributes->for('bar')->size($size['bar']) }}> Bar </div> </div>
->for('wrapper') tells Malevich "resolve classes registered under the wrapper target". Each <div> in the example only picks up the classes registered for its own target, keeping things fully isolated.
Result:
<div class="border-2 border-dashed rounded-2xl font-medium border-black p-4"> <div class="text-sm text-orange-500"> Foo </div> <div class="text-5xl"> Bar </div> </div>
Passing one array instead of per-target values
Instead of manually extracting $variant['foo'], you can pass the whole array - Malevich will automatically look up the key that matches the current for() target:
@props([ 'variants' => ['wrapper' => 'dashed'], 'colors' => ['wrapper' => 'black', 'foo' => 'orange'], 'sizes' => ['wrapper' => 'md', 'foo' => 'md', 'bar' => 'lg'], ]) ... <div {{ $attributes->for('wrapper')->variant($variants)->color($colors)->size($sizes) }}> <div {{ $attributes->for('foo')->color($colors)->size($sizes) }}> Foo </div> <div {{ $attributes->for('bar')->size($sizes) }}> Bar </div> </div>
Same thing, but shorter with use()
@props([ 'variant' => ['wrapper' => 'dashed'], 'color' => ['wrapper' => 'black', 'foo' => 'orange'], 'size' => ['wrapper' => 'md', 'foo' => 'md', 'bar' => 'lg'], ]) ... <div {{ $attributes->for('wrapper')->use(compact('variant', 'color', 'size')) }}> <div {{ $attributes->for('foo')->use(compact('color', 'size')) }}> Foo </div> <div {{ $attributes->for('bar')->use(compact('size')) }}> Bar </div> </div>
Presets - reuse common combinations
If you find yourself repeating the same directive values across components, register them once as a preset and apply them wherever needed.
@props([ 'preset' => 'main', // Override just the wrapper color, from black to orange 'color' => ['wrapper' => 'orange'], ]) @preset('main', [ 'wrapper' => [ 'variant' => 'dashed', 'color' => 'black', 'size' => 'md', ], 'foo' => [ 'color' => 'orange', 'size' => 'md', ], 'bar' => [ 'size' => 'lg', ], ]) ... @color('wrapper', [ 'black' => 'border-black', 'orange' => 'border-orange-500', ]) ... <div {{ $attributes->for('wrapper')->preset('main')->color($color) }}> <div {{ $attributes->for('foo')->preset('main') }}> Foo </div> <div {{ $attributes->for('bar')->preset('main') }}> Bar </div> </div>
Important
Order matters: always call ->preset('name') before any explicit directive calls (->color(...), ->use(...), etc.), so the explicit values you pass afterwards can override the preset defaults.
Slots - merging classes from named slots
If a component element should also inherit class (and other attributes) passed to a named slot, use ->slot(...) to tell Malevich which slot's attribute bag to merge in:
{{-- resources/views/components/ui/foo.blade.php --}}
@props([
'colors' => [],
])
@color('heading', [
'primary' => 'text-blue-600 font-bold',
])
@color('footer', [
'secondary' => 'text-gray-400',
])
<div class="p-4 flex flex-col gap-4">
<h1 {{ $attributes->for('heading')->slot($heading)->color($colors) }}>
{{ $heading }}
</h1>
{{ $slot }}
<footer {{ $attributes->for('footer')->slot($footer)->color($colors) }}>
{{ $footer }}
</footer>
</div>
Usage:
<x-ui.foo :colors="['heading' => 'primary', 'footer' => 'secondary']"> <x-slot name="heading" class="text-2xl"> Heading </x-slot> <main> Content </main> <x-slot name="footer" class="text-xs"> Footer </x-slot> </x-ui.foo>
The class="text-2xl" passed to the heading slot is merged together with the text-blue-600 font-bold classes registered for the heading target - without ->slot(), slot attributes are never picked up automatically, so nothing "leaks" unexpectedly.
Calling native ComponentAttributeBag methods
A Selector is not a replacement for ComponentAttributeBag - it's a thin wrapper you build on top of it. Any method you'd normally call on $attributes (only(), except(), merge(), whereStartsWith(), has(), get(), ...) still works after any Malevich chain, and is transparently forwarded to a ComponentAttributeBag that already has the resolved class baked in:
{{ $attributes->use(compact('variant', 'color', 'size'))->only(['class']) }}
{{ $attributes->for('icon')->color($color)->except(['class'])->merge(['aria-hidden' => 'true']) }}
You don't need to learn a second API for "the rest of the attributes" - once you're done describing directives, just keep chaining whatever ComponentAttributeBag method you need.
Important
Directives first, native methods last. Native ComponentAttributeBag methods like only()/except()/filter() operate on whatever bag is "current" at the point you call them. If you call one of them before ->use()/->directive()/->preset(), it filters the attributes before Malevich has a chance to read the directive values back out of them - which silently produces an empty (or wrong) class, with no error thrown:
{{-- 🚫 breaks: `only(['class'])` strips the @variant/@color/@size attributes before Selector ever sees them --}} {{ $attributes->only(['class'])->use(compact('variant', 'color', 'size')) }} {{-- ✅ works: directives are resolved first, only() just trims the final result afterwards --}} {{ $attributes->use(compact('variant', 'color', 'size'))->only(['class']) }}
As a rule of thumb: put for(), slot(), use(), directive(), preset() and your directive methods (->variant(), ->color(), ...) at the start of the chain, and any plain ComponentAttributeBag method at the end, once classes are already resolved.
Best practices & lifehacks
A set of small, working patterns that make Malevich code shorter, more readable, and easier to maintain - plus a few gotchas that aren't obvious from reading the API alone.
1. Put everything shared behind '*', don't repeat it per option
// 🚫 base classes duplicated across every variant @variant([ 'solid' => 'inline-flex items-center font-medium hover:brightness-95', 'outline' => 'inline-flex items-center font-medium border-2 border-dashed', ]) // ✅ shared once, each variant only describes the delta @variant([ '*' => 'inline-flex items-center font-medium', 'solid' => 'hover:brightness-95', 'outline' => 'border-2 border-dashed bg-transparent', ])
'*' always applies, regardless of the chosen value - use it for the
component's "skeleton", not as another variant key.
2. Conditional classes as arrays, not ternaries
Every directive goes through Arr::toCssClasses, so skip the ? : string
building entirely and describe the condition next to the class itself:
// 🚫 @color([ 'primary' => 'text-blue-500 ' . ($variant === 'solid' ? 'bg-blue-100' : ''), ]) // ✅ @color([ 'primary' => [ 'text-blue-500', 'bg-blue-100' => $variant === 'solid', 'border-blue-200' => $variant === 'outline', ], ])
The logic stays declarative and never leaves the section that defines the color itself.
3. Reach for use() once the prop count grows
// 🚫 verbose $attributes->variant($variant)->color($color)->size($size)->radius($radius) // ✅ scales to any number of directives $attributes->use(compact('variant', 'color', 'size', 'radius'))
Also works with for(): $attributes->for('wrapper')->use(compact('color', 'size')).
4. Only introduce a target when an element genuinely needs its own axis
Targets are worth it when a sub-element truly has its own set of
variants/colors/sizes (an icon, a label, a wrapper). If every part of the
component shares the same variant, don't split it into a target - just
apply it directly on each node:
<div {{ $attributes->for('wrapper')->color($colors['wrapper']) }}>
<svg {{ $attributes->for('icon')->color($colors['icon']) }} />
</div>
Extra targets add indirection without buying you anything.
5. Presets: base first, overrides after - and you can stack them
@preset('outline-card', [ 'variant' => 'outline', 'color' => 'gray', 'size' => 'md', ]) {{-- preset() BEFORE explicit calls, or the override won't stick --}} <div {{ $attributes->preset('outline-card')->color($color ?? null) }}>
Order matters: explicit values called after preset() win over it, and
before it get overwritten by it. This also means presets can be layered
- apply a base design-system preset, then a theme preset on top, and whichever directive the second one touches wins:
<div {{ $attributes->preset('card-base')->preset('theme-danger') }}>
Useful for a "base + theme" split instead of duplicating full class sets per theme.
6. Use slot() only where outer classes should actually land
<h1 {{ $attributes->for('heading')->slot($heading)->color($colors) }}>
{{ $heading }}
</h1>
Without ->slot(), a named slot's class/attributes never leak in
automatically - that's intentional, not a missing step. Add slot()
selectively, only on the elements that should be customizable from the
outside via <x-slot class="...">.
7. Wildcard-only directives can be called with no argument at all
If a directive has nothing to choose between (one fixed set of classes), don't invent a prop for it - just call it bare:
@color(['*' => 'border-black']) <div {{ $attributes->color() }}>
Handy for purely decorative sub-elements inside a compound component that don't need their own prop.
8. Add a new directive instead of encoding two axes into one
If a single directive's options start encoding two independent concerns
('solid-danger', 'outline-danger', 'solid-brand'...), that's the
signal to add a directive rather than multiply combinations:
// config/malevich.php 'directives' => ['variant', 'color', 'size', 'tone'],
@tone(['danger' => 'ring-red-500', 'brand' => 'ring-blue-500']) $attributes->variant('outline')->tone('danger')
Each directive is one orthogonal styling axis; Malevich merges and de-duplicates the combination for you.
9. Reuse a partially-built Selector as a base
Selector is immutable - every call returns a new instance, so it's safe to
keep a "base" chain and branch it for different parts of the same component:
$base = $attributes->color($color)->size($size); // shared base <div {{ $base->for('wrapper')->variant('dashed') }}> <span {{ $base->for('icon') }}></span> </div>
$base never mutates between calls - for('wrapper') and for('icon') can
both branch off it safely without affecting one another.
10. Leave default_target alone unless you actually collide with it
If nothing forces your hand, keep default_target = 'default'. That's what
lets the component's root element automatically pick up a class passed
from the outside (<x-badge class="ml-2">), while named targets
intentionally don't (they aren't the component's root). Only change
default_target if you have a real target literally named default that
conflicts in meaning.
11. directive() is your escape hatch for dynamic directive names
If a directive's name comes from a variable (e.g. driven by theme config),
you don't need it to be declared in malevich.directives with a magic
method - call it explicitly:
foreach (['variant', 'color', 'size'] as $name) { $attributes = $attributes->directive($name, $props[$name] ?? null); }
12. Register @variant/@color/@size in the order the markup reads
Declare directives in the order they logically show up in the rendered
markup (shape first - variant, then color, then size) rather than the
order your editor's autocomplete suggested. Registration order never
affects the resolved output, but it makes a big difference in how easy the
component is to read six months later.
A few extra ones
13. Grab the raw string with toClasses() when you don't want a full attribute bag
toHtml()/__toString() wrap the result as class="..." (plus any other
attributes on the root). If you just need the plain class string - say, to
feed an Alpine x-bind:class, a JS prop, or to concatenate manually - call
toClasses() directly instead:
<div x-bind:class="{{ json_encode([$attributes->for('icon')->toClasses() => true]) }}">
14. Share a directive block across components with a Blade partial
If several components in your design system reuse the exact same
@variant/@color maps (e.g. every "surface" component shares the same
color palette), don't copy-paste the directive block - extract it into a
partial and @include it:
{{-- resources/views/partials/surface-directives.blade.php --}}
@color([
'neutral' => 'bg-white text-gray-900',
'muted' => 'bg-gray-50 text-gray-600',
])
{{-- card.blade.php / panel.blade.php / callout.blade.php --}}
@include('partials.surface-directives')
One source of truth for the palette, reused across every component that needs it, no drift between them over time.
15. Don't do expensive work inside a directive's config array
@variant([...]) / @color([...]) register their config on every
render of the component (it's cheap array building normally, but it does
run every time). Keep the maps to literal arrays and simple expressions;
if a class fragment needs real computation, compute it once above the
directive call and reference the variable instead of inlining logic into
the map itself:
// 🚫 recomputes on every render, buried inside the map @color(['active' => expensive_lookup($request) ? 'ring-2' : '']) // ✅ computed once, directive stays a plain lookup @php $ring = expensive_lookup($request) ? 'ring-2' : ''; @endphp @color(['active' => $ring])
16. Always call the macros on the exact $attributes instance you were given
The registry keys everything off the ComponentAttributeBag object's
identity (via WeakMap). If you reassign or rebuild $attributes (e.g.
$attributes = $attributes->merge([...]) returns a new bag under the
hood in some Laravel versions, or you pass a manually constructed bag into
a sub-view) before calling ->for()/->use(), you can end up resolving
against a bag that never had any directives registered on it, and get back
empty classes. When in doubt, register directives and resolve classes
against the same $attributes variable the component method received.
17. Build your own macro on top of Malevich for very common combos
If a "mode" (variant + color + size + preset, all together) repeats across many components, wrap it in your own macro so call sites don't have to spell it out every time:
ComponentAttributeBag::macro('dangerButton', function () { /** @var \Illuminate\View\ComponentAttributeBag $this */ return $this->preset('button-base')->variant('solid')->color('danger'); });
<button {{ $attributes->dangerButton() }}>Delete</button>
It's just a thin macro over the fluent API Malevich already exposes, but it turns a recurring combination into a single named call.
18. Native ComponentAttributeBag methods (only, except, merge, ...) go at the end of the chain
A Selector transparently forwards any method it doesn't recognize as a
directive to the underlying ComponentAttributeBag - with the resolved
class already merged in. That means you can keep using the bag methods
you already know, right after a Malevich chain:
{{-- keep only the resolved class, drop everything else --}}
{{ $attributes->use(compact('variant', 'color', 'size'))->only(['class']) }}
{{-- resolve classes for a target, then add a plain attribute on top --}}
{{ $attributes->for('icon')->color($color)->merge(['aria-hidden' => 'true']) }}
The trap is calling them in the opposite order. only()/except()/
filter() act on whatever attributes exist at that point in the chain -
called before use()/directive methods, they strip out the very
@variant/@color/@size attributes Malevich still needs to read, and
you silently get an empty class back with no error:
// 🚫 only(['class']) runs first and throws away the directive attributes {{ $attributes->only(['class'])->use(compact('variant', 'color', 'size')) }} // ✅ resolve first, filter the result after {{ $attributes->use(compact('variant', 'color', 'size'))->only(['class']) }}
Rule of thumb: for() / slot() / use() / directive() / preset() /
your directive methods go first, plain ComponentAttributeBag methods
go last.
Configuration reference
config/malevich.php:
| Key | Default | Description |
|---|---|---|
directives |
['variant', 'size', 'color'] |
List of directives to auto-register as both @directive(...) Blade directives and $attributes->directive(...) methods. |
default_target |
'default' |
Internal name used when ->for() is not called. Change only if it conflicts with a target name you actually use. |
components.path |
resource_path('views/components/ui') |
Directory php artisan make:malevich writes new component files into. |
API summary
| Method | Description |
|---|---|
->for(string $target) |
Switch the target you're resolving classes for (defaults to default). |
->slot(mixed $slot) |
Merge the class/attributes of a given ComponentSlot / ComponentAttributeBag into the result. |
->use(array|string $choicesOrTarget, array $choices = []) |
Apply several directive values at once, optionally also setting the target. |
->directive(string $name, mixed $value) |
Manually set a value for any directive by name (used internally by the generated methods). |
->{directiveName}(string $value) |
Auto-generated per configured directive, e.g. ->variant('solid'), ->color('primary'). |
->preset(string $name) |
Apply a previously registered @preset(...) to the current target. |
->toClasses() |
Return the resolved class string only, without wrapping it in class="...". |
->{bagMethod}(...) |
Anything not recognized as a directive (->only(), ->except(), ->merge(), ->has(), ->get(), ...) is forwarded to a ComponentAttributeBag with the resolved class already applied. Call it after the directive/use()/preset() calls - see Calling native ComponentAttributeBag methods. |
Blade directives available in your components:
| Directive | Description |
|---|---|
@directive($target = null, $config) |
Low-level directive registration (used to build the others). |
@preset($name, $config) |
Register a reusable preset of directive values, optionally keyed per target. |
@variant(...), @color(...), @size(...) and any custom directive from config |
Register a class map for the current component: 'option' => 'classes'. Use '*' for classes applied regardless of the selected option. |
Artisan commands:
| Command | Description |
|---|---|
php artisan make:malevich {name} |
Scaffold a new component file from a starter stub into malevich.components.path. See Scaffolding a new component. |
License
The MIT License (MIT). See LICENSE.md for more information.