Button
The Button component provides a consistent way to trigger actions across the application.
import { HandPointingIcon } from '@phosphor-icons/react/dist/ssr';
import { Button } from '@/components/button';
export default function ButtonExample() {
return (
<Button>
<HandPointingIcon />
<span>Click me</span>
</Button>
);
} Dependencies
Source Code
import type { VariantProps } from 'cva';
import { Children, Fragment, isValidElement, useEffect } from 'react';
import { Slot, Slottable } from '@/components/slot';
import { Spinner } from '@/components/spinner';
import { cn, cva } from '@/lib/utils/classnames';
import { Divider } from '../divider';
const buttonStyle = cva({
base: [
'relative inline-flex h-(--button-height) shrink-0 items-center justify-center gap-1.5 whitespace-nowrap font-medium text-(--button-text-color) shadow-xs [--button-text-color:var(--color-foreground)]',
'transition enabled:cursor-pointer disabled:opacity-40',
'active:not-in-data-ui-button-group:scale-98',
'focus-visible:ring-(length:--ring-width) ring-ring focus-visible:outline-none',
// inside button group — horizontal merge (default; ToggleGroup may set
// data-orientation="vertical" on its container to flip the axis)
'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-last:rounded-r-none',
'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-last:border-r-0',
'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-first:rounded-l-none',
'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-first:border-l-0',
// inside button group — vertical merge
'in-data-ui-button-group:in-data-[orientation=vertical]:not-last:rounded-b-none',
'in-data-ui-button-group:in-data-[orientation=vertical]:not-last:border-b-0',
'in-data-ui-button-group:in-data-[orientation=vertical]:not-first:rounded-t-none',
'in-data-ui-button-group:in-data-[orientation=vertical]:not-first:border-t-0',
// pressed state — set via data-pressed by Toggle / ToggleGroup.Item.
// Subtle filled tint that layers over `ghost` and `outline` without flipping
// the text color; the higher selector specificity beats the variant background.
'data-pressed:bg-foreground/10 data-pressed:hover:bg-foreground/15',
],
variants: {
variant: {
primary:
'bg-accent [--button-text-color:var(--color-accent-foreground)] hover:bg-accent/90 in-data-ui-button-group:active:bg-accent/80',
outline:
'border border-border bg-background hover:bg-foreground/2 not-in-data-ui-button-group:focus-visible:border-accent in-data-ui-button-group:active:bg-foreground/4',
ghost:
'border-none bg-transparent shadow-none ring-0 hover:bg-foreground/5 in-data-ui-button-group:active:bg-foreground/10',
destructive:
'bg-error ring-error/50 [--button-text-color:var(--color-white)] hover:bg-error/90',
},
size: {
xs: 'rounded-lg px-2 text-sm [--button-height:--spacing(6)]',
sm: 'rounded-lg px-3 text-sm [--button-height:--spacing(8)]',
md: 'rounded-xl px-4 text-base [--button-height:--spacing(10)]',
lg: 'rounded-2xl px-5 text-base [--button-height:--spacing(12)]',
},
square: {
true: 'w-(--button-height) px-0',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
});
export interface ButtonProps
extends React.ComponentPropsWithRef<'button'>,
VariantProps<typeof buttonStyle> {
asChild?: boolean;
isLoading?: boolean;
}
const Button = ({
children,
className,
variant,
asChild = false,
isLoading,
size = 'md',
square,
type = 'button',
ref,
...props
}: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(
buttonStyle({
className,
variant,
size,
square,
}),
isLoading && 'text-transparent transition-none'
)}
ref={ref}
type={type}
{...props}
>
<Slottable asChild={asChild} child={children}>
{(child) => (
<>
{child}
{isLoading && (
<span
data-button-spinner
className={cn(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'text-(--button-text-color)'
)}
>
<Spinner size={size} />
</span>
)}
</>
)}
</Slottable>
</Comp>
);
};
export interface IconButtonProps extends Omit<ButtonProps, 'square'> {
'aria-label': string;
}
const IconButton = ({ ref, ...props }: IconButtonProps) => {
return <Button square {...props} ref={ref} />;
};
type ButtonGroupProps = React.ComponentPropsWithRef<'div'>;
const ButtonGroup = ({ className, children, ...props }: ButtonGroupProps) => {
// Validate children to ensure they are Button or IconButton components
useEffect(() => {
const childArray = Children.toArray(children);
childArray.forEach((child, index) => {
if (
!isValidElement(child) ||
(child.type !== Button && child.type !== IconButton)
) {
console.warn(
`Warning: ButtonGroup child at index ${index} is not a Button or IconButton component.`
);
}
});
}, [children]);
return (
<div
className={cn('flex items-center *:focus-visible:z-2', className)}
data-ui-button-group
{...props}
>
{Children.toArray(children).map((child, index) => {
return (
<Fragment key={index}>
{index !== 0 && (
<Divider
orientation="vertical"
className="z-1 -mr-px h-[1em] w-px"
/>
)}
{child}
</Fragment>
);
})}
</div>
);
};
const CompoundButton = Object.assign(Button, {
Group: ButtonGroup,
});
export { buttonStyle, CompoundButton as Button, IconButton }; API Reference
Button
| Prop | Default | Type | Description |
|---|---|---|---|
variant | "primary" | "primary""outline""ghost""destructive" | |
size | "md" | "xs""sm""md""lg" | |
square | false | boolean | Makes the button a square. Helpful when you have a button with only an icon inside. |
isLoading | false | boolean | Replaces the button content with a spinner |
asChild | - | boolean |
IconButton
Extends all Button props except square (which is always true).
| Prop | Default | Type | Description |
|---|---|---|---|
aria-label * | - | string | Accessible label for the icon button. Required since there is no visible text for screen readers to announce. |
Button.Group
Accepts all standard div props. No custom props required. Renders buttons side-by-side with a subtle divider between each item.
Examples
Sizes
import { Button } from '@/components/button';
export default function ButtonSizesPreview() {
return (
<div className="flex flex-wrap items-center gap-2">
<Button size="xs">Small</Button>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
);
} Variants
import { Button } from '@/components/button';
export default function ButtonVariantsPreview() {
return (
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary">Primary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
</div>
);
} Loading
import { useState } from 'react';
import { Button } from '@/components/button';
export default function ButtonExample() {
const [isLoading, setIsLoading] = useState(false);
return (
<Button onClick={() => setIsLoading(!isLoading)} isLoading={isLoading}>
Click to toggle
</Button>
);
} Icons
import {
ArrowSquareOutIcon,
PackageIcon,
PencilIcon,
SunIcon,
} from '@phosphor-icons/react/dist/ssr';
import { Button, IconButton } from '@/components/button';
export default function ButtonIconsPreview() {
return (
<div className="flex flex-wrap items-center gap-2">
<IconButton variant="outline" aria-label="Switch theme">
<SunIcon />
</IconButton>
<Button variant="outline">
<PencilIcon />
<span>Edit</span>
</Button>
<Button variant="outline">
<PackageIcon />
<span>External link</span>
<ArrowSquareOutIcon />
</Button>
</div>
);
} Group
import {
TextBIcon,
TextItalicIcon,
TextStrikethroughIcon,
TextUnderlineIcon,
} from '@phosphor-icons/react/dist/ssr';
import { Button, IconButton } from '@/components/button';
export default function ButtonGroupPreview() {
return (
<div className="flex flex-col items-center gap-4">
<Button.Group>
<Button variant="outline">Day</Button>
<Button variant="outline">Week</Button>
<Button variant="outline">Month</Button>
</Button.Group>
<Button.Group>
<IconButton variant="outline" aria-label="Bold">
<TextBIcon />
</IconButton>
<IconButton variant="outline" aria-label="Italic">
<TextItalicIcon />
</IconButton>
<IconButton variant="outline" aria-label="Underline">
<TextUnderlineIcon />
</IconButton>
<IconButton variant="outline" aria-label="Strikethrough">
<TextStrikethroughIcon />
</IconButton>
</Button.Group>
</div>
);
} disabled
import { Button } from '@/components/button';
export default function ButtonDisabledPreview() {
return (
<div className="flex flex-wrap items-center gap-2">
<Button disabled variant="primary">
Primary
</Button>
<Button disabled variant="outline">
Outline
</Button>
<Button disabled variant="ghost">
Ghost
</Button>
<Button disabled variant="destructive">
Destructive
</Button>
</div>
);
} As Link
import {
ArrowSquareOutIcon,
PackageIcon,
} from '@phosphor-icons/react/dist/ssr';
import { Button } from '@/components/button';
export default function ButtonLinkPreview() {
return (
<Button variant="outline" asChild>
<a href="https://significa.co" target="_blank" rel="noopener">
<PackageIcon />
<span>Significa website</span>
<ArrowSquareOutIcon />
</a>
</Button>
);
} Best Practices
-
Variants:
- Use primary for main actions
- Use destructive for dangerous actions
- Use ghost for subtle actions
-
Accessibility:
- Ensure clear button text
- Consider loading states
-
Use
IconButtonfor icon-only buttons: When a button contains only an icon useIconButtoninstead ofButton. Icon-only buttons are a common accessibility pitfall — without anaria-label, screen readers have nothing meaningful to announce, which makes the button invisible to users relying on assistive technology.IconButtonmakesaria-labelrequired at the TypeScript level, turning a silent runtime omission into a compile-time error.
Previous
Breadcrumb
Next
Calendar