Skip to content

Button

A versatile button component with multiple variants, colors, sizes, and states. Includes both standalone buttons and button groups for organizing related actions. Built with accessibility in mind and fully customizable through CSS custom properties.

Features

Button

  • Accessible: Full keyboard support, ARIA attributes, screen reader friendly
  • 6 Semantic Colors: primary, secondary, info, success, warning, error
  • 7 Variants: solid, flat, bordered, outline, ghost, text, frost
  • States: loading, disabled
  • 3 Sizes: sm, md, lg
  • Border Effects: animated border via effect="shine" (neon sweep) or effect="rainbow" (color cycle)
  • Link Mode: renders as an accessible <a role="button"> when href is set
  • Customizable: CSS custom properties for styling

Button Group

  • 2 Orientations: horizontal, vertical
  • Attached Mode: Connect buttons with shared borders
  • Full Width: Buttons expand to fill container
  • Attribute Propagation: Automatically apply size, variant, and color to all children

Source Code

View Source Code
ts
import { define, useField, html, inject, prop } from '@vielzeug/craft';
import { computed } from '@vielzeug/ripple';

import type { ButtonType, ComponentSize, LinkTarget, RoundedSize, ThemeColor } from '../../types';

import { commonProps } from '../../shared';
import {
  coarsePointerMixin,
  colorThemeMixin,
  disabledLoadingMixin,
  forcedColorsMixin,
  frostVariantMixin,
  rainbowEffectMixin,
  reducedMotionMixin,
  roundedVariantMixin,
  shineEffectMixin,
  sizeVariantMixin,
} from '../../styles';
import { useLinkProps } from '../../utils';
import { BUTTON_GROUP_CTX } from '../button-group/button-group';
import { useFormAction } from '../shared';
import componentStyles from './button.css?inline';

export const BUTTON_VARIANTS = ['solid', 'flat', 'bordered', 'outline', 'ghost', 'text', 'frost'] as const;

/** Visual variant for sg-button — derived from BUTTON_VARIANTS for a single source of truth. */
export type ButtonVariant = (typeof BUTTON_VARIANTS)[number];

/** Animated border effect for sg-button. */
export type ButtonEffect = 'shine' | 'rainbow';

/** Button component properties */
export type SgButtonProps = {
  /** Theme color */
  color?: ThemeColor;
  /** Disable interaction */
  disabled?: boolean;
  /** Animated border effect: 'shine' (color-aware neon sweep) or 'rainbow' */
  effect?: ButtonEffect;
  /** Full width button (100% of container) */
  fullwidth?: boolean;
  /** When set, renders an `<a>` instead of `<button>` */
  href?: string;
  /** Icon-only mode (square aspect ratio, no padding) */
  iconOnly?: boolean;
  /** Accessible label for the inner button — required for icon-only buttons */
  label?: string;
  /** Show loading state with spinner */
  loading?: boolean;
  /** Link rel attribute (requires href) */
  rel?: string;
  /** Border radius size */
  rounded?: RoundedSize;
  /** Component size */
  size?: ComponentSize;
  /** Link target (requires href) */
  target?: LinkTarget;
  /** HTML button type attribute */
  type?: ButtonType;
  /** Visual style variant */
  variant?: ButtonVariant;
};

