Button (Tailwind)

PreviousNext

A button component.

Docs
roiuiitem

Preview

Loading preview…
registry/brook/tailwind/ui/button.tsx
"use client";

import { Button } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  [
    "group inline-flex items-center justify-center rounded-[var(--radius)] font-[450]",
    "transition-transform duration-200 ease-[var(--ease-out-quad)] will-change-transform",
    "relative cursor-pointer overflow-hidden border border-transparent",
    "leading-[1.2] tracking-[-0.014em]",
    "",
    "focus-visible:outline-2 focus-visible:outline-[color:var(--color-ring)] focus-visible:outline-offset-2",
    "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70",
    "[&.loading]:cursor-not-allowed [&.loading]:opacity-70",
  ],
  {
    variants: {
      variant: {
        primary: [
          "bg-[color:var(--color-primary)] text-[color:var(--color-primary-foreground)]",
          "shadow-[0_0.5px_0.5px_rgba(0,0,0,0.1)]",
          "hover:not-data-[disabled]:bg-[color:oklch(from_var(--color-primary)_l_c_h_/_0.8)]",
          "active:not-data-[disabled]:scale-[0.97] active:[&.loading]:scale-100",
        ],
        secondary: [
          "bg-[color:var(--color-secondary)] text-[color:var(--color-secondary-foreground)]",
          "hover:not-data-[disabled]:bg-[color:oklch(from_var(--color-secondary)_l_c_h_/_0.8)]",
        ],
        destructive: [
          "bg-[color:var(--color-destructive)] text-[color:var(--color-destructive-foreground)]",
          "hover:not-data-[disabled]:bg-[color:oklch(from_var(--color-destructive)_l_c_h_/_0.85)]",
        ],
        ghost: [
          "bg-transparent text-[color:var(--color-foreground)]",
          "hover:not-data-[disabled]:bg-[color:oklch(from_var(--color-accent)_l_c_h_/_0.6)]",
          "data-[popup-open]:bg-[color:oklch(from_var(--color-accent)_l_c_h_/_0.7)]",
        ],
        outline: [
          "border-[color:oklch(from_var(--color-border)_l_c_h_/_0.7)] bg-[var(--mix-card-50-bg)] text-[color:var(--color-foreground)]",
          "hover:not-data-[disabled]:bg-[var(--mix-card-66-bg)]",
        ],
        link: [
          "bg-transparent p-0 text-[color:var(--color-muted-foreground)] no-underline",
          "transition-[text-decoration] duration-200 ease-out",
          "hover:not-data-[disabled]:text-[color:var(--color-foreground)] hover:not-data-[disabled]:underline",
        ],
      },
      size: {
        sm: "h-8 px-3 text-sm",
        md: "h-10 px-4 py-2 text-[0.925rem]",
        lg: "h-12 px-6 py-2 text-base",
        icon: [
          "aspect-square h-auto w-auto p-1.5 text-sm",
          "before:absolute before:top-1/2 before:left-1/2 before:block before:content-['']",
          "before:-translate-x-1/2 before:-translate-y-1/2 before:h-full before:w-full",
          "before:-z-10 before:min-h-[44px] before:min-w-[44px]",
        ],
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
    },
  }
);

function Spinner() {
  return (
    <svg className="mr-2 animate-[spin_1s_linear_infinite]" fill="none" height="16" viewBox="0 0 24 24" width="16">
      <circle
        cx="12"
        cy="12"
        r="10"
        stroke="currentColor"
        strokeDasharray="31.416"
        strokeDashoffset="31.416"
        strokeLinecap="round"
        strokeWidth="2"
        style={{
          animation: "spin 1s linear infinite",
        }}
      />
      <style>
        {`
          @keyframes spin {
            from {
              transform: rotate(0deg);
              stroke-dashoffset: 31.416;
            }
            to {
              transform: rotate(360deg);
              stroke-dashoffset: 0;
            }
          }
        `}
      </style>
    </svg>
  );
}

