Behive Tech Registry

Spinner

v0.4.0

A loading spinner with size variants and accessible labeling.

pnpm add @behivetech/atoms.spinner

registry/behivetech/atoms/spinner · depends on @behivetech/get-class-name

spinner.tsx
1import { type HTMLAttributes } from "react";
2import { getClassName } from "@behivetech/get-class-name";
3import styles from "./spinner.module.scss";
4
5export type SpinnerSize = "sm" | "md" | "lg";
6
7export interface SpinnerProps extends HTMLAttributes<HTMLSpanElement> {
8 /** Controls the size; defaults to md */
9 size?: SpinnerSize;
10 /** Accessible label announced to screen readers while loading */
11 label?: string;
12 /** Additional class names to merge with the component root element */
13 className?: string;
14}
15
16export const Spinner = ({
17 size = "md",
18 label = "Loading...",
19 className,
20 ...rest
21}: SpinnerProps) => {
22 const [rootClass, getChildClass] = getClassName({
23 className,
24 rootClass: "BHT__Spinner",
25 modifiers: { [size]: true },
26 styles,
27 });
28
29 return (
30 <span {...rest} className={rootClass} role="status" aria-label={label}>
31 <span className={getChildClass("circle")} aria-hidden="true" />
32 </span>
33 );
34};
spinner.module.scss
1// Material Design 3 circular progress indicator (indeterminate): a `primary`
2// active indicator turning over a `secondary-container` track.
3
4.BHT__Spinner {
5 display: inline-flex;
6 align-items: center;
7 justify-content: center;
8
9 &__circle {
10 display: block;
11 border-style: solid;
12 border-color: var(
13 --md-sys-color-secondary-container,
14 var(--color-border-strong)
15 );
16 border-top-color: var(--md-sys-color-primary, var(--color-primary));
17 border-radius: var(--md-sys-shape-corner-full, 9999px);
18 animation: spinner-rotate 0.7s linear infinite;
19 }
20
21 &--sm {
22 .BHT__Spinner {
23 &__circle {
24 width: 1rem;
25 height: 1rem;
26 border-width: 2px;
27 }
28 }
29 }
30
31 &--md {
32 .BHT__Spinner {
33 &__circle {
34 width: 1.5rem;
35 height: 1.5rem;
36 border-width: 2px;
37 }
38 }
39 }
40
41 &--lg {
42 .BHT__Spinner {
43 &__circle {
44 width: 2.5rem;
45 height: 2.5rem;
46 border-width: 3px;
47 }
48 }
49 }
50}
51
52@keyframes spinner-rotate {
53 to {
54 transform: rotate(360deg);
55 }
56}
index.ts
1export { Spinner } from "./spinner.js";
2export type { SpinnerProps, SpinnerSize } from "./spinner.js";
spinner.composition.tsx
1import { Spinner } from './spinner.js';
2
3export const SmallSpinner = () => {
4 return <Spinner size="sm" />;
5};
6
7export const MediumSpinner = () => {
8 return <Spinner size="md" />;
9};
10
11export const LargeSpinner = () => {
12 return <Spinner size="lg" />;
13};
14
15export const SpinnerWithCustomLabel = () => {
16 return <Spinner size="md" label="Saving changes" />;
17};
spinner.spec.tsx
1import React from "react";
2import { render, screen } from "@testing-library/react";
3import { Spinner } from "./spinner";
4
5describe("Spinner", () => {
6 it("renders with default aria label", () => {
7 render(<Spinner />);
8 expect(screen.getByRole("status")).toBeTruthy();
9 expect(screen.getByLabelText("Loading...")).toBeTruthy();
10 });
11
12 it("renders with custom aria label", () => {
13 render(<Spinner label="Saving changes" />);
14 expect(screen.getByLabelText("Saving changes")).toBeTruthy();
15 });
16
17 it("applies size modifier class", () => {
18 const { container } = render(<Spinner size="lg" />);
19 expect(container.firstChild?.className).toMatch(/Spinner--lg/);
20 });
21});