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
  • 🎨 6 Variants: solid, flat, bordered, outline, ghost, text
  • 🎭 States: loading, disabled
  • 📏 3 Sizes: sm, md, lg
  • 🔗 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 { computed, defineComponent, defineField, fire, html, inject, syncContextProps } from '@vielzeug/craftit';
import { when } from '@vielzeug/craftit/directives';

import type { ButtonType, DisablableProps, RoundedSize, SizableProps, ThemableProps, VisualVariant } from '../../types';

import {
  disabledLoadingMixin,
  forcedColorsMixin,
  formFieldMixins,
  frostVariantMixin,
  rainbowEffectMixin,
  sizeVariantMixin,
} from '../../styles';
import { BUTTON_GROUP_CTX } from '../button-group/button-group';
import componentStyles from './button.css?inline';

/** Button component properties */
export type BitButtonProps = ThemableProps &
  SizableProps &
  DisablableProps & {
    /** 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;
    /** Enable animated rainbow border effect */
    rainbow?: boolean;
    /** Link rel attribute (requires href) */
    rel?: string;
    /** Border radius size */
    rounded?: RoundedSize;
    /** Link target (requires href) */
    target?: '_blank' | '_self' | '_parent' | '_top';
    /** HTML button type attribute */
    type?: ButtonType;
    /** Visual style variant */
    variant?: Exclude<VisualVariant, 'glass'>;
  };

/**
 * A customizable button component with multiple variants, sizes, and states.
 * Supports icons, loading states, and special effects like frost and rainbow.
 *
 * @element bit-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' | 'frost'
 * @attr {string} size - Button size: 'sm' | 'md' | 'lg'
 * @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
 * @attr {boolean} rainbow - Enable animated rainbow border effect
 * @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 button element
 * @part loader - The loading spinner element
 * @part content - The button content wrapper
 *
 * @cssprop --button-bg - Background color
 * @cssprop --button-color - Text color
 * @cssprop --button-hover-bg - Hover background
 * @cssprop --button-active-bg - Active/pressed background
 * @cssprop --button-border - Border width
 * @cssprop --button-border-color - Border color
 * @cssprop --button-radius - Border radius
 * @cssprop --button-padding - Inner padding
 * @cssprop --button-gap - Gap between icon and text
 * @cssprop --button-font-size - Font size
 *
 * @example
 * ```html
 * <bit-button variant="solid" color="primary">Click me</bit-button>
 * <bit-button loading color="success">Processing...</bit-button>
 * <bit-button variant="frost" rainbow>Special Button</bit-button>
 * ```
 */
export const BUTTON_TAG = defineComponent<BitButtonProps>({
  formAssociated: true,
  props: {
    color: { default: undefined },
    disabled: { default: false },
    fullwidth: { default: false },
    href: { default: undefined },
    iconOnly: { default: false },
    label: { default: undefined },
    loading: { default: false },
    rainbow: { default: false },
    rel: { default: undefined },
    rounded: { default: undefined },
    size: { default: undefined },
    target: { default: undefined },
    type: { default: 'button' },
    variant: { default: 'solid' },
  },
  setup({ host, props }) {
    // Reactively inherit size/variant/color from a parent bit-button-group when present.
    syncContextProps(inject(BUTTON_GROUP_CTX, undefined), props, ['color', 'size', 'variant']);

    const isDisabled = computed(() => props.disabled.value || props.loading.value || false);
    const isLink = computed(() => !!props.href.value);
    // 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.
    const formField = defineField({
      disabled: isDisabled,
      toFormValue: () => null,
      value: computed(() => ''),
    });
    // Prevent navigation on disabled links; native <button disabled> handles the button case.
    const handleLinkClick = (e: MouseEvent) => {
      if (isDisabled.value) {
        e.preventDefault();
        e.stopPropagation();

        return;
      }

      // Relay to host as a proper MouseEvent. stopPropagation prevents double-dispatch in
      // browsers where shadow DOM already retargets the event to the host.
      e.stopPropagation();
      fire.mouse(host, e.type, e);
    };
    const handleButtonClick = (e: MouseEvent) => {
      if (isDisabled.value) return;

      // Relay to host, handle form submission, and stop inner bubble in one place.
      e.stopPropagation();

      const form = formField.internals.form;

      if (form) {
        if (props.type.value === 'submit') form.requestSubmit();
        else if (props.type.value === 'reset') form.reset();
      }

      fire.mouse(host, e.type, e);
    };

    return html`
      ${when(
        isLink,
        () =>
          html`<a
            part="button"
            :href="${() => props.href.value ?? null}"
            :target="${() => props.target.value ?? null}"
            :rel="${() => props.rel.value ?? null}"
            :aria-label="${() => props.label.value ?? null}"
            :aria-disabled="${() => (isDisabled.value ? 'true' : null)}"
            :aria-busy="${() => (props.loading.value ? 'true' : null)}"
            role="button"
            @click="${handleLinkClick}">
            <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}
            :aria-label="${() => props.label.value ?? null}"
            :aria-disabled="${() => (isDisabled.value ? 'true' : null)}"
            :aria-busy="${() => (props.loading.value ? 'true' : null)}"
            @click="${handleButtonClick}">
            <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: [
    ...formFieldMixins,
    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'),
    disabledLoadingMixin('button'),
    componentStyles,
  ],
  tag: 'bit-button',
});
View Source Code (Button Group)
ts
import { createContext, defineComponent, html, provide, type ReadonlySignal } from '@vielzeug/craftit';

