Agents (llms.txt)

Button

The Button component provides a consistent way to trigger actions across the application.

import { CursorClick01 } from '@untitledui-pro/icons/solid';

import { Button } from '@/components/button';

export default function ButtonExample() {
  return (
    <Button>
      <CursorClick01 />
      <span>Click me</span>
    </Button>
  );
}

Dependencies

Source Code

'use client';

import type { VariantProps } from 'cva';
import * as motion from 'motion/react-client';
import { Children, Fragment, isValidElement, useEffect } from 'react';
import { Slot, Slottable } from '@/components/slot';
import {
  MOTION_DURATION,
  MOTION_EASE,
  MOTION_SCALE,
} from '@/foundations/setup/motion';
import { Spinner } from '@/components/spinner';
import { SafeAnimatePresence } from '@/lib/utils/animate-presence-safe';
import { cn, cva } from '@/lib/utils/classnames';
import { Divider } from '../divider';

const LOADING_DELAY = MOTION_DURATION.base;

const buttonStyle = cva({
  base: [
    'relative inline-flex h-(--button-height) shrink-0 items-center justify-center gap-1.5 whitespace-nowrap font-medium shadow-xs',
    // Untitled UI icons default to a fixed 24px; constrain icon children to 16px
    // so bare `<Icon />` matches the button's text size. Scoped to the content
    // span — `button > [data-button-content] > svg`.
    '[&_[data-button-content]_svg]:size-4',
    'transition duration-(--duration-hover) ease-(--ease) enabled:cursor-pointer disabled:opacity-40',
    'active:not-in-data-ui-button-group:scale-(--press-scale)',
    '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 text-accent-foreground hover:bg-accent/90 active:bg-accent/80',
      outline:
        'border border-border bg-background text-foreground hover:bg-foreground/5 not-in-data-ui-button-group:focus-visible:border-accent active:bg-foreground/8 in-data-ui-button-group:active:bg-foreground/8',
      ghost:
        'border-none bg-transparent text-foreground shadow-none ring-0 hover:bg-foreground/5 active:bg-foreground/10',
      destructive:
        'bg-error text-accent-foreground ring-error/50 hover:bg-error/90 active:bg-error/80',
      // ConnectKit primary connect button: muted fill, no border, font-weight 600.
      muted:
        'border-transparent bg-background-secondary font-semibold text-foreground shadow-none hover:bg-foreground/8 active:bg-foreground/12',
      // ConnectKit "balance"/tertiary button: white surface with subtle 1px inset
      // shadow that flips to muted on hover.
      brandSoft:
        'border-transparent bg-background text-foreground shadow-[inset_0_0_0_1px_var(--color-border)] hover:bg-background-secondary hover:shadow-[inset_0_0_0_1px_var(--color-background-secondary)] active:bg-foreground/8',
    },
    size: {
      xs: 'rounded-lg px-2 text-sm [--button-height:--spacing(7)]',
      sm: 'rounded-lg px-3 text-sm [--button-height:--spacing(9)]',
      md: 'rounded-xl px-4 text-base [--button-height:--spacing(11)]',
      lg: 'rounded-2xl px-5 text-base [--button-height:--spacing(14)]',
    },
    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,
        })
      )}
      ref={ref}
      type={type}
      {...props}
    >
      <Slottable asChild={asChild} child={children}>
        {(child) => (
          <>
            {/* Content stays in layout always — keeps button width stable.
                Slides + fades on toggle (no delay going to loading; 200ms
                delay coming back so the spinner exits first). */}
            <motion.span
              data-button-content
              className="inline-flex items-center gap-1.5"
              animate={isLoading ? 'loading' : 'idle'}
              initial={false}
              variants={{
                idle: {
                  opacity: 1,
                  y: 0,
                  transition: {
                    duration: MOTION_DURATION.base, // 0.2
                    ease: MOTION_EASE.default,
                    delay: LOADING_DELAY,
                  },
                },
                loading: {
                  opacity: 0,
                  y: 10,
                  transition: {
                    duration: MOTION_DURATION.hover, // 0.1
                    ease: MOTION_EASE.default,
                  },
                },
              }}
            >
              {child}
            </motion.span>

            {/* Spinner overlays absolutely so it doesn't affect width.
                Rotates + scales in (200ms delay) and out (no delay). */}
            <SafeAnimatePresence initial={false}>
              {isLoading && (
                <motion.span
                  key="button-spinner"
                  data-button-spinner
                  aria-hidden={false}
                  className="absolute inset-0 inline-flex items-center justify-center"
                  initial={{ opacity: 0, scale: MOTION_SCALE.enterUp }} // 0.95 — subtle, not poppy
                  animate={{
                    opacity: 1,
                    scale: 1,
                    transition: {
                      duration: MOTION_DURATION.base, // 0.2 — feels instant
                      ease: MOTION_EASE.default, // standard decelerate
                      delay: LOADING_DELAY, // keep the handoff stagger
                    },
                  }}
                  exit={{
                    opacity: 0,
                    scale: MOTION_SCALE.enterUp, // exits back to 0.95
                    transition: {
                      duration: MOTION_DURATION.hover, // 0.1 — faster out than in
                      ease: MOTION_EASE.default,
                    },
                  }}
                >
                  <Spinner
                    size={size}
                    className={cn(
                      // Spinner's default `md` (size-4) reads small against the
                      // 44px button; bump it visually inside the button only.
                      size === 'md' && 'size-5',
                      size === 'lg' && 'size-6'
                    )}
                  />
                </motion.span>
              )}
            </SafeAnimatePresence>
          </>
        )}
      </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""muted""brandSoft"
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>
      <Button variant="muted">Brand</Button>
      <Button variant="brandSoft">Brand Soft</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 {
  Edit01,
  LinkExternal01,
  Package,
  Sun,
} from '@untitledui-pro/icons/solid';

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">
        <Sun />
      </IconButton>
      <Button variant="outline">
        <Edit01 />
        <span>Edit</span>
      </Button>
      <Button variant="outline">
        <Package />
        <span>External link</span>
        <LinkExternal01 />
      </Button>
    </div>
  );
}

