Skip to content
Componentsstable

Button

Buttons start things: booking a pass, adding a festival to your plan, sending a request. WeDance has a warm brand set for dancer-facing screens and the plain shadcn set for admin and tools.

Overview

vue
<Button variant="cta" size="lg">Find your next festival <ArrowRight /></Button>
<Button variant="pill">Book ticket</Button>
<Button variant="soft">Add to my plan</Button>
<Button variant="outline-pill">See the lineup</Button>

Import from @/components/ui/button. A survey of 255 hand-rolled <button>s in product pages found about 28 gradient CTAs, 10 solid pills with a lip shadow, 12 soft tinted pills, 14 white outline pills and 39 small uppercase pills — each now one variant away.

Which variant?

Ask these in order and stop at the first yes.

  1. 1Is this the one thing the screen exists for?One per screen. Never two breathing buttons side by side.cta
  2. 2Is it the main action inside a card, form or modal?Book ticket, Send request, Save profile.pill
  3. 3Is it a secondary action that sits next to the primary?soft on cream and white; outline-pill when the background is busy.soft or outline-pill
  4. 4Is it tertiary — skip, close, back, edit?link inside text, ghost everywhere else.ghost or link
  5. 5Does it delete or cancel something for good?Pair it with a confirm step that says what will be lost.destructive
  6. 6Admin, settings or a dense tool?The shadcn set. Square-ish, sentence case, no lip.default / secondary / outline

Brand variants

Dancer-facing screens. Pills: rounded, bold, uppercase, tracked.

cta

The single hero action on a screen. Breathing gradient, shimmer, lift, press and ripple come from the global .wd-cta class.

vue
<Button variant="cta">Find your next festival</Button>
pill

Primary action inside a card, form or modal. Solid red with the brand lip shadow.

vue
<Button variant="pill">Book ticket</Button>
soft

Secondary action that should still feel warm — next to a pill, or on its own in a list row.

vue
<Button variant="soft">Add to my plan</Button>
outline-pill

The quiet alternative beside a cta or pill. White surface, red border, red text.

vue
<Button variant="outline-pill">See the lineup</Button>

Core variants

The shadcn set — admin, settings, dense tools, dialogs.

default

Plain primary for admin and settings screens, where brand flourish is noise.

secondary

Neutral filled action in tools and admin.

outline

Neutral alternative to default.

ghost

Tertiary actions, toolbar buttons, close buttons.

link

Inline text action inside a sentence or under a form.

destructive

Irreversible actions. Outlined so it never reads as a primary CTA; fills on hover.

Accent colour

Brand variants re-tint with accent — for a festival's own colour or a category. The accent must reach 4.5:1 against white.

vue
<Button variant="pill" accent="var(--wd-violet-600)">Add to my plan</Button>
<Button variant="soft" accent="var(--wd-cyan-700)">Share ride</Button>
<Button variant="cta" :accent="festival.accentColor">Book ticket</Button>

Sizes

pill-sm · h-8 · text-xs · pill, uppercase
sm · h-9
default · h-10
lg · h-11 · hero CTAs
vue
<Button variant="pill" size="pill-sm">Ask locals</Button>
<Button variant="pill" size="sm">Book ticket</Button>
<Button variant="pill">Book ticket</Button>
<Button variant="pill" size="lg">Book ticket</Button>

States

Hover a button to see its hover state; tab to see the focus ring.

VariantRest / hoverDisabledLoading
cta
pill
soft
outline-pill
default
secondary
outline
ghost
link
destructive

Loading

Use loading while a request runs — it disables the button, shows a spinner and sets aria-busy.

vue
<Button variant="pill" :loading="sending" @click="send">
  {{ sending ? "Sending request…" : "Send booking request" }}
</Button>

With icons

lucide-vue-next icons size themselves to 16px. Put the icon before the label for objects, after it for direction.

