Behive Tech Component Registry

Button

v0.5.0Stable

A flexible button component with variants, sizes, loading and disabled states.

pnpm add @behivetech/atoms.button

registry/behivetech/atoms/button

button.tsx
1import { type ButtonHTMLAttributes } from "react";
2import { Slot } from "@radix-ui/react-slot";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./button.module.scss";
5
6export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
7export type ButtonSize = "sm" | "md" | "lg";
8
9export 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}
25
26/**
27 * A button with `variant`, `size`, `loading` and `iconOnly` treatments.
28 *
29 * **Styling hook:** the `ghost` and `secondary` (outlined) variants' label
30 * colour is read from the `--button-label-color` custom property (default: the
31 * `primary` role). A surface where `primary` falls under 4.5:1, such as Dark
32 * `surface-container-high` in the-behive's Dark tokens (4.02:1), sets it on its
33 * own class, e.g. `--button-label-color: var(--md-sys-color-secondary)`. Inside
34 * a `Modal` or `Drawer` set it through their `className` or on a class inside
35 * the body: their content portals to `body`, so a wrapper around them never
36 * reaches the buttons. `primary` and `danger` ignore it.
37 */
38export const Button = ({
39 variant = "primary",
40 size = "md",
41 loading = false,
42 iconOnly = false,
43 disabled,
44 className,
45 children,
46 asChild = false,
47 ...rest
48}: ButtonProps) => {
49 const [rootClass, getChildClass] = getClassName({
50 className,
51 rootClass: "BHT__Button",
52 modifiers: {
53 [variant]: true,
54 [size]: true,
55 loading,
56 disabled: !!disabled,
57 "icon-only": iconOnly,
58 },
59 styles,
60 });
61
62 const Comp = (asChild ? Slot : "button") as React.ElementType;
63
64 return (
65 <Comp
66 {...rest}
67 className={rootClass}
68 disabled={disabled || loading}
69 aria-busy={loading || undefined}
70 >
71 {asChild ? (
72 children
73 ) : (
74 <>
75 {loading && (
76 <span className={getChildClass("spinner")} aria-hidden="true" />
77 )}
78 <span className={getChildClass("label")}>{children}</span>
79 </>
80 )}
81 </Comp>
82 );
83};
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 an
5// `--md-sys-*` token, so the component re-themes with the palette instead of
6// carrying a brand of its own.
7//
8// Variant names map onto MD3's button types rather than being renamed, because
9// the prop is part of this package's published API:
10//
11// primary -> Filled secondary -> Outlined
12// ghost -> Text danger -> Filled, on the error role
13//
14// Each color falls back to the pre-MD3 `--color-*` name, which
15// `cms.base-styles` aliases to the matching MD3 role. That keeps this working
16// against an older base-styles instead of rendering unstyled.
17//
18// **Structure.** Modifiers belong to the root only — a child never carries
19// `--sm`. Inside a modifier block, `&.BHT__Button` is the root element
20// (compound) and `.BHT__Button` without the `&` is a descendant (a child node).
21// The ghost and secondary (outlined) variants' label colour is the one public
22// styling hook: `--button-label-color` (default `primary`).
23// Pseudo-class blocks (`&:hover`, `&:disabled`) are states rather than
24// modifiers and stay unwrapped.
25
26.BHT__Button {
27 // `isolation` keeps the state layer's stacking context local, so a button
28 // inside a positioned parent can't have its ::after escape behind a sibling.
29 position: relative;
30 isolation: isolate;
31 display: inline-flex;
32 align-items: center;
33 justify-content: center;
34 gap: var(--spacing-2, 0.5rem);
35 border: 1px solid transparent;
36 // MD3 buttons are pill-shaped. This is the single most visible change from
37 // the previous styling, which used the 6px `--radius-md`.
38 border-radius: var(--md-sys-shape-corner-full, 9999px);
39 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
40 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
41 font-weight: var(--md-sys-typescale-label-large-weight, 500);
42 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
43 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
44 cursor: pointer;
45 white-space: nowrap;
46 text-decoration: none;
47 user-select: none;
48 transition:
49 background var(--md-sys-motion-duration-short-3, 150ms)
50 var(--md-sys-motion-easing-standard, ease),
51 color var(--md-sys-motion-duration-short-3, 150ms)
52 var(--md-sys-motion-easing-standard, ease),
53 border-color var(--md-sys-motion-duration-short-3, 150ms)
54 var(--md-sys-motion-easing-standard, ease);
55
56 // --- Children ------------------------------------------------------------
57
58 &__label {
59 display: inline-flex;
60 align-items: center;
61 }
62
63 &__spinner {
64 display: inline-block;
65 width: 1em;
66 height: 1em;
67 border: 2px solid currentColor;
68 border-top-color: transparent;
69 border-radius: var(--md-sys-shape-corner-full, 9999px);
70 animation: button-spin 0.6s linear infinite;
71 }
72
73 // --- State layer ---------------------------------------------------------
74 //
75 // **This replaces six hardcoded brand colors and two `filter: brightness()`
76 // rules, and it is the whole reason the component re-themes.**
77 //
78 // MD3 expresses interaction as a translucent veil of the *content* color over
79 // the container. `currentColor` is already that content color in every
80 // variant — `on-primary` on a filled button, `--button-label-color` (default
81 // `primary`) on an outlined or text one — so one rule serves all four
82 // variants and follows the palette for free. The old `rgba(247, 179, 52, 0.1)`
83 // was a fixed amber that stayed amber no matter what `--color-primary` was
84 // set to, and `filter: brightness()` lightens on a dark surface and washes
85 // out on a light one.
86 &::after {
87 content: "";
88 position: absolute;
89 inset: 0;
90 border-radius: inherit;
91 background: currentColor;
92 opacity: 0;
93 pointer-events: none;
94 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
95 var(--md-sys-motion-easing-standard, ease);
96 }
97
98 &:hover:not(:disabled)::after {
99 opacity: var(--md-sys-state-hover-opacity, 0.08);
100 }
101
102 &:focus-visible::after {
103 opacity: var(--md-sys-state-focus-opacity, 0.1);
104 }
105
106 &:active:not(:disabled)::after {
107 opacity: var(--md-sys-state-pressed-opacity, 0.1);
108 }
109
110 // --- Touch target --------------------------------------------------------
111 //
112 // **The component previously had no minimum target, and `sm` (32px) and the
113 // default `md` (40px) both sat under the 48dp floor.** Rather than inflating
114 // the visual size — which would reflow every layout already built on these
115 // buttons — an invisible pseudo-element extends the *hit area* to 48px while
116 // the button keeps its drawn height.
117 //
118 // Deliberately px, not rem. A touch target is a physical property of the
119 // finger, not of the type size, so it must not shrink when a reader sets a
120 // smaller font.
121 &::before {
122 content: "";
123 position: absolute;
124 left: 0;
125 right: 0;
126 top: 50%;
127 translate: 0 -50%;
128 height: 100%;
129 min-height: 48px;
130 }
131
132 // --- Focus ---------------------------------------------------------------
133 //
134 // There was no focus style at all before this: a keyboard user had no idea
135 // which control they were on. `:focus-visible` rather than `:focus` so a
136 // pointer click doesn't leave a ring behind.
137 &:focus-visible {
138 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
139 outline-offset: 2px;
140 }
141
142 // --- Size modifiers ------------------------------------------------------
143 //
144 // Heights are unchanged, so existing layouts keep their rhythm; the 48px
145 // floor above is what makes the two smaller ones accessible.
146 &--sm {
147 &.BHT__Button {
148 padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);
149 min-height: 2rem;
150 }
151 }
152
153 &--md {
154 &.BHT__Button {
155 padding: var(--spacing-2, 0.5rem) var(--spacing-5, 1.25rem);
156 min-height: 2.5rem;
157 }
158 }
159
160 &--lg {
161 &.BHT__Button {
162 font-size: var(--md-sys-typescale-title-medium-size, 1rem);
163 padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);
164 min-height: 3rem;
165 }
166 }
167
168 // --- Variant modifiers ---------------------------------------------------
169
170 &--primary {
171 &.BHT__Button {
172 background: var(--md-sys-color-primary, var(--color-primary));
173 color: var(--md-sys-color-on-primary, var(--color-on-primary));
174 border-color: transparent;
175 }
176 }
177
178 &--secondary {
179 &.BHT__Button {
180 background: transparent;
181 color: var(
182 --button-label-color,
183 var(--md-sys-color-primary, var(--color-primary))
184 );
185 border-color: var(--md-sys-color-outline, var(--color-border-strong));
186 }
187 }
188
189 // The ghost and secondary labels read `--button-label-color` so a raised
190 // surface that can't take a `primary` label (4.02:1 on the-behive's Dark
191 // `surface-container-high`, under the 4.5:1 small-text rule) re-themes it
192 // from its own class, the way Modal re-themes Card through `--card-border`,
193 // instead of selecting into these classes.
194 &--ghost {
195 &.BHT__Button {
196 background: transparent;
197 color: var(
198 --button-label-color,
199 var(--md-sys-color-primary, var(--color-primary))
200 );
201 border-color: transparent;
202 }
203 }
204
205 &--danger {
206 &.BHT__Button {
207 background: var(--md-sys-color-error, var(--color-error));
208 color: var(--md-sys-color-on-error, #ffffff);
209 border-color: transparent;
210 }
211 }
212
213 // --- Icon-only -----------------------------------------------------------
214 //
215 // The width rules previously read `.Button--sm` and the label rule
216 // `.Button__label`, both missing the `BHT__` the markup actually carries, so
217 // every rule in this block matched nothing and icon-only buttons had no
218 // width at all.
219 &--icon-only {
220 &.BHT__Button {
221 padding: 0;
222
223 &--sm {
224 width: 2rem;
225 }
226
227 &--md {
228 width: 2.5rem;
229 }
230
231 &--lg {
232 width: 3rem;
233 }
234 }
235
236 .BHT__Button {
237 &__label {
238 display: inline-flex;
239 align-items: center;
240 justify-content: center;
241 }
242 }
243 }
244
245 // --- Disabled / loading --------------------------------------------------
246 //
247 // MD3 expresses disabled as 38% of the content color on a 12% container
248 // rather than a blanket opacity, but a flat `opacity` is what the rest of
249 // this library uses and changing it here alone would make Button the odd one
250 // out. Worth revisiting across the set, not in isolation.
251 &--disabled {
252 &.BHT__Button {
253 opacity: 0.45;
254 cursor: not-allowed;
255 pointer-events: none;
256 }
257 }
258
259 &:disabled {
260 opacity: 0.45;
261 cursor: not-allowed;
262 pointer-events: none;
263 }
264
265 &--loading {
266 &.BHT__Button {
267 cursor: wait;
268 pointer-events: none;
269 }
270 }
271}
272
273@keyframes button-spin {
274 to {
275 transform: rotate(360deg);
276 }
277}
index.ts
1export { Button } from "./button.js";
2export type { ButtonProps, ButtonVariant, ButtonSize } from "./button.js";
button.example.tsx
1import { Button } from "./button.js";
2
3export 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";
4
5describe("Button", () => {
6 it("renders children", () => {
7 render(<Button>Click me</Button>);
8 expect(screen.getByText("Click me")).toBeTruthy();
9 });
10
11 it("is disabled when disabled prop is true", () => {
12 render(<Button disabled>Disabled</Button>);
13 expect(screen.getByRole("button")).toBeDisabled();
14 });
15
16 it("is disabled when loading", () => {
17 render(<Button loading>Loading</Button>);
18 expect(screen.getByRole("button")).toBeDisabled();
19 });
20
21 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 });
27
28 it("does not call onClick when disabled", async () => {
29 const onClick = vi.fn();
30 render(
31 <Button disabled onClick={onClick}>
32 Disabled
33 </Button>,
34 );
35 await userEvent.click(screen.getByRole("button"));
36 expect(onClick).not.toHaveBeenCalled();
37 });
38
39 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 });
47
48 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});
59
60describe("Button ghost variant", () => {
61 it("renders the ghost modifier that carries the --button-label-color hook", () => {
62 render(<Button variant="ghost">Cancel</Button>);
63 expect(screen.getByRole("button").className).toContain(
64 "BHT__Button--ghost",
65 );
66 });
67});