/**
 * ArrowPointer component for displaying directional arrows within buttons.
 *
 * @param pointLeft - When true, arrow points left instead of right
 * @param pointExternal - When true, applies external link arrow styling (diagonal orientation)
 *
 * @example
 * ```tsx
 * // Right-pointing arrow (default)
 * <ArrowPointer />
 *
 * // Left-pointing arrow
 * <ArrowPointer pointLeft />
 *
 * // External link arrow
 * <ArrowPointer pointExternal />
 * ```
 */
function ArrowPointer({ pointLeft = false, pointExternal = false }: { pointLeft?: boolean; pointExternal?: boolean }) {
  const arrowClasses = cn(
    "-mr-2 relative top-0 ml-2 h-2.5 w-3 overflow-visible",
    "transition-all duration-200 ease-[var(--ease-in-out-cubic)]",
    pointLeft && "-ml-2 mr-2",
    pointExternal && "group-hover:-rotate-45 origin-[8%]"
  );

  const pointClasses = "transition-transform duration-200 ease-[var(--ease-in-out-cubic)] group-hover:translate-x-0.5";
  const shaftClasses =
    "opacity-0 transition-[transform,opacity] duration-200 ease-[var(--ease-out-quad)] group-hover:opacity-100 group-hover:-translate-x-0.5";

  const pointLeftClasses = "group-hover:-translate-x-0.5";
  const shaftLeftClasses = "group-hover:opacity-100 group-hover:translate-x-px";

  return (
    <svg className={arrowClasses} fill="none" viewBox="0 0 14 10" xmlns="http://www.w3.org/2000/svg">
      <g>
        <path
          className={cn(pointClasses, pointLeft ? pointLeftClasses : null)}
          d={pointLeft ? "M14.8 1l-4 4 4 4" : "M-0.8 1l4 4-4 4"}
          fill="none"
          stroke="currentColor"
          strokeLinecap="square"
          strokeLinejoin="miter"
          strokeWidth="2"
        />
        <path
          className={cn(shaftClasses, pointLeft ? shaftLeftClasses : null)}
          d={pointLeft ? "M14.8 5H9.8" : "M0 5h4.8"}
          fill="none"
          stroke="currentColor"
          strokeLinecap="square"
          strokeLinejoin="miter"
          strokeWidth="2"
        />
      </g>
    </svg>
  );
}

interface ButtonRootProps
  extends Omit<React.ComponentProps<"button">, "className" | "style">,
    VariantProps<typeof buttonVariants> {
  className?: string | ((state: Button.State) => string | undefined);
  style?: React.CSSProperties | ((state: Button.State) => React.CSSProperties | undefined);
  render?: React.ReactElement | ((props: React.HTMLAttributes<HTMLElement>, state: Button.State) => React.ReactElement);
  focusableWhenDisabled?: boolean;
  nativeButton?: boolean;
  showArrow?: boolean;
  pointLeft?: boolean;
  pointExternal?: boolean;
  loading?: boolean;
}

function ButtonRoot({
  className,
  variant,
  size,
  showArrow = false,
  pointLeft = false,
  pointExternal = false,
  loading = false,
  children,
  render,
  nativeButton,
  ...props
}: ButtonRootProps) {
  const decoratedChildren = (
    <>
      {loading ? <Spinner /> : null}
      {!loading && showArrow && pointLeft && <ArrowPointer pointExternal={pointExternal} pointLeft />}
      {children}
      {!loading && showArrow && !pointLeft && <ArrowPointer pointExternal={pointExternal} />}
    </>
  );

  // Auto-detect nativeButton based on render prop if not explicitly set

  return (
    <Button
      className={cn(buttonVariants({ variant, size }), loading ? "loading" : null, className)}
      data-slot="button"
      disabled={props.disabled || loading}
      focusableWhenDisabled={loading}
      render={render}
      {...props}
    >
      {decoratedChildren}
    </Button>
  );
}

export { ButtonRoot as Button, ArrowPointer };

Installation

npx shadcn@latest add @roiui/button-tailwind

Usage

import { ButtonTailwind } from "@/components/button-tailwind"
<ButtonTailwind />