Button
It came from what the components needed internally — the toast's "Undo", the calendar's
"Apply". Instead of each one inventing its own, they all come from here, and it's available to your project too. It's
just classes: a <button> is still a <button>.
Examples
Variant, size and state are classes and attributes that combine.
Sizes
is-sm is 30px, the default comes from --tuc-control-height and is-lg is 44px.
With an icon
The <svg> picks up the button's size on its own. is-icon makes it square — and then aria-label is required.
Busy
aria-busy="true" with .tuc-spinner. Click "Save" to see it switch.
Disabled
disabled gets neither clicks nor focus; aria-disabled="true" stays focusable and announced.
Full width
is-block fills the row — the button of a narrow form or a phone screen.
As a link
On an <a> the class removes the underline; it's the right choice when the button navigates rather than acts.
How to use
tuc-btn plus a variant. Without a variant the button is transparent and borderless — the base
the variants build on.
<button type="submit" class="tuc-btn is-primary">Save</button>
<button type="button" class="tuc-btn is-outline is-sm">Cancel</button>
<button type="button" class="tuc-btn is-ghost is-icon" aria-label="Close">
<svg>...</svg>
</button>
<a class="tuc-btn is-link" href="/help/">Learn more</a>In a Django form
The button belongs to the template, not to the form. With type="submit" it submits the form as usual.
<form method="post">
{% csrf_token %}
{{ form }}
<a class="tuc-btn is-outline" href="{% url 'customers' %}">Cancel</a>
<button type="submit" class="tuc-btn is-primary">Save</button>
</form>In JavaScript
There's no component to instantiate. What you do in code is switch the state — for example, busy during a submit:
const btn = document.querySelector('#save');
btn.setAttribute('aria-busy', 'true');
btn.disabled = true; // prevents a second click
btn.prepend(Object.assign(document.createElement('span'), { className: 'tuc-spinner' }));
// ...when it's done
btn.removeAttribute('aria-busy');
btn.disabled = false;
btn.querySelector('.tuc-spinner').remove();Variants and when to use them
One primary action per area. The others step back in visual weight, and the destructive one stands out by color.
| Class | What it's for |
|---|---|
is-primary | Primary action, in the accent color (--tuc-accent) |
is-outline | Secondary action, with a border |
is-ghost | Tertiary, no visual weight — actions inside a table, a toolbar |
is-danger | Destructive, filled with --tuc-danger-fill |
is-link | Looks like a link, behaves like a button |
Why the button's red is a different token
--tuc-danger is lighter in dark mode, so it has contrast as text — and a button filled with it would look pastel with white text. --tuc-danger-fill is the same in both themes.
Buttons and fields share the same height
Both come from --tuc-control-height — 38px, or 44px in the compact layout, where the touch target needs to be bigger. Radius and border come from --tuc-radius-md and --tuc-border-width. A button next to a select lines up with no adjustment.
Busy and disabled
Both block the action, but they say different things, so they look different.
<button class="tuc-btn is-primary" aria-busy="true" disabled>
<span class="tuc-spinner"></span> Saving
</button>
<button class="tuc-btn is-outline" aria-disabled="true">Export</button>Busy is not disabled
disabled alongside aria-busy prevents the second click, but the 50% fade would say "not available" — and the button is working. With aria-busy="true" it keeps its full color and the wait cursor. The spinner is sized in em and painted with currentColor: inside the button it takes the icon's place and comes out white on a primary, with no variant needed.
Disabled shows the not-allowed cursor
No pointer-events: none: an element that doesn't receive the pointer can't change the cursor, and the person gets no sign that the button won't respond. On a <button disabled> the browser already blocks the click. With aria-disabled="true" the click still arrives on purpose — the button stays focusable and announced, and whoever uses the attribute handles the click in their own handler.
Disabled also doesn't react to hover — changing color would suggest it still works — and it loses its shadow.
When pressed, the button shrinks slightly: on a tap, with no hover, color alone doesn't confirm the finger hit
the target. With prefers-reduced-motion that movement is removed.
Keyboard and accessibility
It's the browser's <button>: focus, Enter and Space come for free.
What's left to the template is picking the right element and naming whatever has no text.
| Key | Action |
|---|---|
Tab | Reaches the button, with the same focus ring as the fields; skips disabled, not aria-disabled |
Enter Space | Activates the <button>; on an <a class="tuc-btn">, only Enter |
is-iconwithout text needsaria-label; the<svg>getsaria-hidden="true".<a>to navigate,<button>to act — the screen reader announces each one differently.- Inside a
<form>, givetype="button"to anything that doesn't submit: the browser's default issubmit.
Classes
CSS only, no JavaScript. Don't write a second button style: an action inside a table or a code
block is also a .tuc-btn, with its own class only for positioning.
| Class or attribute | What it's for |
|---|---|
tuc-btn | The base: height, font, radius, focus. Works on <button> and <a> |
is-primary is-outline is-ghost is-danger is-link | Variant |
is-sm is-lg | Size: 30px and 44px; the icon follows |
is-icon | Square, icon only; combines with is-sm and is-lg |
is-block | Fills the full width |
disabled | Faded, not-allowed cursor, no hover; the browser blocks the click |
aria-disabled="true" | Same look, but focusable and with the click getting through |
aria-busy="true" | Busy: full color and wait cursor, even with disabled |
.tuc-spinner inside | Takes the icon's place and size |
--tuc-btn-icon | Size of the child <svg>; each button size sets its own |