Button
A block button with a press animation, in five variants and three sizes.
Installation
npx shadcn@latest add @runqora/craft-buttonThis writes components/craft/button.tsx and button-variants.ts, and adds the
CraftUI theme to your CSS. See Installation
if you haven't added the registry yet.
Usage
import { Button } from "@/components/craft/button";
<Button>Play</Button>;Examples
Sizes
Disabled and loading
A disabled button turns gray and can't be focused. A loading button keeps its color and its keyboard focus, shows a spinner, and ignores clicks until it's done, so someone using a keyboard doesn't lose their place.
Icons
Pass any icon as leadingIcon or trailingIcon. For a button with only an
icon, use size="icon" and give it an aria-label.
Full width
Links
Something that goes to another page should stay a real link. Apply
buttonVariants to an <a>, or to your router's link component, so that
assistive technology announces it as a link. It lives in its own file, so you can
call it from a Server Component.
Props
Button also accepts every attribute of a <button>.
Accessibility
- Renders a native
<button>withtype="button", so it won't submit a form unless you ask it to withtype="submit". - Works with Enter and Space, and shows a solid focus ring for keyboard users.
loadingsetsaria-busyand keeps the button focusable.- The hover color is brighter than in the game, so hovered primary and destructive buttons use a dark label to keep a contrast of at least 4.5:1.
Customization
Every color comes from a token, so you can change one button without touching the component.
To match the white hover label in the game, set
--craft-primary-hover-fg: #ffffff. It falls below AA contrast, so it's not the
default. For a change everywhere, edit the tokens in your CSS. See
Themes.
Dependencies
@base-ui/reactclass-variance-authority- shadcn's
utils, andcraft-theme
Source
"use client";
import { Button as BaseButton } from "@base-ui/react/button";
import type { VariantProps } from "class-variance-authority";
import type { ComponentProps, ReactNode } from "react";
import { buttonVariants } from "@/components/craft/button-variants";
import { cn } from "@/lib/utils";
const disabledClasses =
"text-craft-disabled-fg [--bevel-edge:var(--craft-disabled-edge)] [--bevel-face:var(--craft-disabled)] [--bevel-hi:var(--craft-disabled)] [--bevel-lo:var(--craft-disabled)] [--bevel-outline:var(--craft-disabled-outline)]";
function Spinner() {
return (
<svg
aria-hidden="true"
className="motion-safe:animate-[spin_1s_steps(8)_infinite]"
data-slot="button-spinner"
fill="currentColor"
shapeRendering="crispEdges"
viewBox="0 0 16 16"
>
<path d="M7 1h2v4H7zM7 11h2v4H7zM1 7h4v2H1zM11 7h4v2h-4z" />
<path
d="M3 3h2v2H3zM11 3h2v2h-2zM3 11h2v2H3zM11 11h2v2h-2z"
opacity="0.5"
/>
</svg>
);
}
export interface ButtonProps
extends ComponentProps<typeof BaseButton>,
VariantProps<typeof buttonVariants> {
/** Stretches the button to the width of its container. */
fullWidth?: boolean;
/** Rendered before the label. Use `loading` for a busy state. */
leadingIcon?: ReactNode;
/** Shows a spinner and blocks clicks, while the button keeps keyboard focus. */
loading?: boolean;
/** Rendered after the label. */
trailingIcon?: ReactNode;
}
export function Button({
children,
className,
disabled,
fullWidth,
leadingIcon,
loading,
size,
trailingIcon,
variant,
...props
}: ButtonProps) {
return (
<BaseButton
aria-busy={loading || undefined}
className={cn(
buttonVariants({ size, variant }),
fullWidth && "w-full",
disabled && disabledClasses,
className
)}
data-slot="button"
disabled={disabled || loading}
focusableWhenDisabled={loading}
{...props}
>
{loading ? <Spinner /> : (leadingIcon ?? null)}
{children}
{trailingIcon}
</BaseButton>
);
}import { cva } from "class-variance-authority";
// Separate from button.tsx, with no "use client", so that Server Components can
// call it to style links.
export const buttonVariants = cva(
[
// The button itself only holds the layout. `before` paints the block, so a
// press can sink the block without moving the layout or shrinking the
// click target.
"font-craft-display relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 pb-(--craft-edge) whitespace-nowrap select-none",
"before:craft-bevel [--bevel-depth:var(--craft-edge)] before:absolute before:inset-0 before:-z-10 motion-safe:before:transition-[top] motion-safe:before:duration-75",
"active:pb-0 active:[--bevel-depth:0px] active:before:top-(--craft-edge)",
"focus-visible:craft-focus [&_svg]:size-4 [&_svg]:shrink-0",
"data-disabled:pointer-events-none",
],
{
defaultVariants: { size: "md", variant: "primary" },
variants: {
size: {
icon: "size-(--craft-control-md) text-xl",
lg: "h-(--craft-control-lg) px-[calc(var(--craft-space)*1.5)] text-2xl",
md: "h-(--craft-control-md) px-(--craft-space) text-xl",
sm: "h-(--craft-control-sm) px-[calc(var(--craft-space)*0.75)] text-lg",
},
variant: {
destructive: [
"text-craft-destructive-fg [--bevel-edge:var(--craft-destructive-edge)] [--bevel-face:var(--craft-destructive)] [--bevel-hi:var(--craft-destructive-hi)] [--bevel-lo:var(--craft-destructive-lo)]",
"hover:text-craft-destructive-hover-fg hover:[--bevel-face:var(--craft-destructive-hover)] hover:[--bevel-hi:var(--craft-destructive-hover-hi)] hover:[--bevel-lo:var(--craft-destructive-hover-lo)]",
"active:text-craft-destructive-fg active:[--bevel-face:var(--craft-destructive-active)] active:[--bevel-hi:var(--craft-destructive-active-hi)] active:[--bevel-lo:var(--craft-destructive-active-lo)]",
],
ghost: [
"text-craft-text [--bevel-edge:transparent] [--bevel-face:transparent] [--bevel-hi:transparent] [--bevel-lo:transparent] [--bevel-outline:transparent]",
"hover:[--bevel-face:var(--craft-hover)] active:[--bevel-face:var(--craft-active)]",
],
outline: [
"text-craft-text [--bevel-edge:transparent] [--bevel-face:transparent] [--bevel-hi:transparent] [--bevel-lo:transparent] [--bevel-outline:var(--craft-text)]",
"hover:[--bevel-face:var(--craft-hover)] active:[--bevel-face:var(--craft-active)]",
],
primary: [
"text-craft-primary-fg [--bevel-edge:var(--craft-primary-edge)] [--bevel-face:var(--craft-primary)] [--bevel-hi:var(--craft-primary-hi)] [--bevel-lo:var(--craft-primary-lo)]",
"hover:text-craft-primary-hover-fg hover:[--bevel-face:var(--craft-primary-hover)] hover:[--bevel-hi:var(--craft-primary-hover-hi)] hover:[--bevel-lo:var(--craft-primary-hover-lo)]",
"active:text-craft-primary-fg active:[--bevel-face:var(--craft-primary-active)] active:[--bevel-hi:var(--craft-primary-active-hi)] active:[--bevel-lo:var(--craft-primary-active-lo)]",
],
secondary: [
"text-craft-secondary-fg [--bevel-edge:var(--craft-secondary-edge)] [--bevel-face:var(--craft-secondary)] [--bevel-hi:var(--craft-secondary-hi)] [--bevel-lo:var(--craft-secondary-lo)]",
"hover:[--bevel-face:var(--craft-secondary-hover)] hover:[--bevel-hi:var(--craft-secondary-hover-hi)] hover:[--bevel-lo:var(--craft-secondary-hover-lo)]",
"active:[--bevel-face:var(--craft-secondary-active)] active:[--bevel-hi:var(--craft-secondary-active-hi)] active:[--bevel-lo:var(--craft-secondary-active-lo)]",
],
},
},
}
);