/**
 * A customizable button component with multiple variants, sizes, and states.
 * Supports icons, loading states, and animated border effects.
 *
 * @element sg-button
 *
 * @attr {string} type - HTML button type: 'button' | 'submit' | 'reset'
 * @attr {boolean} disabled - Disable button interaction
 * @attr {boolean} loading - Show loading state with spinner
 * @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'frost'
 * @attr {string} size - Button size: 'sm' | 'md' | 'lg'
 * @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
 * @attr {string} effect - Animated border effect: 'shine' | 'rainbow'
 * @attr {boolean} icon-only - Icon-only mode (square aspect ratio, no padding)
 * @attr {boolean} fullwidth - Full width button (100% of container)
 *
 * @fires click - Emitted when button is clicked (unless disabled/loading)
 *
 * @slot - Button content (text, icons, etc.)
 * @slot prefix - Content before the button text (e.g., icons)
 * @slot suffix - Content after the button text (e.g., icons, badges)
 *
 * @part button - The inner button or anchor element
 * @part loader - The loading spinner element
 * @part content - The button content wrapper
 *
 * @cssprop --button-bg - Background color override
 * @cssprop --button-color - Text color override
 * @cssprop --button-border - Border width override
 * @cssprop --button-border-color - Border color override
 * @cssprop --button-radius - Border radius override
 * @cssprop --button-padding - Inner padding override
 * @cssprop --button-gap - Gap between icon and text override
 * @cssprop --button-font-size - Font size override
 * @cssprop --button-frost-active-bg - Background when pressed (frost variant)
 * @cssprop --button-frost-active-border-color - Border color when pressed (frost variant)
 *
 * @example
 * ```html
 * <sg-button variant="solid" color="primary">Click me</sg-button>
 * <sg-button loading color="success">Processing...</sg-button>
 * <sg-button effect="shine" color="primary">Shine</sg-button>
 * <sg-button effect="rainbow" variant="frost">Rainbow</sg-button>
 * ```
 */
export const BUTTON_TAG = 'sg-button' as const;
define<SgButtonProps>(BUTTON_TAG, {
  formAssociated: true,
  props: {
    ...commonProps,
    effect: prop.string<ButtonEffect>(),
    fullwidth: prop.bool(false),
    href: prop.string(),
    iconOnly: prop.bool(false),
    label: prop.string(),
    rel: prop.string(),
    target: prop.string<LinkTarget>(),
    type: prop.oneOf(['button', 'submit', 'reset'] as const, 'button'),
    variant: prop.oneOf(BUTTON_VARIANTS, 'solid'),
  },
  setup(props, { bind, el }) {
    // Prefer group context over own props for color/size/variant.
    const groupCtx = inject(BUTTON_GROUP_CTX);
    const effectiveColor = computed(() => groupCtx?.color.value ?? props.color.value);
    const effectiveSize = computed(() => groupCtx?.size.value ?? props.size.value);
    const effectiveVariant = computed(() => groupCtx?.variant.value ?? props.variant.value);

    const isDisabled = computed(() => !!(props.disabled.value || props.loading.value));

    // isLink and effectiveRel are computed from signals — correct even if href changes at runtime.
    const { effectiveRel, isLink } = useLinkProps(props.href, props.rel, props.target);

    // Form association: relay submit/reset clicks to the associated form.
    // The inner <button> always has type="button" so shadow DOM never drives native form actions.
    // getForm() returns null for link mode at runtime, so no form actions fire.
    const formField = useField({
      disabled: isDisabled,
      toFormValue: () => null,
      value: computed(() => ''),
    });

    const handleClick = useFormAction(
      () => (isLink.value ? null : formField.internals.form),
      props.type,
      isDisabled,
      el,
    );

    // ARIA attributes live on the host; delegatesFocus ensures AT reads them correctly.
    bind({
      attr: {
        'aria-busy': props.loading,
        'aria-disabled': isDisabled,
        'aria-label': props.label,
        color: effectiveColor,
        effect: props.effect,
        size: effectiveSize,
        variant: effectiveVariant,
      },
    });

    return html`
      ${() =>
        isLink.value
          ? html`<a
              part="button"
              :href="${props.href}"
              :target="${props.target}"
              :rel="${effectiveRel}"
              role="button"
              :aria-busy="${props.loading}"
              @click="${handleClick}">
              <span class="loader" part="loader" aria-label="Loading" ?hidden=${() => !props.loading.value}></span>
              <slot name="prefix"></slot>
              <span class="content" part="content"><slot></slot></span>
              <slot name="suffix"></slot>
            </a>`
          : html`<button part="button" type="button" ?disabled=${isDisabled} @click="${handleClick}">
              <span class="loader" part="loader" aria-label="Loading" ?hidden=${() => !props.loading.value}></span>
              <slot name="prefix"></slot>
              <span class="content" part="content"><slot></slot></span>
              <slot name="suffix"></slot>
            </button>`}
    `;
  },
  shadow: { delegatesFocus: true },
  styles: [
    colorThemeMixin,
    coarsePointerMixin,
    reducedMotionMixin,
    roundedVariantMixin,
    forcedColorsMixin,
    sizeVariantMixin({
      lg: {
        fontSize: 'var(--text-base)',
        gap: 'var(--size-2-5)',
        height: 'var(--size-12)',
        iconSize: 'var(--size-6)',
        lineHeight: 'var(--leading-relaxed)',
        padding: 'var(--size-2-5) var(--size-5)',
      },
      sm: {
        fontSize: 'var(--text-sm)',
        gap: 'var(--size-1-5)',
        height: 'var(--size-8)',
        iconSize: 'var(--size-4)',
        lineHeight: 'var(--leading-tight)',
        padding: 'var(--size-1-5) var(--size-3)',
      },
    }),
    frostVariantMixin('button'),
    rainbowEffectMixin('button'),
    shineEffectMixin('button'),
    disabledLoadingMixin,
    componentStyles,
  ],
});
View Source Code (Button Group)
ts
import { createContext, define, html, prop } from '@vielzeug/craft';
import { type ReadonlySignal } from '@vielzeug/ripple';

