Skip to content
91 linesCodeBlameRaw
1import { type VariantProps, cva } from "class-variance-authority";
2import { LoaderCircle } from "lucide-react";
3import { Slot } from "radix-ui";
4import type { ComponentProps } from "react";
5
6import { cn } from "../../lib/cn";
7
8// shadcn/ui's button, styled with g1t's tokens: the one button. `asChild`
9// puts its look on a <Link> or a menu's trigger; a form's submit button
10// that says it is working is `SubmitButton` in ./index, and a link that
11// looks like one is `ButtonLink` there, both drawn with `buttonVariants`.
12//
13// Variants: `default` is the plain dark-on-light (light-on-dark) button for
14// the main action; `accent` the lavender one, for the one action a page
15// is for; `outline` a bordered quiet one; `secondary` a raised one;
16// `ghost` bare until hovered, for icons and toolbars; `soft` the accent as
17// a wash, the one tinted control in a row; `destructive`
18// for what cannot be undone; `link` words alone.
19//
20// Sizes: `default` 36px, `sm` 32px, `xs` 28px, `lg` 40px; the icon sizes
21// are squares of the same. `bar` is the top bar's: 32px on a computer, 44px
22// to a finger on a phone, and `icon-bar` its square.
23
24export const buttonVariants = cva(
25 "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap outline-none transition-[color,background-color,border-color,box-shadow] focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
26 {
27 variants: {
28 variant: {
29 default: "bg-fg text-bg hover:bg-fg-hover",
30 accent: "bg-accent text-bg hover:bg-accent-hover",
31 outline: "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
32 secondary: "bg-raised text-fg hover:bg-line",
33 ghost: "text-muted hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg aria-pressed:bg-raised aria-pressed:text-fg",
34 soft: "bg-accent/10 text-fg ring-1 ring-accent/20 ring-inset hover:bg-accent/15 hover:ring-accent/35",
35 destructive: "border border-danger/40 text-danger hover:border-danger hover:bg-danger/10",
36 link: "font-normal text-accent underline-offset-4 hover:underline",
37 },
38 size: {
39 default: "h-9 px-3.5",
40 sm: "h-8 gap-1.5 px-2.5 text-[0.8125rem]",
41 xs: "h-7 gap-1 px-2 text-xs",
42 lg: "h-10 px-5",
43 bar: "h-8 gap-1.5 px-2 text-[0.8125rem] max-md:h-11 max-md:min-w-11",
44 icon: "size-9",
45 "icon-sm": "size-8",
46 "icon-xs": "size-7",
47 "icon-lg": "size-10",
48 "icon-bar": "size-8 max-md:size-11",
49 // Words in a sentence, for the `link` variant: no box of its own.
50 inline: "h-auto gap-1.5 p-0 text-[length:inherit]",
51 },
52 },
53 defaultVariants: { variant: "default", size: "default" },
54 },
55);
56
57export type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>["variant"]>;
58export type ButtonSize = NonNullable<VariantProps<typeof buttonVariants>["size"]>;
59
60export type ButtonProps = ComponentProps<"button"> &
61 VariantProps<typeof buttonVariants> & {
62 asChild?: boolean;
63 /**
64 * Working on what it asked for: a spinner before its words, pressed
65 * no more, and `aria-busy` for a screen reader (lib/busy.ts keeps it
66 * until what it asked for shows). Not with `asChild`.
67 */
68 busy?: boolean;
69 };
70
71export function Button({ className, variant, size, asChild = false, busy = false, disabled, children, ...props }: ButtonProps) {
72 if (asChild) {
73 return (
74 <Slot.Root data-slot="button" className={cn(buttonVariants({ variant, size }), className)} {...props}>
75 {children}
76 </Slot.Root>
77 );
78 }
79 return (
80 <button
81 data-slot="button"
82 className={cn(buttonVariants({ variant, size }), className)}
83 disabled={disabled || busy}
84 aria-busy={busy || undefined}
85 {...props}
86 >
87 {busy && <LoaderCircle aria-hidden="true" className="size-[1.1em] animate-spin motion-reduce:animate-none" />}
88 {children}
89 </button>
90 );
91}