Components
Button
StableButtons trigger an action or event. The label and visual weight together tell people exactly what will happen when they tap.
Purpose
A button labels an action and invites the user to take it. The label must be specific enough that the outcome is unambiguous before the tap — "Book now", not "Submit". The visual weight of the button (primary vs ghost) communicates how important that action is relative to everything else on the page.
Usage
Reach for a button when the tap changes state or data — booking a trip, saving a form, cancelling a reservation, submitting a search. When the goal is navigation — moving the user to a different URL with nothing changing on the server — reach for a Link instead, even if it needs button-like visual weight (in which case, pass href to this component; see Buttons vs links).
Buttons appear wherever a flow needs a decision point: at the bottom of a form (submit / cancel), inside a modal or drawer (confirm / dismiss), on a card (a single supporting action), and in a toolbar (compact, low-emphasis controls). The variant you choose should match how many other actions compete for attention in that exact spot — see Variants below.
Anatomy
- 1Container — Padded background surface with border-radius, border, and hover/active transitions.
- 2Leading icon slot — Optional 16 px icon preceding the label. Reinforces — never replaces — the text.
- 3Label — Verb-led sentence-case text. The single most important prop.
- 4Trailing icon slot — Optional 16 px icon following the label — typically a directional arrow.
- 5Focus ring — 2 px ring with 2 px offset; visible only on keyboard focus (focus-visible). Meets WCAG 2.4.7.
The focus ring (callout 5) only renders on
:focus-visible— tab to the button above with your keyboard to see it appear.
Measurements
The redline below is drawn from the values actually shipped in Button.jsx and lib/kuoni-tokens.js at the md size — nothing here is estimated. Kuoni buttons use content-driven auto width; there is no 150 px-style fixed minimum. The only enforced floor is the min-width baked into each size's Tailwind class (min-w-[80px] at md), which only becomes visible when the label is very short — most real labels (multi-word, verb-led) exceed it comfortably.
Variants
Info
Kuoni treats Danger as a distinct button type, sitting alongside Primary, Secondary and Ghost — not as a cross-cutting "intent" that could combine with any of them (e.g. there is no "primary + danger" state). This is a deliberate simplification for this system's scope: it keeps the variant prop a single flat enum instead of two independent axes, at the cost of not being able to express a high-emphasis destructive action. If a future surface genuinely needs that combination, it should be raised as a system-level decision, not solved by composing classes locally.
Primary
Wow Yellow background, Dark text. The single highest-emphasis action on the view.
When to use: The single most important thing a user can do on the current view. "Book now" on a holiday detail page. "Continue to payment" at the bottom of a booking form. "Search" on the homepage search widget.
Use exactly one primary (Wow Yellow) button per view or per task flow step.
One yellow CTA guides the eye to the single most important next step.
Place multiple primary buttons on the same page or card grid row.
Three yellow buttons compete. The eye doesn't know where to go.
Secondary
Dark background, white text. High emphasis but not the primary action. Appears alongside, never instead of, the primary button when a second important action exists.
When to use: An important supporting action that exists on the same view as a primary button. "Save and exit" next to "Continue". "View all offers" next to "Book now". If you find yourself making this decision often, reconsider whether the second action is truly important enough to warrant the visual weight.
Reserve secondary for a genuinely important action that sits next to a primary button.
Secondary supports a primary action without competing for the same attention.
Pair primary with secondary unless the two actions are genuinely equal in importance.
Two high-emphasis buttons signal that both actions matter equally — they rarely do.
Ghost
Transparent background, dark stroke border. Low emphasis. Disappears into the layout while remaining clearly interactive.
When to use: Dismissal actions ("Cancel", "Close"), tertiary navigation links styled as controls ("Back", "Edit"), toolbar and table row actions that should not compete with primary content. In a button group, ghost lives next to a primary or secondary button.
Pair a primary button with a ghost button for a dismissal or lower-priority action in the same group.
Ghost next to primary creates clear hierarchy without visual competition.
Group two ghost (or two text-only) buttons together without another variant establishing hierarchy.
Two low-emphasis buttons side by side give the user no cue which one to prefer.
Danger
Red background, white text. Reserved for irreversible or destructive actions.
When to use: Permanently destructive actions where the consequence cannot be undone — "Cancel booking", "Delete account", "Remove traveller". Never use danger for actions that are merely negative in sentiment but reversible.
Use the danger variant for destructive actions and always wrap them in a confirmation dialog.
Danger + confirmation dialog = a safety net before an irreversible action.
Reach for danger whenever an action feels risky — reserve it for irreversible ones only.
Using danger for 'negative but reversible' actions trains users to ignore the warning.
Variant comparison
Sizes
Three sizes share the same type size and padding rhythm. The height difference (32 / 40 / 48 px) establishes the touch-target hierarchy. Default to md for all product UI; use lg for hero and marketing moments; use sm for compact toolbars and table row actions.
| Size | Height | H-padding | Font | Tap target (touch) | Click target (pointer) |
|---|---|---|---|---|---|
sm | 32 px | 12 px | 12 px | 44 × 44 px minimum (WCAG 2.1 AA) | 32 × 64 px — the rendered hit area is sufficient for mouse/trackpad |
md | 40 px | 16 px | 14 px | 44 × 44 px minimum (WCAG 2.1 AA) | 40 × 80 px |
lg | 48 px | 24 px | 16 px | 44 × 44 px minimum (WCAG 2.1 AA) | 48 × 96 px |
Tap target is the touch input case: WCAG 2.1 AA (2.5.5) requires a 44 × 44 px minimum for anything operated by a finger. Kuoni honours this by expanding the effective touch area with padding/margin at sm, and by the rendered box already meeting it at md/lg.
Click target is the pointer input case — mouse or trackpad, which is far more precise than a fingertip. The rendered button box itself is the click target, and it can safely be smaller than 44 × 44 px for pointer users; the two figures are shown separately here because conflating them into one blended number would either understate the touch requirement or overstate what pointer users actually need.
States
Default and hover
The button transitions its background on hover over 150 ms. The cursor changes to pointer. A pressed (active) state darkens by one more step.
Book now
Book now
Disabled
Prefer keeping the button enabled and showing a validation error over disabling it. A disabled button is hard to discover — it doesn't invite the tap, and once tapped (or not) it explains nothing about what's missing or how to fix it. In most product flows the better pattern is: leave the button active, let the user attempt the action, and surface a clear inline error ("Add at least one traveller to continue") when they do. Reserve true disabling for cases where the action is genuinely unavailable in a way that doesn't need to be discoverable — a "Refresh" button with no new data to fetch, not a "Continue" button waiting on form completion.
The disabled prop applies the native disabled attribute, which:
- Removes the button from the tab order
- Prevents all pointer and keyboard events
- Reduces opacity to 40%
When to use disabled: When the action is genuinely not available in the current application state and you do not need sighted keyboard users to discover the button still exists (e.g., a submit button before a required form field is filled — and only when a validation-error alternative genuinely isn't feasible).
When to prefer aria-disabled: When the button exists but is temporarily unavailable and keyboard users should still be able to find it and understand why it is inactive (e.g., a "Proceed" button that's waiting for a price-check API call to return). Manage the click behaviour in onClick instead of using the native attribute.
Loading
The loading prop shows a spinner in the leading icon position, blocks interaction, and sets aria-busy="true" and aria-disabled="true" so assistive technology announces the change. The button label is preserved so the button width stays stable and screen readers still hear the action name.
A visually-hidden loadingLabel ("Loading, please wait" by default) is injected for screen readers. Customise it to describe the specific operation:
<Button loading loadingLabel="Checking flight availability">
Search
</Button>
With icons
Icon slots sit inside the button's padding — not outside it — so the overall visual weight stays constant. Use 16 px icons at md size (12 px at sm, 20 px at lg).
Leading icon
Trailing icon
Icon-only
When using a button with no visible label, you must supply aria-label so screen-reader users know what the button does.
The loading spinner automatically replaces the
iconLefticon while loading so you never need to hide it manually.
Full width
The fullWidth prop makes the button stretch to fill its container. Use for mobile form actions and any context where the button should be the primary width reference on the page.
Button groups
When two or more actions appear side by side, place the primary (or most important) button first, followed by secondary or ghost variants.
Where groups sit in a layout
| Placement | Use for | Reasoning |
|---|---|---|
| Left-aligned | Primary actions in a toolbar, table row, or card footer | Reduces cursor travel — the group sits closest to the content it acts on, in the same reading direction as the eye's natural left-to-right scan |
| Right-aligned | Progressive flows: form submission, wizard steps, dialog footers | Matches the convention of "forward" motion ending on the right; the eye finishes reading the form/dialog content, then meets the action at the natural end point |
| Centered / full-width | Buttons nested inside a card or narrow container (e.g. a Modal or mobile sheet) | With little horizontal space to establish a directional convention, centering (or fullWidth stacking) keeps the group visually balanced against the container edges |
Use gap-3 (space.3 — 12 px) between buttons in a horizontal group, matching the token already used for button internal padding rhythm. Don't invent a different gap value for grouped buttons.
Minimum button width
Kuoni buttons are auto-width by content, with an enforced Tailwind min-w-[80px] at md (64px at sm, 96px at lg) baked into Button.jsx's size classes — see Measurements above. When grouping buttons of the same size, this shared floor keeps very short labels ("Cancel", "Back") from looking narrower than their neighbours.
Grouping combinations
Lead a group with the primary (or most important) button, followed by lower-emphasis variants.
Primary leads, ghost follows — one clear next step, one easy way out.
Place two primary-variant buttons in the same button group.
Two primary buttons in the same group tell the user both actions matter equally — they can't both be the single most important thing.
Pair one ghost (or other low-emphasis) button with a primary or secondary button.
A single ghost button reads clearly as the low-emphasis option next to a stronger sibling.
Group two low-emphasis (ghost or text-style) buttons together without another variant establishing which one leads.
Two ghost buttons together give no visual cue which one to prefer — the hierarchy collapses.
Place the most prominent button at the top of a stacked group.
In a stacked (vertical, e.g. mobile full-width) group, the most prominent action should be reachable first.
Place a ghost or secondary button above the primary button in a vertically stacked group.
Burying the primary action beneath secondary ones in a stacked group forces extra scanning to find it.
Buttons vs links
| Button | Link | |
|---|---|---|
| Renders as | <button> | <a href> |
| Purpose | Triggers an action (state change, API call, form submit) | Navigates to a URL |
| Keyboard | Enter and Space activate | Enter activates |
| Right-click | No "open in new tab" option | Full browser context menu |
| Back button | Not affected | Navigates back |
Pass href to render the button as an <a> tag with button styling — for CTAs that navigate rather than act. An aria-label is applied automatically and rel="noreferrer noopener" is added when target="_blank".
{/* Action — form submit */}
<Button type="submit" variant="primary">Complete booking</Button>
{/* Navigation — link styled as button */}
<Button href="/destinations/maldives" variant="secondary">
Explore the Maldives
</Button>
{/* External link */}
<Button href="https://kuoni.co.uk" target="_blank" variant="ghost">
Visit kuoni.co.uk
</Button>
Content guidelines
Good button copy is invisible — the user reads it once and immediately knows what to do. Bad copy creates pause and doubt.
Lead with a verb
The label must start with an action word that names the outcome.
| ✅ Do | ❌ Don't |
|---|---|
| "Book now" | "Booking" |
| "Save changes" | "Changes saved" |
| "Add traveller" | "Traveller" |
| "View itinerary" | "Itinerary" |
Be specific
Prefer the specific over the generic. Generic labels transfer the cognitive work of understanding the outcome onto the user.
| ✅ Do | ❌ Don't |
|---|---|
| "Save and continue" | "OK" |
| "Cancel booking" | "Cancel" |
| "Complete payment" | "Submit" |
| "Remove traveller" | "Delete" |
Keep it short
One to four words is almost always enough. When the label is longer than five words, the hierarchy is wrong — the button is being asked to do too much.
Use sentence case
"Book now", not "Book Now". Only proper nouns are capitalised.
Omit unnecessary articles
"Add luggage", not "Add a luggage item".
Best practices
Rules below apply across every variant — they aren't specific to Primary, Secondary, Ghost or Danger. Variant-specific guidance lives with each variant in Variants, and grouping-combination guidance lives in Button groups.
Start every button label with an action verb: "Book", "Save", "View", "Add".
Verb-led copy makes the outcome unambiguous before the tap, no matter which variant carries it.
Use generic labels like "Submit", "OK", "Yes" or "Click here".
'Submit' and 'OK' force the user to infer the outcome from context.
Use the same size (sm / md / lg) for every button within a single toolbar, form footer, or button group.
One consistent size across a toolbar or button row keeps the row visually calm and easy to scan.
Mix button sizes within the same group to signal importance; use variant for that instead.
Mixed sizes in one row read as an error rather than an intentional hierarchy — hierarchy is a variant's job, not a size's.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | — | The button label. Should be a short, verb-led string for text buttons. |
| variant | 'primary' | 'secondary' | 'ghost' | 'danger' | 'primary' | Visual weight and intent of the button. Maps directly to a semantic role. |
| size | 'sm' | 'md' | 'lg' | 'md' | Controls height, horizontal padding and font size. sm=32px, md=40px, lg=48px. |
| type | 'button' | 'submit' | 'reset' | 'button' | Native button type. Always set 'submit' explicitly inside forms. |
| disabled | boolean | false | Applies the native disabled attribute. Removes from tab order and prevents all interaction. Prefer a validation error, or aria-disabled for temporarily unavailable actions. |
| loading | boolean | false | Shows a spinner, sets aria-busy and aria-disabled, and blocks clicks. The label is preserved so the button width stays stable. |
| loadingLabel | string | 'Loading, please wait' | Visually-hidden text injected for screen readers while loading is true. Customise to name the specific operation. |
| fullWidth | boolean | false | Stretches the button to fill its container width. Use for mobile form CTAs. |
| iconLeft | ReactNode | — | Element rendered before the label. Replaced by a spinner while loading is true. |
| iconRight | ReactNode | — | Element rendered after the label. Hidden while loading is true to preserve layout. |
| href | string | — | When provided, renders as an <a> tag with button styling. Use for navigational CTAs. |
| target | string | — | Forwarded to <a>. Use '_blank' for external links; rel='noreferrer noopener' is added automatically. |
| rel | string | 'noreferrer noopener' when target='_blank' | Forwarded to <a>. Override to add custom rel values. |
| aria-label | string | — | Overrides the visible label for screen readers. Required for icon-only buttons. |
| aria-describedby | string | — | ID of an element that provides additional description of the button's action. |
| aria-expanded | boolean | — | Set to true when the button controls a disclosure widget that is currently expanded. |
| aria-controls | string | — | ID of the element this button controls (e.g., a modal or accordion panel). |
| aria-pressed | boolean | — | Use for toggle buttons. Signals pressed/not-pressed state to assistive technology. |
| onClick | (event: MouseEvent) => void | — | Click handler. Automatically suppressed while loading is true. |
| className | string | — | Additional Tailwind classes merged onto the root element. Use sparingly — prefer props. |
Accessibility
Keyboard interaction
| Key | Action |
|---|---|
Tab | Move focus to the next interactive element |
Shift + Tab | Move focus to the previous interactive element |
Enter | Activate the button |
Space | Activate the button (equivalent to Enter for <button>) |
Note for
hrefbuttons: When rendered as an<a>tag, onlyEnteractivates the link —Spacescrolls the page. If keyboard users must activate withSpace, use a<button>instead and handle navigation inonClick.
Focus ring
All variants show a 2 px ring with 2 px offset on :focus-visible. This selector fires only for keyboard (and sequential) focus, not for mouse clicks — avoiding the distracting ring for pointer users while keeping it visible for keyboard users. The ring colour is chosen per variant to ensure 3:1 contrast against the page background:
| Variant | Ring colour | Contrast vs white |
|---|---|---|
| Primary | #1A1A1A Dark | 17.4:1 ✅ AAA |
| Secondary | #F5C518 Wow Yellow | 1.6:1 — visible due to dark button creating visual grouping |
| Ghost | #1A1A1A Dark | 17.4:1 ✅ AAA |
| Danger | #C0392B Danger red | 5.4:1 ✅ AA |
disabled vs aria-disabled
{/* Native disabled — removed from tab order, no events fire */}
<Button disabled>Not available</Button>
{/* aria-disabled — stays focusable, announces as disabled, click fires
but you suppress the action in onClick */}
<Button
aria-disabled
onClick={(e) => {
if (e.currentTarget.getAttribute('aria-disabled')) {
e.preventDefault();
showTooltip('Complete your details to continue');
}
}}
>
Continue
</Button>
Use aria-disabled when you want users to discover the button exists and understand why it is temporarily inactive. A tooltip or inline message should explain what they need to do to enable it. See States → Disabled for the underlying product decision on when to disable at all.
Loading state announcements
When loading becomes true, a visually-hidden string is injected into the button. Screen readers re-announce the button label including this text when focus remains on the button. Because the visual label does not change, sighted users see the spinner instead.
{/* aria output while loading:
"Checking availability — Loading, please wait, button" */}
<Button loading loadingLabel="Loading, please wait">
Checking availability
</Button>
For long operations, also use aria-live on a status region elsewhere in the page to announce completion.
Icon-only buttons
Every button with no visible text must have an aria-label. Without it, screen readers announce only the button role with no name — the worst possible experience for non-sighted users.
{/* ✅ Correct */}
<Button
variant="ghost"
aria-label="Add to wishlist"
iconLeft={<Heart size={16} />}
/>
{/* ❌ Incorrect — screen reader announces "button" with no name */}
<Button variant="ghost" iconLeft={<Heart size={16} />} />
Colour contrast
| Variant | Foreground | Background | Ratio | WCAG |
|---|---|---|---|---|
| Primary | #1A1A1A | #F5C518 | 10.7:1 | ✅ AAA |
| Secondary | #FFFFFF | #1A1A1A | 17.4:1 | ✅ AAA |
| Ghost | #1A1A1A | transparent | 17.4:1 on white page | ✅ AAA |
| Danger | #FFFFFF | #C0392B | 5.4:1 | ✅ AA |
Related components
- Link — use instead of Button whenever the action is navigation to a URL rather than a state change. Button itself can render as a link (
hrefprop) when it needs to look like a button while behaving like one. - Button groups are a layout pattern documented above (Button groups), not a separate component — there is no standalone
ButtonGroupin the library; compose multipleButtoninstances with a flex container andgap-3.