import type { ComponentSize, ThemeColor, VisualVariant } from '../../types';

/** Context provided by bit-button-group to its bit-button children. */
export type ButtonGroupContext = {
  color: ReadonlySignal<ThemeColor | undefined>;
  size: ReadonlySignal<ComponentSize | undefined>;
  variant: ReadonlySignal<Exclude<VisualVariant, 'glass'> | undefined>;
};
/** Injection key for the button-group context. */
export const BUTTON_GROUP_CTX = createContext<ButtonGroupContext>('ButtonGroupContext');

import styles from './button-group.css?inline';

/** Button group component properties */
export type BitButtonGroupProps = {
  /** Attach buttons together (no gap, rounded only on edges) */
  attached?: boolean;
  /** Button color for all children (propagated) */
  color?: ThemeColor;
  /** Make all buttons full width */
  fullwidth?: boolean;
  /** Accessible group label */
  label?: string;
  /** Group orientation */
  orientation?: 'horizontal' | 'vertical';
  /** Button size for all children (propagated) */
  size?: ComponentSize;
  /** Button variant for all children (propagated) */
  variant?: Exclude<VisualVariant, 'glass'>;
};

// -------------------- Component Definition --------------------
/**
 * A container for grouping buttons with coordinated styling and layout.
 *
 * @element bit-button-group
 *
 * @attr {string} size - Button size: 'sm' | 'md' | 'lg'
 * @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'frost'
 * @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {string} orientation - Group orientation: 'horizontal' | 'vertical'
 * @attr {boolean} attached - Attach buttons together (no gap, rounded only on edges)
 * @attr {boolean} fullwidth - Make all buttons full width
 * @attr {string} label - Accessible group label
 *
 * @slot - Button elements (bit-button)
 *
 * @cssprop --group-gap - Gap between buttons
 * @cssprop --group-radius - Border radius for edge buttons
 *
 * @example
 * ```html
 * <bit-button-group><bit-button>First</bit-button><bit-button>Second</bit-button></bit-button-group>
 * ```
 */
export const BUTTON_GROUP_TAG = defineComponent<BitButtonGroupProps>({
  props: {
    attached: { default: false },
    color: { default: undefined },
    fullwidth: { default: false },
    label: { default: undefined },
    orientation: { default: undefined },
    size: { default: undefined },
    variant: { default: undefined },
  },
  setup({ props }) {
    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.value ?? null}">
        <slot></slot>
      </div>
    `;
  },
  styles: [styles],
  tag: 'bit-button-group',
});

Basic Usage

Standalone Button

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

<script type="module">
  import '@vielzeug/buildit/button';
</script>

Button Group

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

<script type="module">
  import '@vielzeug/buildit/button';
  import '@vielzeug/buildit/button-group';
</script>

Visual Options

Variants

The button comes with eight 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

Rainbow Border

Animated rainbow border effect perfect for highlighting call-to-action buttons or special features.

PreviewCode
RTL

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, bit-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

bit-button Attributes

AttributeTypeDefaultDescription
variant'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'frost''solid'Visual style variant
color'primary' | 'secondary' | 'success' | 'warning' | 'error''primary'Semantic color
size'sm' | 'md' | 'lg''md'Button size
type'button' | 'submit' | 'reset''button'Button type (for forms)
disabledbooleanfalseDisable the button
loadingbooleanfalseShow loading state
rainbowbooleanfalseAnimated rainbow border effect
icon-onlybooleanfalseIcon-only mode (square aspect ratio, no padding)
labelstringAccessible label for the inner element — required for icon-only buttons
fullwidthbooleanfalseButton takes full width of container
roundedbooleanfalseFully rounded corners
hrefstringURL to navigate to; renders as <a role="button">
target'_blank' | '_self' | '_parent' | '_top'Link target (requires href)
relstringLink rel attribute (requires href)

bit-button-group Attributes

AttributeTypeDefaultDescription
orientation'horizontal' | 'vertical''horizontal'Group layout direction
attachedbooleanfalseRemove spacing and connect buttons
fullwidthbooleanfalseButtons expand to fill 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' | 'success' | 'warning' | 'error'-Apply color to all child buttons

Slots

bit-button

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

bit-button-group

SlotDescription
(default)Child button elements

Events

bit-button

EventDetailDescription
clickMouseEventNative click event — standard MouseEvent, not suppressed

bit-button-group

No events.

CSS Custom Properties

bit-button

PropertyDescriptionDefault
--button-bgBackground colorVariant-dependent
--button-colorText colorVariant-dependent
--button-radiusBorder radius0.375rem
--button-paddingInner paddingSize-dependent

bit-button-group

PropertyDescriptionDefault
--group-gapSpacing between buttons0.5rem
--group-radiusBorder radius for first/last buttons in attached mode0.375rem

Accessibility

Both components follow WAI-ARIA best practices.

bit-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 require label attribute.

bit-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

bit-button

Do:

  • Use semantic colors to communicate intent.
  • Provide aria-label for icon-only buttons.
  • Use loading state for async operations.

Don't:

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

bit-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.