import type { ComponentSize, ThemeColor } from '../../shared';
import type { ButtonVariant } from '../button/button';

import { sizableBundle, themableBundle } from '../../shared';
import styles from './button-group.css?inline';

/** Button group properties */
export type SgButtonGroupProps = {
  /** Join buttons together into a single unit */
  attached?: boolean;
  /** Theme color tint for all child buttons */
  color?: ThemeColor;
  /** Group children span full width */
  fullwidth?: boolean;
  /** Label for screen readers */
  label?: string;
  /** Layout direction */
  orientation?: 'horizontal' | 'vertical';
  /** Shared size for all child buttons */
  size?: ComponentSize;
  /** Shared visual variant for all child buttons */
  variant?: ButtonVariant;
};

/** Shared context propagated from sg-button-group to child sg-button elements */
export type ButtonGroupContext = {
  color: ReadonlySignal<ThemeColor | undefined>;
  size: ReadonlySignal<ComponentSize | undefined>;
  variant: ReadonlySignal<ButtonVariant | undefined>;
};

export const BUTTON_GROUP_CTX = createContext<ButtonGroupContext | undefined>('SgButtonGroup');

/**
 * A container for grouping related buttons.
 * Child `sg-button` components automatically inherit the group's color, size, and variant.
 *
 * @element sg-button-group
 *
 * @attr {boolean} attached - Join buttons together into a unit
 * @attr {string} color - Shared color tint: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {boolean} fullwidth - Group spans full width
 * @attr {string} label - Accessible label
 * @attr {string} orientation - 'horizontal' | 'vertical'
 * @attr {string} size - Shared size: 'sm' | 'md' | 'lg'
 * @attr {string} variant - Shared visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'frost'
 *
 * @slot - Place sg-button elements here
 *
 * @cssprop --group-gap - Gap between buttons (non-attached mode)
 * @cssprop --group-radius - Border radius of the group container
 * @cssprop --button-radius - Passed through to child buttons to control corner radius
 * @cssprop --button-border-start - Passed through to suppress start borders in attached mode
 * @cssprop --button-border-top - Passed through to suppress top borders in vertical attached mode
 * @part group - Group container.
 * @example
 * ```html
 * <sg-button-group attached>
 *   <sg-button variant="solid" color="primary">Save</sg-button>
 *   <sg-button variant="solid" color="primary">Save &amp; Continue</sg-button>
 * </sg-button-group>
 * <sg-button-group orientation="vertical" size="sm">
 *   <sg-button>Top</sg-button>
 *   <sg-button>Middle</sg-button>
 *   <sg-button>Bottom</sg-button>
 * </sg-button-group>
 * ```
 */
