Skip to content
Velocity X Docs
← All components

Button Set

Free · MIT

One button primitive covering primary/secondary/ghost/destructive variants, three sizes, a loading spinner state, and an icon-only mode.

Category
Core UI
Framework
astro
Version
1.0.0
Peer deps
none

Install

bash
velocityx add button-set

Free and MIT licensed — no account needed. The file lands under src/components/velocity/button-set/ by default (override with --dest), and it's yours to edit from the moment it's written.

Demo

View the live demo on the showcase site ↗

Source

Pulled live from the registry — this is the exact file velocityx add button-set writes into your project right now.

components/velocity/button-set/ButtonSet.astro

astro
---
//    VELOCITY COMPONENT · theming
// 
//    Colours here are Tailwind `primary-*` / `accent-*`, which resolve through
//    the Velocity preset. Out of the box they are a neutral indigo — NOT the
//    Aidxn brand — so this drops into your project without dragging our look in.
// 
//    ⚡ NOT USING VELOCITY? Run this once, in your project root:
// 
//          velocityx theme
// 
//    It writes velocity-preset.cjs + velocity-theme.css. Add the preset to your
//    tailwind.config, import the css, then set your whole brand in ONE place:
// 
//          :root {
//            --vx-brand: custom;
//            --vx-primary-600: 220 38 38;   <- your action colour
//            --vx-primary-700: 185 28 28;
//          }
// 
//    ⚠️  Raw RGB channels, space separated — NOT hex. `#DC2626` here silently
//        breaks every /opacity variant in the library (bg-primary-600/20 etc).
// 
//    Prefer not to install anything? Find-and-replace `primary-` with your own
//    scale name. Nothing else in this file depends on Velocity.
/**
 * ButtonSet — the single button primitive covering every state the library
 * needs: primary/secondary/ghost/destructive variants, sm/md/lg sizes,
 * loading (spinner replaces label, button stays the same width via
 * aria-busy + disabled), and icon-only (square, requires aria-label).
 *
 * Renders an <a> when `href` is passed, a <button> otherwise — same visual
 * system either way.
 *
 * Usage:
 *   <ButtonSet variant="primary">Save changes</ButtonSet>
 *   <ButtonSet variant="destructive" size="sm">Delete</ButtonSet>
 *   <ButtonSet variant="secondary" loading>Saving…</ButtonSet>
 *   <ButtonSet variant="ghost" icon aria-label="Settings"><svg .../></ButtonSet>
 */
export interface Props {
  variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
  size?: 'sm' | 'md' | 'lg';
  href?: string;
  type?: 'button' | 'submit' | 'reset';
  loading?: boolean;
  disabled?: boolean;
  icon?: boolean;
  'aria-label'?: string;
  class?: string;
}
const {
  variant = 'primary',
  size = 'md',
  href,
  type = 'button',
  loading = false,
  disabled = false,
  icon = false,
  class: className = '',
  ...rest
} = Astro.props;

const VARIANTS = {
  primary: 'bg-primary-600 text-white hover:bg-primary-500 focus-visible:ring-primary-600 disabled:bg-primary-300 dark:disabled:bg-primary-900',
  secondary: 'bg-white text-secondary-800 border border-secondary-300 hover:bg-secondary-50 dark:bg-secondary-900 dark:text-secondary-200 dark:border-secondary-700 dark:hover:bg-secondary-800 focus-visible:ring-primary-600',
  ghost: 'bg-transparent text-secondary-700 hover:bg-secondary-100 dark:text-secondary-300 dark:hover:bg-secondary-800 focus-visible:ring-primary-600',
  destructive: 'bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-600 disabled:bg-red-300',
} as const;

const SIZES = {
  sm: icon ? 'h-8 w-8' : 'h-8 px-3 text-xs gap-1.5',
  md: icon ? 'h-10 w-10' : 'h-10 px-4 text-sm gap-2',
  lg: icon ? 'h-12 w-12' : 'h-12 px-6 text-base gap-2',
} as const;

const Tag = href ? 'a' : 'button';
const isDisabled = disabled || loading;
---

<Tag
  href={href}
  type={!href ? type : undefined}
  disabled={!href && isDisabled ? true : undefined}
  aria-disabled={href && isDisabled ? 'true' : undefined}
  aria-busy={loading ? 'true' : undefined}
  tabindex={href && isDisabled ? -1 : undefined}
  class:list={[
    'inline-flex items-center justify-center rounded-xl font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-secondary-950 disabled:cursor-not-allowed disabled:opacity-70',
    VARIANTS[variant],
    SIZES[size],
    className,
  ]}
  {...rest}
>
  {loading && (
    <svg aria-hidden="true" class="h-4 w-4 animate-spin motion-reduce:animate-none" viewBox="0 0 24 24" fill="none">
      <circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" />
      <path class="opacity-90" d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" />
    </svg>
  )}
  {!loading && <slot />}
  {loading && !icon && <span class="sr-only">Loading</span>}
</Tag>

Theming

Uses the shared primary-* / accent-* scale — see Theming if colours look unstyled after install.