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
<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.
- 1Is this the one thing the screen exists for?One per screen. Never two breathing buttons side by side.
cta - 2Is it the main action inside a card, form or modal?Book ticket, Send request, Save profile.
pill - 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 - 4Is it tertiary — skip, close, back, edit?link inside text, ghost everywhere else.
ghost or link - 5Does it delete or cancel something for good?Pair it with a confirm step that says what will be lost.
destructive - 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.
The single hero action on a screen. Breathing gradient, shimmer, lift, press and ripple come from the global .wd-cta class.
<Button variant="cta">Find your next festival</Button>Primary action inside a card, form or modal. Solid red with the brand lip shadow.
<Button variant="pill">Book ticket</Button>Secondary action that should still feel warm — next to a pill, or on its own in a list row.
<Button variant="soft">Add to my plan</Button>The quiet alternative beside a cta or pill. White surface, red border, red text.
<Button variant="outline-pill">See the lineup</Button>Core variants
The shadcn set — admin, settings, dense tools, dialogs.
defaultPlain primary for admin and settings screens, where brand flourish is noise.
secondaryNeutral filled action in tools and admin.
outlineNeutral alternative to default.
ghostTertiary actions, toolbar buttons, close buttons.
linkInline text action inside a sentence or under a form.
destructiveIrreversible 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.
<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
<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.
| Variant | Rest / hover | Disabled | Loading |
|---|---|---|---|
| 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.
<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.
<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>As a link
If it goes somewhere, it's a link. as-child passes the button styles to NuxtLink, which renders a real <a href>.
<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.
<!-- 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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| loading | boolean | false | Shows a spinner, sets aria-busy="true" and disables the button. Keep the label — change it to the running verb ("Booking…"). |
| disabled | boolean | false | Native disabled. Dimmed, desaturated, cursor not-allowed; the cta stops animating. |
| as | string | Component | 'button' | Element or component to render. Leave as button for actions. |
| asChild | boolean | false | Render the single child (e.g. NuxtLink) with the button styles instead of a wrapper. Use for navigation. |
| class | string | 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.
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 anddisabledwork for free. Never put@clickon a div. - Navigation is a link, not a button: use
as-childwithNuxtLinkso it renders an<a href>people can open in a new tab. - Icon-only buttons (
icon,icon-round) need anaria-label, and the icon getsaria-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-noneoverrides. loadingsetsaria-busyand keeps the label in the DOM, so screen readers still announce what is running.- The cta’s breathing and shimmer stop under
prefers-reduced-motionand when disabled. - Uppercase is visual only (CSS) — write labels in sentence case in the source so screen readers don’t spell them out.