Building components
Build a reusable Twig component with cva() for class variants, merge_attributes to accept the caller's attributes, and the tag block to render it.
A reusable component needs three things: classes that depend on its options, a way for the caller to add or override attributes, and markup. This guide builds a button with one helper for each:
cva()picks classes from thevariantandsizeoptions.|merge_attributescombines the component’s attributes with the caller’s.{% tag %}renders the element around the label.
The component
{% set button = cva(
base: 'inline-flex items-center rounded-md font-medium',
variants: {
variant: {
primary: 'bg-blue-600 text-white',
ghost: 'bg-transparent text-blue-600',
},
size: {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2',
},
},
defaultVariant: {variant: 'primary', size: 'md'},
) %}
{% set attrs = {
type: 'button',
class: button.apply({variant: variant ?? null, size: size ?? null}),
}|merge_attributes(attributes ?? {}) %}
{% tag 'button' with attrs %}{{ label }}{% endtag %}
A null option falls back to defaultVariant, so callers only pass the options they want to change.
Using it
With only a label, the button uses the default variant and size:
{% include 'components/button.twig' with {label: 'Save'} only %}
{# <button type="button" class="inline-flex items-center rounded-md font-medium bg-blue-600 text-white px-4 py-2">Save</button> #}
The caller can pick options and pass extra attributes:
{% include 'components/button.twig' with {
label: 'Delete',
variant: 'ghost',
size: 'sm',
attributes: {class: 'px-6', 'hx-delete': '/item/1', disabled: true},
} only %}
{# <button type="button" class="inline-flex items-center rounded-md font-medium bg-transparent text-blue-600 py-1 text-sm px-6" disabled hx-delete="/item/1">Delete</button> #}
Here’s what |merge_attributes did with the caller’s hash:
px-6replaced the size’spx-2, because Tailwind merging lets the later class win.hx-deleteanddisabledwere added.typestayedbutton, since the caller didn’t set it. A caller who setstype: 'submit'overrides it.
Without Tailwind
The same pattern works with any CSS. Turn off Tailwind merging so the caller’s classes are appended instead of resolved:
<div{{ attr({class: 'card card-sm'}|merge_attributes({class: 'card-lg'}, {tailwind_merge: false})) }}>
{# <div class="card card-sm card-lg"> #}
To turn it off everywhere, set mergeOptions on the extension.