VisorVisor
ComponentsGeneral

Button

A versatile button component with multiple variants and sizes. Built with CVA and CSS Modules — copy it into your project and own it completely.

npx visor add button

Variants

Sizes

Disabled State

Gated State

A permission-gated button is visually dimmed but remains hover-able, surfacing a tooltip that explains why the action is unavailable. Unlike the native disabled state, the button stays in the tab order so assistive technology users can discover the reason.

Use gated to apply the visual state, and gatedReason to provide the tooltip text. A TooltipProvider ancestor is required when gatedReason is set.

The gated prop uses aria-disabled="true" instead of the native disabled attribute, so the button stays keyboard-focusable. Click handlers are suppressed internally — no need to guard them in your code.

Installation

npx visor add button

This copies two files into your project:

  • components/ui/button/button.tsx — the component
  • components/ui/button/button.module.css — the styles

Usage

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

export default function Example() {
  return <Button>Click me</Button>;
}

API Reference

ButtonProps

PropTypeDefaultDescription
variant'default' | 'secondary' | 'outline' | 'ghost' | 'destructive''default'Visual style variant of the button.
size'sm' | 'md' | 'lg''md'Size of the button, affecting height and padding.
asChildbooleanfalseMerge props onto the immediate child element instead of rendering a <button>.
disabledbooleanfalseDisables the button and applies reduced-opacity styling.
classNamestringAdditional CSS class names to merge onto the element.
gatedbooleanfalseRenders the button in a permission-gated state: visually dimmed (opacity 0.4), cursor not-allowed, and click handlers suppressed. Uses aria-disabled instead of native disabled so the button remains keyboard-focusable (required for the tooltip to be accessible).
gatedReasonstringExplanation surfaced in an anchored tooltip when the button is gated. Only rendered when gated is also true. Requires a TooltipProvider ancestor. Example: "You can't delete this — you're not an owner".
onClickReact.MouseEventHandler<HTMLButtonElement>Click event handler.
...propsReact.ButtonHTMLAttributes<HTMLButtonElement>All standard HTML button attributes are forwarded.

The component also accepts all standard <button> HTML attributes.

Source Files

After running npx visor add button, you'll have:

button.tsx

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import styles from "./button.module.css";

const buttonVariants = cva(styles.base, {
  variants: {
    variant: {
      default: styles.variantDefault,
      secondary: styles.variantSecondary,
      outline: styles.variantOutline,
      ghost: styles.variantGhost,
      destructive: styles.variantDestructive,
    },
    size: {
      sm: styles.sizeSm,
      md: styles.sizeMd,
      lg: styles.sizeLg,
    },
  },
  defaultVariants: {
    variant: "default",
    size: "md",
  },
});

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size }), className)}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = "Button";

export { Button, buttonVariants };

button.module.css

All styles use CSS custom properties from @loworbitstudio/visor-core, so the button automatically adapts to your active theme.

Customization

After copying the component, you own it completely. Common customizations:

// Add a loading state
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, loading, children, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size }), className)}
        disabled={loading || props.disabled}
        ref={ref}
        {...props}
      >
        {loading ? <Spinner size={16} /> : children}
      </button>
    );
  }
);