Agents (llms.txt)
Octocat

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>
  );
}
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

  1. Variants:

    • Use primary for main actions
    • Use destructive for dangerous actions
    • Use ghost for subtle actions
  2. Accessibility:

    • Ensure clear button text
    • Consider loading states
  3. Use IconButton for icon-only buttons: When a button contains only an icon use IconButton instead of Button. Icon-only buttons are a common accessibility pitfall — without an aria-label, screen readers have nothing meaningful to announce, which makes the button invisible to users relying on assistive technology. IconButton makes aria-label required at the TypeScript level, turning a silent runtime omission into a compile-time error.

Previous

Breadcrumb

Next

Calendar