Group

import {
  Bold01,
  Italic01,
  Strikethrough01,
  Underline01,
} from '@untitledui-pro/icons/solid';

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">
          <Bold01 />
        </IconButton>
        <IconButton variant="outline" aria-label="Italic">
          <Italic01 />
        </IconButton>
        <IconButton variant="outline" aria-label="Underline">
          <Underline01 />
        </IconButton>
        <IconButton variant="outline" aria-label="Strikethrough">
          <Strikethrough01 />
        </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 { LinkExternal01, Package } from '@untitledui-pro/icons/solid';

import { Button } from '@/components/button';

export default function ButtonLinkPreview() {
  return (
    <Button variant="outline" asChild>
      <a href="https://playstack.com.au" target="_blank" rel="noopener">
        <Package />
        <span>Playstack website</span>
        <LinkExternal01 />
      </a>
    </Button>
  );
}

Motion

The loading state is a two-phase handoff between the button content and the spinner overlay, both driven by family motion tokens:

  • Content fade — exits with MOTION_DURATION.hover (0.1s) and re-enters with MOTION_DURATION.base (0.2s), both using MOTION_EASE.default. The exit is faster than the entry so the content clears quickly when loading starts and settles deliberately when it ends.
  • Spinner enter/exit — scales from MOTION_SCALE.enterUp (0.95) to 1 on entry and back to 0.95 on exit, with the same hover/base duration split as the content fade.
  • Handoff staggerLOADING_DELAY (0.2s) delays the spinner’s entry and the content’s re-entry so each phase starts only after the other has cleared. This is a pattern-specific stagger, not a contract token, so it lives as a local constant rather than in motion.ts.

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