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) oreffect="rainbow"(color cycle)Link Mode: renders as an accessible <a role="button">whenhrefis setCustomizable: 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
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)
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 & 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
<sg-button variant="solid" color="primary">Click me</sg-button>Button Group
<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.
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.
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.
Shine
A neon comet shimmer that follows the color attribute — two arcs sweep the border using the button's own theme color.
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.
Sizes
Three sizes for different contexts.
States
Loading
Show a loading spinner and prevent interaction during async operations.
Disabled
Prevent interaction and reduce opacity for unavailable actions.
Icons & Extras
With Icons
Add prefix or suffix icons using slots.
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.
Full Width
Button expands to fill the full width of its container.
Link Buttons
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.
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)
Vertical
Attached Mode
Remove spacing and connect buttons with shared borders for segmented controls.
Attribute Propagation
Apply size, variant, or color to all child buttons automatically via the parent group.
Full Width
Buttons expand to fill the container equally.
API Reference
sg-button Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
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 |
disabled | boolean | false | Disable the button |
loading | boolean | false | Show loading spinner; also disables interaction |
effect | 'shine' | 'rainbow' | — | Animated border effect: neon comet (shine) or colorful sweep (rainbow) |
icon-only | boolean | false | Square aspect ratio, no padding — use with label for accessibility |
label | string | — | Accessible label set as aria-label on the host — required for icon-only |
fullwidth | boolean | false | Expand to full container width |
rounded | 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' | — | Border radius size; omit value (or use 'full') for pill shape |
href | string | — | URL — renders as <a role="button"> instead of <button> |
target | '_blank' | '_self' | '_parent' | '_top' | — | Link target (requires href) |
rel | string | — | Link rel attribute (requires href) |
sg-button-group Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal' | 'vertical' | 'horizontal' | Group layout direction |
attached | boolean | false | Remove spacing and connect buttons with borders |
fullwidth | boolean | false | Buttons expand equally to fill container |
label | string | — | Accessible 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
| Slot | Description |
|---|---|
| (default) | Button content (text, icons, etc.) |
prefix | Content before the main content |
suffix | Content after the main content |
sg-button-group
| Slot | Description |
|---|---|
| (default) | Child button elements |
Events
sg-button
| Event | Detail | Description |
|---|---|---|
click | MouseEvent | Native click event — standard MouseEvent, not suppressed |
sg-button-group
No events.
Parts
sg-button
| Part | Description |
|---|---|
button | The inner <button> or <a> element |
loader | The loading spinner |
content | The text/content wrapper inside the button |
sg-button-group
| Part | Description |
|---|---|
group | Group container |
CSS Custom Properties
sg-button
| Property | Description | Default |
|---|---|---|
--button-bg | Background color override | Variant-dependent |
--button-color | Text color override | Variant-dependent |
--button-border | Border width override | var(--border) |
--button-border-color | Border color override | Variant-dependent |
--button-border-top | Top border width override (used by attached group) | — |
--button-border-start | Inline-start border width override (used by attached group) | — |
--button-radius | Border radius override | var(--rounded-lg) |
--button-padding | Inner padding override | Size-dependent |
--button-gap | Gap between icon and text override | Size-dependent |
--button-font-size | Font size override | Size-dependent |
--button-frost-active-bg | Background when hovered/pressed in frost variant | Variant-dependent |
--button-frost-active-border-color | Border color when hovered/pressed in frost variant | Variant-dependent |
sg-button-group
| Property | Description | Default |
|---|---|---|
--group-gap | Spacing between buttons (non-attached mode) | var(--size-2) |
--group-radius | Border radius applied to first/last buttons in attached mode | var(--rounded-lg) |
Accessibility
Both components follow WAI-ARIA best practices.
sg-button
EnterandSpaceactivate the button.Tabmoves focus to/from the button.
- Announces button role and label.
aria-disabledwhen disabled.aria-busywhen loading.- Icon-only buttons must have the
labelattribute — it is set asaria-labelon the host element.
sg-button-group
- Automatically includes
role="group"on the container. - Use the
labelattribute to provide context for screen readers (e.g.,label="Text alignment").
Tabmoves focus between buttons.
- Buttons within a group are announced in context.
Best Practices
sg-button
Do:
- Use semantic colors to communicate intent.
- Always set the
labelattribute 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
attachedmode for related segmented controls. - Use
fullwidthfor mobile-optimized layouts or primary actions. - Provide an
aria-labelwhen the group's purpose isn't clear from the content.
Don't:
- Mix too many variants or colors within a single group.
- Use
verticalorientation for more than 4-5 buttons if possible.