Button
v0.4.0A flexible button component with variants, sizes, loading and disabled states.
registry/behivetech/atoms/button · depends on @behivetech/get-class-name
button.tsx
1import { type ButtonHTMLAttributes } from "react";2import { Slot } from "@radix-ui/react-slot";3import { getClassName } from "@behivetech/get-class-name";4import styles from "./button.module.scss";56export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";7export type ButtonSize = "sm" | "md" | "lg";89export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {10 /** Controls the color/style treatment; defaults to primary */11 variant?: ButtonVariant;12 /** Controls the size; defaults to md */13 size?: ButtonSize;14 /** Shows a spinner and disables the button while an action is in flight */15 loading?: boolean;16 /** Renders the button as a square icon-only control with no visible label spacing */17 iconOnly?: boolean;18 /** Additional class names to merge with the component root element */19 className?: string;20 /** Button label/content */21 children?: React.ReactNode;22 /** Renders the button's props onto its single child element (via Radix `Slot`) instead of a `<button>` */23 asChild?: boolean;24}2526export const Button = ({27 variant = "primary",28 size = "md",29 loading = false,30 iconOnly = false,31 disabled,32 className,33 children,34 asChild = false,35 ...rest36}: ButtonProps) => {37 const [rootClass, getChildClass] = getClassName({38 className,39 rootClass: "BHT__Button",40 modifiers: {41 [variant]: true,42 [size]: true,43 loading,44 disabled: !!disabled,45 "icon-only": iconOnly,46 },47 styles,48 });4950 const Comp = (asChild ? Slot : "button") as React.ElementType;5152 return (53 <Comp54 {...rest}55 className={rootClass}56 disabled={disabled || loading}57 aria-busy={loading || undefined}58 >59 {asChild ? (60 children61 ) : (62 <>63 {loading && (64 <span className={getChildClass("spinner")} aria-hidden="true" />65 )}66 <span className={getChildClass("label")}>{children}</span>67 </>68 )}69 </Comp>70 );71};
button.module.scss
1// Material Design 3 button.2//3// The public API is unchanged — `variant` × `size`, plus `loading`, `iconOnly`4// and `asChild`. What changed is that every value now comes from an5// `--md-sys-*` token, so the component re-themes with the palette instead of6// carrying a brand of its own.7//8// Variant names map onto MD3's button types rather than being renamed, because9// the prop is part of this package's published API:10//11// primary -> Filled secondary -> Outlined12// ghost -> Text danger -> Filled, on the error role13//14// Each color falls back to the pre-MD3 `--color-*` name, which15// `theme.base-styles` aliases to the matching MD3 role. That keeps this working16// against an older base-styles instead of rendering unstyled.17//18// **Structure.** Modifiers belong to the root only — a child never carries19// `--sm`. Inside a modifier block, `&.BHT__Button` is the root element20// (compound) and `.BHT__Button` without the `&` is a descendant (a child node).21// Pseudo-class blocks (`&:hover`, `&:disabled`) are states rather than22// modifiers and stay unwrapped.2324.BHT__Button {25 // `isolation` keeps the state layer's stacking context local, so a button26 // inside a positioned parent can't have its ::after escape behind a sibling.27 position: relative;28 isolation: isolate;29 display: inline-flex;30 align-items: center;31 justify-content: center;32 gap: var(--spacing-2, 0.5rem);33 border: 1px solid transparent;34 // MD3 buttons are pill-shaped. This is the single most visible change from35 // the previous styling, which used the 6px `--radius-md`.36 border-radius: var(--md-sys-shape-corner-full, 9999px);37 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));38 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);39 font-weight: var(--md-sys-typescale-label-large-weight, 500);40 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);41 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);42 cursor: pointer;43 white-space: nowrap;44 text-decoration: none;45 user-select: none;46 transition:47 background var(--md-sys-motion-duration-short-3, 150ms)48 var(--md-sys-motion-easing-standard, ease),49 color var(--md-sys-motion-duration-short-3, 150ms)50 var(--md-sys-motion-easing-standard, ease),51 border-color var(--md-sys-motion-duration-short-3, 150ms)52 var(--md-sys-motion-easing-standard, ease);5354 // --- Children ------------------------------------------------------------5556 &__label {57 display: inline-flex;58 align-items: center;59 }6061 &__spinner {62 display: inline-block;63 width: 1em;64 height: 1em;65 border: 2px solid currentColor;66 border-top-color: transparent;67 border-radius: var(--md-sys-shape-corner-full, 9999px);68 animation: button-spin 0.6s linear infinite;69 }7071 // --- State layer ---------------------------------------------------------72 //73 // **This replaces six hardcoded brand colors and two `filter: brightness()`74 // rules, and it is the whole reason the component re-themes.**75 //76 // MD3 expresses interaction as a translucent veil of the *content* color over77 // the container. `currentColor` is already that content color in every78 // variant — `on-primary` on a filled button, `primary` on an outlined or text79 // one — so one rule serves all four variants and follows the palette for80 // free. The old `rgba(247, 179, 52, 0.1)` was a fixed amber that stayed amber81 // no matter what `--color-primary` was set to, and `filter: brightness()`82 // lightens on a dark surface and washes out on a light one.83 &::after {84 content: "";85 position: absolute;86 inset: 0;87 border-radius: inherit;88 background: currentColor;89 opacity: 0;90 pointer-events: none;91 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)92 var(--md-sys-motion-easing-standard, ease);93 }9495 &:hover:not(:disabled)::after {96 opacity: var(--md-sys-state-hover-opacity, 0.08);97 }9899 &:focus-visible::after {100 opacity: var(--md-sys-state-focus-opacity, 0.1);101 }102103 &:active:not(:disabled)::after {104 opacity: var(--md-sys-state-pressed-opacity, 0.1);105 }106107 // --- Touch target --------------------------------------------------------108 //109 // **The component previously had no minimum target, and `sm` (32px) and the110 // default `md` (40px) both sat under the 48dp floor.** Rather than inflating111 // the visual size — which would reflow every layout already built on these112 // buttons — an invisible pseudo-element extends the *hit area* to 48px while113 // the button keeps its drawn height.114 //115 // Deliberately px, not rem. A touch target is a physical property of the116 // finger, not of the type size, so it must not shrink when a reader sets a117 // smaller font.118 &::before {119 content: "";120 position: absolute;121 left: 0;122 right: 0;123 top: 50%;124 translate: 0 -50%;125 height: 100%;126 min-height: 48px;127 }128129 // --- Focus ---------------------------------------------------------------130 //131 // There was no focus style at all before this: a keyboard user had no idea132 // which control they were on. `:focus-visible` rather than `:focus` so a133 // pointer click doesn't leave a ring behind.134 &:focus-visible {135 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));136 outline-offset: 2px;137 }138139 // --- Size modifiers ------------------------------------------------------140 //141 // Heights are unchanged, so existing layouts keep their rhythm; the 48px142 // floor above is what makes the two smaller ones accessible.143 &--sm {144 &.BHT__Button {145 padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);146 min-height: 2rem;147 }148 }149150 &--md {151 &.BHT__Button {152 padding: var(--spacing-2, 0.5rem) var(--spacing-5, 1.25rem);153 min-height: 2.5rem;154 }155 }156157 &--lg {158 &.BHT__Button {159 font-size: var(--md-sys-typescale-title-medium-size, 1rem);160 padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);161 min-height: 3rem;162 }163 }164165 // --- Variant modifiers ---------------------------------------------------166167 &--primary {168 &.BHT__Button {169 background: var(--md-sys-color-primary, var(--color-primary));170 color: var(--md-sys-color-on-primary, var(--color-on-primary));171 border-color: transparent;172 }173 }174175 &--secondary {176 &.BHT__Button {177 background: transparent;178 color: var(--md-sys-color-primary, var(--color-primary));179 border-color: var(--md-sys-color-outline, var(--color-border-strong));180 }181 }182183 &--ghost {184 &.BHT__Button {185 background: transparent;186 color: var(--md-sys-color-primary, var(--color-primary));187 border-color: transparent;188 }189 }190191 &--danger {192 &.BHT__Button {193 background: var(--md-sys-color-error, var(--color-error));194 color: var(--md-sys-color-on-error, #ffffff);195 border-color: transparent;196 }197 }198199 // --- Icon-only -----------------------------------------------------------200 //201 // The width rules previously read `.Button--sm` and the label rule202 // `.Button__label`, both missing the `BHT__` the markup actually carries, so203 // every rule in this block matched nothing and icon-only buttons had no204 // width at all.205 &--icon-only {206 &.BHT__Button {207 padding: 0;208209 &--sm {210 width: 2rem;211 }212213 &--md {214 width: 2.5rem;215 }216217 &--lg {218 width: 3rem;219 }220 }221222 .BHT__Button {223 &__label {224 display: inline-flex;225 align-items: center;226 justify-content: center;227 }228 }229 }230231 // --- Disabled / loading --------------------------------------------------232 //233 // MD3 expresses disabled as 38% of the content color on a 12% container234 // rather than a blanket opacity, but a flat `opacity` is what the rest of235 // this library uses and changing it here alone would make Button the odd one236 // out. Worth revisiting across the set, not in isolation.237 &--disabled {238 &.BHT__Button {239 opacity: 0.45;240 cursor: not-allowed;241 pointer-events: none;242 }243 }244245 &:disabled {246 opacity: 0.45;247 cursor: not-allowed;248 pointer-events: none;249 }250251 &--loading {252 &.BHT__Button {253 cursor: wait;254 pointer-events: none;255 }256 }257}258259@keyframes button-spin {260 to {261 transform: rotate(360deg);262 }263}
index.ts
1export { Button } from "./button.js";2export type { ButtonProps, ButtonVariant, ButtonSize } from "./button.js";
button.composition.tsx
1import { Button } from "./button.js";23export const PrimaryButton = () => <Button variant="primary">Primary</Button>;4export const SecondaryButton = () => (5 <Button variant="secondary">Secondary</Button>6);7export const GhostButton = () => <Button variant="ghost">Ghost</Button>;8export const DangerButton = () => <Button variant="danger">Danger</Button>;9export const SmallButton = () => <Button size="sm">Small</Button>;10export const LargeButton = () => <Button size="lg">Large</Button>;11export const LoadingButton = () => <Button loading>Loading</Button>;12export const DisabledButton = () => <Button disabled>Disabled</Button>;13export const IconOnlyButton = () => (14 <Button iconOnly aria-label="Delete" variant="danger">15 ✕16 </Button>17);
button.spec.tsx
1import { render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Button } from "./button.js";45describe("Button", () => {6 it("renders children", () => {7 render(<Button>Click me</Button>);8 expect(screen.getByText("Click me")).toBeTruthy();9 });1011 it("is disabled when disabled prop is true", () => {12 render(<Button disabled>Disabled</Button>);13 expect(screen.getByRole("button")).toBeDisabled();14 });1516 it("is disabled when loading", () => {17 render(<Button loading>Loading</Button>);18 expect(screen.getByRole("button")).toBeDisabled();19 });2021 it("calls onClick when clicked", async () => {22 const onClick = vi.fn();23 render(<Button onClick={onClick}>Click me</Button>);24 await userEvent.click(screen.getByRole("button"));25 expect(onClick).toHaveBeenCalledTimes(1);26 });2728 it("does not call onClick when disabled", async () => {29 const onClick = vi.fn();30 render(31 <Button disabled onClick={onClick}>32 Disabled33 </Button>,34 );35 await userEvent.click(screen.getByRole("button"));36 expect(onClick).not.toHaveBeenCalled();37 });3839 it("applies the icon-only modifier class", () => {40 render(41 <Button iconOnly aria-label="Delete">42 ✕43 </Button>,44 );45 expect(screen.getByRole("button").className).toContain("icon-only");46 });4748 it("renders its single child directly via asChild instead of a <button>", () => {49 render(50 <Button asChild>51 <a href="/somewhere">Go</a>52 </Button>,53 );54 const link = screen.getByRole("link", { name: "Go" });55 expect(link).toBeTruthy();56 expect(screen.queryByRole("button")).toBeNull();57 });58});