One clear action per decision. Everything else supports it.
Buttons v2 defines the action hierarchy every surface should reach for: Primary,
Secondary, Tertiary, Destructive and icon-only. It's also Irozon's first canonical
component — packages/design-system/src/components/button.css, framework-neutral
CSS keyed by a small HTML API, consumable from plain HTML, WordPress, PHP
templates, React or Vite alike. React never becomes the source of truth for how
it looks.
.iz-button — every button starts here.data-variant="primary | secondary | tertiary | destructive".data-size="small | large" — default requires no attribute.data-icon-only plus a required aria-label.<button class="iz-button" data-variant="primary">
Save changes
</button>
Five roles. One of them wins per decision area.
A screen can have several buttons, but only one Primary at a time — it is the single strongest action, not a color to reach for by default. Primary itself is semantic and theme-adaptive, not a fixed brand color: on light surfaces it resolves to Ink, the strong premium anchor; on dark surfaces it resolves to Green, where Ink no longer has enough presence. Green remains functional and selective everywhere else — see Color.
data-icon-only + aria-label
Rules
- One Primary per decision area. A form, a modal, a card — never two Primary buttons competing for attention in the same context.
- Secondary is the default workhorse. Most buttons on most screens should be Secondary — reach for Primary deliberately, not by habit.
- Tertiary stays button-shaped even at rest — the padding and radius are the affordance, so it never reads as plain body text.
-
Destructive reuses
--color-error, the same token as status Error — no separate red was introduced for this system. -
Icon-only buttons always carry an accessible name — an
aria-labelon the button, not a tooltip alone.
Three sizes. No XS, no XL.
A restrained scale — Small for dense/inline contexts, Default for nearly everything, Large for hero-level or touch-first moments.
--r-xs
data-size="small"--fs-label),
16px icons, 6px gap, --r-xs radius. Dense rows, table actions,
inline toolbars.
--fs-body-sm,
weight 500), 18px icons, 8px gap, 12px radius — a literal value, not a
--r-* token; the scale has no rung there, and this matches
Signature CTA's own corner exactly. The default for forms, cards, toolbars,
dialogs.
data-size="large"--fs-body), 20px
icons, 10px gap, 12px radius (inherited from the default). Hero sections,
first-run flows, touch-first contexts.
Calm at rest. Precise when touched.
Hover is a signal, not a show. Focus is independent of hover — a keyboard user sees it even when a mouse never touched the button. Disabled never animates like an active control.
scale(0.97), tactile
disabled — dimmed, inert, no hover/press motion
aria-busy="true" — rendered with CSS alone
Try Tab-ing to any button on this page — the focus ring appears on keyboard focus
only, never on a mouse click, matching the :focus-visible convention
used across the rest of this system.
Icon-before, icon-after, icon-only.
Tabler outline icons use the same semantic color and size tier as the button — 16px for compact, 20px for default UI and 24px for large/feature actions. Directional icons may get a tiny meaningful nudge on hover; decoration never animates on its own. See the canonical Icons foundation for the full rules.
data-icon-hint="end" nudges on hover
aria-label="Delete item"
Rules
- Every icon-only button carries an
aria-labeldescribing the action, never the icon's name ("Delete item", not "Trash icon"). - Decorative icons paired with a visible label are
aria-hidden="true"— the label already carries the meaning. - Directional hover motion (an arrow nudging toward its direction) is reserved for icons that are actually directional — never applied to a static icon just for polish.
The action stays put. Only its content changes.
aria-busy="true" is the entire contract — the CSS component renders
the loading state on its own, no JavaScript required. The label stays in the DOM
(just made invisible), so its layout box keeps holding the button's width; nothing
needs to measure or lock a width in script. The demo below adds JS only to
simulate an async action finishing.
aria-busy, width holds by itself
<button class="iz-button" data-variant="primary" aria-busy="true" disabled>
<span class="iz-loader iz-button__spinner" data-size="small" aria-hidden="true"></span>
<span>Save changes</span>
</button>
.iz-loader used standalone — at data-size="small", marked aria-hidden since the button's own aria-busy already carries the semantics. It's always present in the DOM (hidden by default); aria-busy="true" alone reveals it. No insertion/removal in JS.visibility: hidden, not display: none — its box keeps occupying space, so the button's width never collapses or needs a JS-measured minimum.aria-busy="true" communicates "working" to assistive tech; pair it with the native disabled attribute to also block a duplicate click/Enter.Do / Don't.
Checked, not assumed.
<button> — Enter and Space both activate it natively, no custom key handling.:hover/:active state — never suppressed by them.disabled attribute. Loading adds aria-busy="true" alongside it, so assistive tech distinguishes "unavailable" from "working."disabled vs. aria-disableddisabled is the default: the browser removes the button from the tab order and blocks interaction for free. Use aria-disabled="true" instead when the control must stay focusable and announced — e.g. so a screen-reader user can reach it and hear why it's unavailable via aria-describedby, rather than it silently disappearing from the tab order.aria-label on the button element — never conveyed by the icon shape or a hover-only tooltip alone.--color-on-action-primary, paired per theme for maximum contrast — White on Ink (light surfaces), Ink on Green (dark surfaces; Green is bright enough that White fails here). Never White on Green. Secondary and Tertiary use body text tokens on canvas/surface, already AA-checked on the Color page.prefers-reduced-motion: reduce, matching every other motion recipe on this site.One hero moment. Not another Primary.
Reserved for rare, high-emphasis Irozon brand moments such as a hero or major campaign action — promoted verbatim from the Irozon website's homepage Hero CTA, the exact Ink → Lime slide-and-reveal with its 3-dot mark. Signature CTA does not replace Primary: routine product and interface actions use the core semantic button hierarchy above. In practice —
The full action hierarchy
- Signature CTA — rare brand-expression action. At most one per page.
- Button (Primary/Secondary/Tertiary/Destructive) — the interface/action hierarchy for everything else that performs an action.
- Link — navigation and editorial navigation. If it navigates, it's an
<a>, styled.iz-linkor, for a CTA-weight navigation moment,.iz-button. - Switch — a binary setting, not an action at all.
Rules
- Not a Primary variant. Structurally and behaviorally distinct — a fixed Ink/Lime brand pairing, not the theme-adaptive semantic Primary color.
- One per page, at most. A hero or a single major campaign moment — never a routine action, never repeated in the same view as Primary.
-
Colors are fixed, not semantic. Ink at rest, Lime on
hover/focus — reproduced exactly from the source, not derived from
--color-action-primary. -
Motion uses
--ease-signature, the one deliberately springy exception in the motion scale, reserved for exactly this reveal. -
Anchor or button, same class. Use an
<a>when navigating, a<button>when acting/submitting (e.g. a form's final submit)..iz-signature-ctais the explicit opt-in either way — it never becomes a default submit style.
<a class="iz-signature-cta" href="/contact">
<span class="iz-signature-cta__sizer" aria-hidden="true">Get in touch</span>
<span class="iz-signature-cta__base">
<span class="iz-signature-cta__label">Get in touch</span>
</span>
<span class="iz-signature-cta__panel" aria-hidden="true"></span>
</a>
<!-- Acting/submitting instead of navigating: same class, a <button>. -->
<button type="submit" class="iz-signature-cta">
<span class="iz-signature-cta__sizer" aria-hidden="true">Send message</span>
<span class="iz-signature-cta__base">
<span class="iz-signature-cta__label">Send message</span>
</span>
<span class="iz-signature-cta__panel" aria-hidden="true"></span>
</button>
This HTML/CSS pattern remains supported for non-React sites using the canonical
styles from @irozon/design-system. The CSS generates the decorative
mark; no SVG or React dependency is required.
Use the published React components.
In React apps, prefer an existing export from
@irozon/design-system-react over recreating its markup. Use
<Button> for ordinary interface actions. The Signature CTA is
a separate, rare brand-expression component for the hero or major campaign action
shown above.
import { Button } from "@irozon/design-system-react";
<Button variant="destructive" size="small" onClick={handleDelete}>
Delete
</Button>
Recommended for React: use the exported
<SignatureCTA> for the brand-expression component above. It owns
the canonical markup and defaults to a navigation anchor; use
as="button" for actions.
import "@irozon/design-system/irozon.css";
import { Link } from "react-router-dom";
import { SignatureCTA } from "@irozon/design-system-react";
<SignatureCTA href="/contact">Get in touch</SignatureCTA>
<SignatureCTA as="button" type="button" onClick={handleOpen}>
Open planner
</SignatureCTA>
<form onSubmit={handleSubmit}>
<SignatureCTA as="button" type="submit">Send message</SignatureCTA>
</form>
<SignatureCTA as={Link} to="/contact">Get in touch</SignatureCTA>
Import the canonical CSS once. For client-side routing, pass your router's Link
through as; those props are inferred from that component. The design
system does not depend on React Router. Both implementations share the same
CSS-generated three-dot mark, Ink → Lime reveal, and reduced-motion behavior.