Button
SaveA button with primary, secondary, ghost, and destructive variants, a 100ms press scale, and a loading state that keeps its width.
- Category
- Core
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React, Vite + Vue
- Styling
- Tailwind CSS v4
Preview
Button preview
Installation
npx @seamlessui/cli add buttonPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add button --dry-runUsage
button-example.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/seamless/ui/button";
export default function ButtonExample() {
const [saving, setSaving] = useState(false);
function save() {
setSaving(true);
window.setTimeout(() => setSaving(false), 1600);
}
return (
<div className="flex flex-wrap items-center justify-center gap-2.5">
<Button>Deploy</Button>
<Button variant="secondary">
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none">
<path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
New agent
</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="destructive">Delete</Button>
<Button variant="secondary" loading={saving} onClick={save}>
Save changes
</Button>
</div>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "primary" | "secondary" | "ghost" | "destructive" | "primary" | Visual style. |
| size | "sm" | "md" | "lg" | "md" | Height and padding. |
| loading | boolean | false | Fades the label out and shows a centered spinner in its place, so the width stays the same. Sets aria-busy and ignores clicks; the button stays focusable and keeps its accessible name. |
| ...props | ComponentProps<"button"> | — | Any native button attribute. type defaults to "button". |
Accessibility
- Renders a native <button>, so Enter and Space work without extra code.
- Visible focus ring on keyboard focus only (focus-visible).
- While loading, focus is kept and aria-busy is announced instead of disabling the button.
- The press scale, label fade, and spinner rotation stop when reduced motion is preferred; the spinner is then a static arc.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/button.tsxbutton- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/button/0.2.0.json.
ui/button.tsxShowHide
ui/button.tsx
"use client";
import type { ComponentProps, MouseEvent } from "react";
function cx(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(" ");
}
const base = cx(
"relative isolate inline-flex select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium",
// Hover colors ease over 150ms; the press scale answers faster, in 100ms.
"transition-[background-color,border-color,color,box-shadow,scale,opacity] duration-150 ease-out active:duration-100",
"motion-safe:active:scale-[0.97] motion-reduce:transition-none",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
"disabled:pointer-events-none disabled:opacity-45 aria-busy:cursor-progress",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
);
const variants = {
primary: cx(
"bg-zinc-950 text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.12),0_1px_2px_rgb(0_0_0/0.2)] hover:bg-zinc-800",
"dark:bg-white dark:text-black dark:shadow-[inset_0_-1px_0_rgb(0_0_0/0.12)] dark:hover:bg-zinc-200",
),
secondary: cx(
"border border-zinc-950/12 bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)] hover:border-zinc-950/20 hover:bg-zinc-950/[0.03]",
"dark:border-white/15 dark:bg-black dark:text-white dark:shadow-none dark:hover:border-white/25 dark:hover:bg-white/[0.06]",
),
ghost: cx(
"text-zinc-600 hover:bg-zinc-950/[0.04] hover:text-zinc-950",
"dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
),
destructive:
"bg-red-600 text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.16),0_1px_2px_rgb(0_0_0/0.2)] hover:bg-red-700 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.16)]",
} as const;
// A leading icon pulls the start padding in by 2px so the content looks optically centered.
const sizes = {
sm: "h-8 px-3 text-sm has-[>[data-slot=label]>svg:first-child]:pl-2.5",
md: "h-9 px-3.5 text-sm has-[>[data-slot=label]>svg:first-child]:pl-3",
lg: "h-10 px-4 text-[15px] has-[>[data-slot=label]>svg:first-child]:pl-3.5",
} as const;
export type ButtonVariant = keyof typeof variants;
export type ButtonSize = keyof typeof sizes;
export interface ButtonProps extends ComponentProps<"button"> {
variant?: ButtonVariant;
size?: ButtonSize;
/**
* Swaps the label for a spinner and ignores clicks while keeping the button focusable.
* The label stays in the layout (only faded), so the button keeps its width and accessible name.
*/
loading?: boolean;
}
export function Button({
variant = "primary",
size = "md",
loading = false,
type = "button",
className,
onClick,
children,
...props
}: ButtonProps) {
function handleClick(event: MouseEvent<HTMLButtonElement>) {
if (loading) {
event.preventDefault();
return;
}
onClick?.(event);
}
return (
<button
type={type}
aria-busy={loading || undefined}
data-variant={variant}
data-loading={loading || undefined}
className={cx(base, variants[variant], sizes[size], className)}
onClick={handleClick}
{...props}
>
<span
data-slot="label"
className={cx(
"inline-flex items-center gap-[inherit]",
"transition-[opacity,scale] duration-150 ease-out motion-reduce:transition-none",
loading && "opacity-0 motion-safe:scale-[0.96]",
)}
>
{children}
</span>
{loading ? <Spinner /> : null}
</button>
);
}
function Spinner() {
return (
<span aria-hidden="true" className="absolute inset-0 flex items-center justify-center">
<svg
viewBox="0 0 16 16"
fill="none"
className={cx(
"motion-safe:animate-spin",
"transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
"starting:opacity-0 motion-safe:starting:scale-90",
)}
>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
);
}Vue
A native Vue 3 implementation ships in the Vue pilot. Run the same command in a Vite + Vue project; the CLI detects Vue and installs these files instead. The live preview above is the React version; the Vue example below is built in a clean Vite + Vue app by CI.
button-example.vue
<script setup lang="ts">
import { ref } from "vue";
import Button from "@/components/seamless/ui/Button.vue";
const saving = ref(false);
function save() {
saving.value = true;
setTimeout(() => (saving.value = false), 1200);
}
</script>
<template>
<div class="flex flex-wrap items-center gap-3">
<Button>Deploy</Button>
<Button variant="secondary">Preview</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="destructive">Delete</Button>
<Button :loading="saving" @click="save">{{ saving ? "Saving" : "Save" }}</Button>
</div>
</template>src/components/seamless/ui/Button.vuevue/button@0.2.0
ui/Button.vueShowHide
ui/Button.vue
<script lang="ts">
export type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive";
export type ButtonSize = "sm" | "md" | "lg";
</script>
<script setup lang="ts">
import { computed, useAttrs } from "vue";
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
variant?: ButtonVariant;
size?: ButtonSize;
/**
* Swaps the label for a spinner and ignores clicks while keeping the button focusable.
* The label stays in the layout (only faded), so the button keeps its width and accessible name.
*/
loading?: boolean;
type?: "button" | "submit" | "reset";
}>(),
{ variant: "primary", size: "md", loading: false, type: "button" },
);
const attrs = useAttrs();
const base = [
"relative isolate inline-flex select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium",
// Hover colors ease over 150ms; the press scale answers faster, in 100ms.
"transition-[background-color,border-color,color,box-shadow,scale,opacity] duration-150 ease-out active:duration-100",
"motion-safe:active:scale-[0.97] motion-reduce:transition-none",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
"disabled:pointer-events-none disabled:opacity-45 aria-busy:cursor-progress",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
].join(" ");
const variants: Record<ButtonVariant, string> = {
primary:
"bg-zinc-950 text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.12),0_1px_2px_rgb(0_0_0/0.2)] hover:bg-zinc-800 " +
"dark:bg-white dark:text-black dark:shadow-[inset_0_-1px_0_rgb(0_0_0/0.12)] dark:hover:bg-zinc-200",
secondary:
"border border-zinc-950/12 bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)] hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] " +
"dark:border-white/15 dark:bg-black dark:text-white dark:shadow-none dark:hover:border-white/25 dark:hover:bg-white/[0.06]",
ghost: "text-zinc-600 hover:bg-zinc-950/[0.04] hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
destructive:
"bg-red-600 text-white shadow-[inset_0_1px_0_rgb(255_255_255/0.16),0_1px_2px_rgb(0_0_0/0.2)] hover:bg-red-700 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.16)]",
};
// A leading icon pulls the start padding in by 2px so the content looks optically centered.
const sizes: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-sm has-[>[data-slot=label]>svg:first-child]:pl-2.5",
md: "h-9 px-3.5 text-sm has-[>[data-slot=label]>svg:first-child]:pl-3",
lg: "h-10 px-4 text-[15px] has-[>[data-slot=label]>svg:first-child]:pl-3.5",
};
// Pass every attribute through except the click listener, which only runs when not loading.
const passthrough = computed(() => {
const { onClick: _onClick, class: _class, ...rest } = attrs;
return rest;
});
function handleClick(event: MouseEvent) {
if (props.loading) {
event.preventDefault();
return;
}
const listener = attrs.onClick as ((event: MouseEvent) => void) | ((event: MouseEvent) => void)[] | undefined;
for (const fn of Array.isArray(listener) ? listener : listener ? [listener] : []) fn(event);
}
</script>
<template>
<button
v-bind="passthrough"
:type="props.type"
:aria-busy="props.loading || undefined"
:data-variant="props.variant"
:data-loading="props.loading || undefined"
:class="[base, variants[props.variant], sizes[props.size], attrs.class]"
@click="handleClick"
>
<span
data-slot="label"
:class="[
'inline-flex items-center gap-[inherit]',
'transition-[opacity,scale] duration-150 ease-out motion-reduce:transition-none',
props.loading ? 'opacity-0 motion-safe:scale-[0.96]' : '',
]"
>
<slot />
</span>
<span v-if="props.loading" aria-hidden="true" class="absolute inset-0 flex items-center justify-center">
<svg
viewBox="0 0 16 16"
fill="none"
class="motion-safe:animate-spin transition-[opacity,scale] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none starting:opacity-0 motion-safe:starting:scale-90"
>
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-opacity="0.25" stroke-width="1.5" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</span>
</button>
</template>