Irozon Design System — design foundations, tokens and components by Irozon AB.
Irozon Design System · v1.0
Components / Buttons

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.

Base class
.iz-button — every button starts here.
Variant
data-variant="primary | secondary | tertiary | destructive".
Size
data-size="small | large" — default requires no attribute.
Icon-only
data-icon-only plus a required aria-label.
<button class="iz-button" data-variant="primary">
  Save changes
</button>
Action hierarchy

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.

Primary — Ink / White on light surfaces
Secondary — neutral, clearly actionable
Tertiary — quiet, still button-shaped
Destructive — canonical error token
Icon-only — data-icon-only + aria-label
Primary — Green / Ink on dark surfaces
Secondary — dark neutral
Tertiary — quiet light
Destructive — dark semantic Error

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-label on the button, not a tooltip alone.
Sizes

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.

36px · --r-xs
48px · 12px radius · no attribute needed
52px · 12px radius
data-size="small"
36px height, 14px horizontal padding, 13px label (--fs-label), 16px icons, 6px gap, --r-xs radius. Dense rows, table actions, inline toolbars.
Default (no attribute)
48px height, 24px horizontal padding, 15px label (--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"
52px height, 26px horizontal padding, 16px label (--fs-body), 20px icons, 10px gap, 12px radius (inherited from the default). Hero sections, first-run flows, touch-first contexts.
Icon-only
Same heights as their size (36 / 48 / 52px), square. 36px sits below the 44px AAA touch-target guideline — reserve Small icon-only for dense desktop UI, not primary mobile actions.
States

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.

No interaction yet
A subtle tonal lift/press, never a hue shift toward Green
Keyboard focus ring, independent of hover
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.

Icons

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.

Icon before label
Icon after — data-icon-hint="end" nudges on hover
Icon-only — aria-label="Delete item"

Rules

  • Every icon-only button carries an aria-label describing 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.
Loading

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.

Click — toggles 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>
Markup
The internal indicator is the same canonical .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.
Width
The label uses visibility: hidden, not display: none — its box keeps occupying space, so the button's width never collapses or needs a JS-measured minimum.
Semantics
aria-busy="true" communicates "working" to assistive tech; pair it with the native disabled attribute to also block a duplicate click/Enter.
JS's job here
The demo controller only toggles the attributes, guards against a second click while already running, and fakes the async delay — it never renders the state itself.
Restraint
Reserved for network-bound actions with real latency. An instant, synchronous action never shows a spinner.
Usage

Do / Don't.

Do
Use exactly one Primary per decision area · lean on the hierarchy instead of color alone · label with verbs ("Save changes", not "OK") · keep labels short enough to scan.
Don't
Reach for Green on every button · place two equally-dominant Primaries in one view · disable a button to hide a permission the user should instead be told about · ship an icon-only control without an accessible name.
Accessibility

Checked, not assumed.

Keyboard
Every button is a real <button> — Enter and Space both activate it natively, no custom key handling.
Focus-visible
The 2px focus ring appears on keyboard focus only, and is independent of :hover/:active state — never suppressed by them.
Target size
Default and Large sizes clear the 44px AAA touch target. Small (36px) is documented as a desktop-density exception, not for primary mobile actions.
Disabled vs. loading
Disabled uses the native disabled attribute. Loading adds aria-busy="true" alongside it, so assistive tech distinguishes "unavailable" from "working."
disabled vs. aria-disabled
Both are supported — they're not interchangeable. Native disabled 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.
Icon-only names
Always an aria-label on the button element — never conveyed by the icon shape or a hover-only tooltip alone.
Contrast
Primary's text uses --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.
Reduced motion
Press scale and icon hover-nudge both drop to a plain color/opacity change under prefers-reduced-motion: reduce, matching every other motion recipe on this site.
Brand expression — Signature CTA

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 —

Hero
Signature CTA — the one brand moment per page.
Later section / ordinary strongest action
Primary — the core semantic button.

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-link or, for a CTA-weight navigation moment, .iz-button.
  • Switch — a binary setting, not an action at all.
Get in touch Hover/focus to reveal — hold Tab to see the focus-visible state

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-cta is 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.

React

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.