vue
<Button variant="pill"><Ticket aria-hidden="true" /> Book ticket</Button>
<Button variant="soft"><CalendarPlus aria-hidden="true" /> Add to my plan</Button>
<Button variant="cta">Find your next festival <ArrowRight aria-hidden="true" /></Button>
<Button variant="outline-pill" size="icon-round" aria-label="Save to favourites"><Heart aria-hidden="true" /></Button>
<Button variant="ghost" size="icon" aria-label="Close"><X aria-hidden="true" /></Button>

If it goes somewhere, it's a link. as-child passes the button styles to NuxtLink, which renders a real <a href>.

vue
<Button as-child variant="cta" size="lg">
  <NuxtLink to="/festivals">Find your next festival</NuxtLink>
</Button>
<Button as-child variant="link">
  <NuxtLink to="/docs/design">Back to the design system</NuxtLink>
</Button>

Replacing hand-rolled buttons

Pages still carry inline gradients and lip shadows. When you touch one, swap it.

vue
<!-- Before: hand-rolled (≈28 copies across pages) -->
<button
  class="inline-flex items-center gap-2 px-6 py-3 rounded-full text-white text-sm font-bold uppercase tracking-wider"
  style="background:linear-gradient(135deg, var(--wd-red-600), var(--wd-orange-500)); box-shadow: 0 4px 0 -1px var(--wd-red-800);"
  @click="openAuth">
  Join WeDance
</button>

<!-- After -->
<Button variant="cta" size="lg" @click="openAuth">Join WeDance</Button>

Props

PropTypeDefaultDescription
variant'cta' | 'pill' | 'soft' | 'outline-pill' | 'default' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive''default'Visual style. Brand variants are pills: rounded, bold, uppercase, tracked.
size'default' | 'sm' | 'lg' | 'pill-sm' | 'icon' | 'icon-sm' | 'icon-lg' | 'icon-round''default'Height and padding. pill-sm is the small uppercase pill; icon-round is a 44px round icon-only button.
loadingbooleanfalseShows a spinner, sets aria-busy="true" and disables the button. Keep the label — change it to the running verb ("Booking…").
disabledbooleanfalseNative disabled. Dimmed, desaturated, cursor not-allowed; the cta stops animating.
asstring | Component'button'Element or component to render. Leave as button for actions.
asChildbooleanfalseRender the single child (e.g. NuxtLink) with the button styles instead of a wrapper. Use for navigation.
classstring | array | object—Extra classes, merged with tailwind-merge — layout only (w-full, mt-4), not colours.

Do and don't

Do — One cta per screen — the action the page exists for. Everything else steps down to pill, soft or outline-pill.

Don't — Two breathing CTAs compete. Neither reads as the main action, and the motion turns into noise.

Pick a pass to continue

Do — Explain why an action is unavailable, right next to it.

Don't — A silently disabled button. People can't tell if it's broken or what to do next.

Do — Destructive for irreversible actions, with a plain label that names what goes.

Don't — A bright filled red button for delete — it looks exactly like Book ticket.

Accessibility

  • Button renders a real <button> — keyboard focus, Enter/Space and disabled work for free. Never put @click on a div.
  • Navigation is a link, not a button: use as-child with NuxtLink so it renders an <a href> people can open in a new tab.
  • Icon-only buttons (icon, icon-round) need an aria-label, and the icon gets aria-hidden="true".
  • Don’t disable without saying why. If “Book ticket” is unavailable, tell people next to it (“Sold out”, “Pick a pass first”) — a dimmed button alone is a dead end.
  • Every variant shows the amber focus ring on keyboard focus. Don’t remove it with outline-none overrides.
  • loading sets aria-busy and keeps the label in the DOM, so screen readers still announce what is running.
  • The cta’s breathing and shimmer stop under prefers-reduced-motion and when disabled.
  • Uppercase is visual only (CSS) — write labels in sentence case in the source so screen readers don’t spell them out.