export const BUTTON_GROUP_TAG = 'sg-button-group' as const;
define<SgButtonGroupProps>(BUTTON_GROUP_TAG, {
  props: {
    ...themableBundle,
    ...sizableBundle,
    attached: prop.bool(false),
    fullwidth: prop.bool(false),
    label: prop.string(),
    orientation: prop.string<'horizontal' | 'vertical'>(),
    variant: prop.string(),
  },
  setup(props, { provide }) {
    provide(BUTTON_GROUP_CTX, {
      color: props.color!,
      size: props.size!,
      variant: props.variant!,
    });

    return html`
      <div class="button-group" part="group" role="group" :aria-label="${props.label}">
        <slot></slot>
      </div>
    `;
  },
  styles: [styles],
});

Basic Usage

Standalone Button

html
<sg-button variant="solid" color="primary">Click me</sg-button>

Button Group

html
<sg-button-group>
  <sg-button>First</sg-button>
  <sg-button>Second</sg-button>
  <sg-button>Third</sg-button>
</sg-button-group>

Visual Options

Variants

The button comes with seven visual variants to match different levels of emphasis.

PreviewCode
RTL

Frost Variant

Modern frost effect with backdrop blur that adapts based on color:

  • Without color: Subtle canvas-based frost overlay
  • With color: Frosted glass effect with colored tint

Best Used With

Frost variant works best when placed over colorful backgrounds or images to showcase the blur and transparency effects.

PreviewCode
RTL

Animated Border Effects

Use the effect attribute to add an animated border effect.

Rainbow

An Okabe-Ito colorblind-safe rainbow sweep — great for highlighting call-to-action buttons.

PreviewCode
RTL

Shine

A neon comet shimmer that follows the color attribute — two arcs sweep the border using the button's own theme color.

PreviewCode
RTL

Accessibility

Both effects automatically pause their animation when the user has reduced motion enabled (prefers-reduced-motion: reduce).

Colors

Six semantic colors for different contexts.

PreviewCode
RTL

Sizes

Three sizes for different contexts.

PreviewCode
RTL

States

Loading

Show a loading spinner and prevent interaction during async operations.

PreviewCode
RTL

Disabled

Prevent interaction and reduce opacity for unavailable actions.

PreviewCode
RTL

Icons & Extras

With Icons

Add prefix or suffix icons using slots.

PreviewCode
RTL

Rounded (Custom Border Radius)

Use the rounded attribute to apply border radius from the theme. Use it without a value (or rounded="full") for pill shape, or specify a theme value like "lg", "xl", etc.

PreviewCode
RTL

Full Width

Button expands to fill the full width of its container.

PreviewCode
RTL

When href is provided, sg-button renders as an <a role="button"> element instead of <button>. All visual variants, sizes, states, and slots behave exactly the same.

PreviewCode
RTL

Security

Always set rel="noopener noreferrer" when using target="_blank" to prevent tabnapping attacks.

Button Groups

Orientation

Group buttons in horizontal or vertical layouts.

Horizontal (Default)

PreviewCode
RTL

Vertical

PreviewCode
RTL

Attached Mode

Remove spacing and connect buttons with shared borders for segmented controls.

PreviewCode
RTL

Attribute Propagation

Apply size, variant, or color to all child buttons automatically via the parent group.

PreviewCode
RTL

Full Width

Buttons expand to fill the container equally.

PreviewCode
RTL

API Reference

sg-button Attributes

