Skip to content
Seamless UI

Button

Save

A 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 button

Pre-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-run

Usage

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

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "destructive""primary"Visual style.
size"sm" | "md" | "lg""md"Height and padding.
loadingbooleanfalseFades 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.
...propsComponentProps<"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.tsxShow
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.vueShow
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>