AttributeTypeDefaultDescription
variant'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'frost''solid'Visual style variant
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Semantic color (uncolored default when omitted)
size'sm' | 'md' | 'lg''md'Button size
type'button' | 'submit' | 'reset''button'HTML button type for form association
disabledbooleanfalseDisable the button
loadingbooleanfalseShow loading spinner; also disables interaction
effect'shine' | 'rainbow'Animated border effect: neon comet (shine) or colorful sweep (rainbow)
icon-onlybooleanfalseSquare aspect ratio, no padding — use with label for accessibility
labelstringAccessible label set as aria-label on the host — required for icon-only
fullwidthbooleanfalseExpand to full container width
rounded'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'Border radius size; omit value (or use 'full') for pill shape
hrefstringURL — renders as <a role="button"> instead of <button>
target'_blank' | '_self' | '_parent' | '_top'Link target (requires href)
relstringLink rel attribute (requires href)

sg-button-group Attributes

AttributeTypeDefaultDescription
orientation'horizontal' | 'vertical''horizontal'Group layout direction
attachedbooleanfalseRemove spacing and connect buttons with borders
fullwidthbooleanfalseButtons expand equally to fill container
labelstringAccessible aria-label for the group container
size'sm' | 'md' | 'lg'Apply size to all child buttons
variant'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'frost'Apply variant to all child buttons
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Apply color to all child buttons

Slots

sg-button

SlotDescription
(default)Button content (text, icons, etc.)
prefixContent before the main content
suffixContent after the main content

sg-button-group

SlotDescription
(default)Child button elements

Events

sg-button

EventDetailDescription
clickMouseEventNative click event — standard MouseEvent, not suppressed

sg-button-group

No events.

Parts

sg-button

PartDescription
buttonThe inner <button> or <a> element
loaderThe loading spinner
contentThe text/content wrapper inside the button

sg-button-group

PartDescription
groupGroup container

CSS Custom Properties

sg-button

PropertyDescriptionDefault
--button-bgBackground color overrideVariant-dependent
--button-colorText color overrideVariant-dependent
--button-borderBorder width overridevar(--border)
--button-border-colorBorder color overrideVariant-dependent
--button-border-topTop border width override (used by attached group)
--button-border-startInline-start border width override (used by attached group)
--button-radiusBorder radius overridevar(--rounded-lg)
--button-paddingInner padding overrideSize-dependent
--button-gapGap between icon and text overrideSize-dependent
--button-font-sizeFont size overrideSize-dependent
--button-frost-active-bgBackground when hovered/pressed in frost variantVariant-dependent
--button-frost-active-border-colorBorder color when hovered/pressed in frost variantVariant-dependent

sg-button-group

PropertyDescriptionDefault
--group-gapSpacing between buttons (non-attached mode)var(--size-2)
--group-radiusBorder radius applied to first/last buttons in attached modevar(--rounded-lg)

Accessibility

Both components follow WAI-ARIA best practices.

sg-button

Keyboard Navigation
  • Enter and Space activate the button.
  • Tab moves focus to/from the button.
Screen Readers
  • Announces button role and label.
  • aria-disabled when disabled.
  • aria-busy when loading.
  • Icon-only buttons must have the label attribute — it is set as aria-label on the host element.

sg-button-group

Semantic Structure
  • Automatically includes role="group" on the container.
  • Use the label attribute to provide context for screen readers (e.g., label="Text alignment").
Keyboard Navigation
  • Tab moves focus between buttons.
Screen Readers
  • Buttons within a group are announced in context.

Best Practices

sg-button

Do:

  • Use semantic colors to communicate intent.
  • Always set the label attribute on icon-only buttons so screen readers announce the action.
  • Use loading state for async operations.

Don't:

  • Use multiple primary buttons in the same context.
  • Nest interactive elements inside buttons.

sg-button-group

Do:

  • Use attached mode for related segmented controls.
  • Use fullwidth for mobile-optimized layouts or primary actions.
  • Provide an aria-label when the group's purpose isn't clear from the content.

Don't:

  • Mix too many variants or colors within a single group.
  • Use vertical orientation for more than 4-5 buttons if possible.