The shared builder and stylesheet for every `@behivetech/icons.*` package: `createIcon`, the factory each icon package builds its component with, and the stylesheet that sizes them. It holds no glyphs; each one is its own package, `@behivetech/icons.<name>`, so an app loads only the icons it renders.

pnpm add @behivetech/icons.base

registry/behivetech/icons/base

create-icon.tsx
1import { type ReactNode, type Ref, type SVGAttributes } from "react";
2import { getClassName } from "@behivetech/get-class-name";
3import styles from "./icon.module.scss";
4
5export type IconSize = "sm" | "md" | "lg" | "xl";
6
7export interface IconBaseProps extends SVGAttributes<SVGSVGElement> {
8 /** Controls width/height; defaults to md */
9 size?: IconSize;
10 /** Additional class names to merge with the component root element */
11 className?: string;
12 /**
13 * Accessible name of the icon, rendered as the svg's `<title>`. When set
14 * the icon is exposed as an image with this name; without it (and without
15 * `aria-label` or `aria-labelledby`) the icon is decorative and hidden from
16 * assistive technology. Pass an already translated string.
17 */
18 titleAccess?: string;
19 /** Ref to the root svg element */
20 ref?: Ref<SVGSVGElement>;
21}
22
23export type IconComponent = (props: IconBaseProps) => ReactNode;
24
25/**
26 * Builds an icon component from its glyph geometry, the way MUI's
27 * `createSvgIcon` does. Each icon package (`@scope/icons.<name>`) calls this
28 * once, and lists this package as a dependency (`workspace:^`), so an app has one
29 * factory and one stylesheet however many icons it uses. Mark each call `PURE`
30 * (a `#__PURE__` comment) so a bundler can drop an icon nothing uses.
31 *
32 * @param displayName The component's name in devtools, e.g. `CheckIcon`
33 * @param children The svg's geometry: one element or a fragment of them, on a 24 by 24 grid
34 * @returns A component taking `IconBaseProps`
35 */
36export function createIcon(
37 displayName: string,
38 children: ReactNode,
39): IconComponent {
40 // React 19 passes `ref` to a function component as an ordinary prop, so
41 // there is no `forwardRef` wrapper here; the ref lands on the root svg. Nor is
42 // there a `memo`: the glyph is one element built once, and re-rendering an
43 // svg of a few nodes costs less than comparing its props.
44 const Component = ({
45 size = "md",
46 className,
47 titleAccess,
48 ref,
49 ...rest
50 }: IconBaseProps) => {
51 // `rootClass` is "BHT__Icon", not "BHT__CreateIcon": this file is the
52 // factory, not a component, and every glyph it builds renders as an Icon.
53 // A conventions audit keying off the file name will flag this — it is
54 // correct as is.
55 const [rootClass] = getClassName({
56 className,
57 rootClass: "BHT__Icon",
58 modifiers: { [size]: true },
59 styles,
60 });
61 const isLabelled = Boolean(
62 titleAccess || rest["aria-label"] || rest["aria-labelledby"],
63 );
64
65 // `rest` comes after the defaults so a caller can override any of them
66 // (`strokeWidth`, `viewBox`, `aria-hidden`), as with MUI's SvgIcon.
67 return (
68 <svg
69 ref={ref}
70 viewBox="0 0 24 24"
71 fill="none"
72 stroke="currentColor"
73 strokeWidth={2}
74 strokeLinecap="round"
75 strokeLinejoin="round"
76 focusable="false"
77 aria-hidden={isLabelled ? undefined : true}
78 role={isLabelled ? "img" : undefined}
79 {...rest}
80 className={rootClass}
81 >
82 {children}
83 {titleAccess ? <title>{titleAccess}</title> : null}
84 </svg>
85 );
86 };
87
88 Component.displayName = displayName;
89 return Component;
90}
icon.module.scss
1.BHT__Icon {
2 display: inline-block;
3 flex-shrink: 0;
4
5 &--sm {
6 width: 1rem;
7 height: 1rem;
8 }
9
10 &--md {
11 width: 1.25rem;
12 height: 1.25rem;
13 }
14
15 &--lg {
16 width: 1.5rem;
17 height: 1.5rem;
18 }
19
20 &--xl {
21 width: 2.5rem;
22 height: 2.5rem;
23 }
24}
index.ts
1export { createIcon } from "./create-icon.js";
2export type { IconBaseProps, IconComponent, IconSize } from "./create-icon.js";
icon.example.tsx
1import { createIcon } from "./create-icon.js";
2
3const SquareIcon = createIcon(
4 "SquareIcon",
5 <rect x="4" y="4" width="16" height="16" rx="2" />,
6);
7
8/** An icon built with `createIcon`, at each size. Real icons come from the `@behivetech/icons.*` packages. */
9export const IconSizes = () => (
10 <>
11 <SquareIcon size="sm" />
12 <SquareIcon />
13 <SquareIcon size="lg" />
14 <SquareIcon size="xl" />
15 </>
16);
17
18/** Named with `titleAccess`, so it is announced as an image. */
19export const NamedIcon = () => <SquareIcon titleAccess="Square" />;
create-icon.spec.tsx
1import { createRef } from "react";
2import { render } from "@testing-library/react";
3import { createIcon } from "./create-icon.js";
4
5const TestIcon = createIcon("TestIcon", <path d="M0 0h24v24H0z" />);
6
7const renderSvg = (props: Parameters<typeof TestIcon>[0] = {}) => {
8 const { container } = render(<TestIcon {...props} />);
9 return container.querySelector("svg") as SVGSVGElement;
10};
11
12describe("createIcon accessibility", () => {
13 it("hides a decorative icon from assistive technology", () => {
14 const svg = renderSvg();
15 expect(svg.getAttribute("aria-hidden")).toBe("true");
16 expect(svg.hasAttribute("role")).toBe(false);
17 expect(svg.querySelector("title")).toBeNull();
18 });
19
20 it("stays out of the tab order in browsers that make svg focusable", () => {
21 expect(renderSvg().getAttribute("focusable")).toBe("false");
22 });
23
24 it("exposes titleAccess as an image named by a <title>", () => {
25 const svg = renderSvg({ titleAccess: "Delete" });
26 expect(svg.getAttribute("role")).toBe("img");
27 expect(svg.hasAttribute("aria-hidden")).toBe(false);
28 expect(svg.querySelector("title")?.textContent).toBe("Delete");
29 });
30
31 it("keeps the glyph next to the title", () => {
32 const svg = renderSvg({ titleAccess: "Delete" });
33 expect(svg.querySelector("path")).not.toBeNull();
34 });
35
36 it("exposes an icon named by aria-label as an image", () => {
37 const svg = renderSvg({ "aria-label": "Delete" });
38 expect(svg.getAttribute("role")).toBe("img");
39 expect(svg.hasAttribute("aria-hidden")).toBe(false);
40 expect(svg.getAttribute("aria-label")).toBe("Delete");
41 expect(svg.querySelector("title")).toBeNull();
42 });
43
44 it("exposes an icon named by aria-labelledby as an image", () => {
45 const svg = renderSvg({ "aria-labelledby": "label-id" });
46 expect(svg.getAttribute("role")).toBe("img");
47 expect(svg.hasAttribute("aria-hidden")).toBe(false);
48 });
49
50 it("lets a caller override aria-hidden", () => {
51 const svg = renderSvg({ "aria-hidden": false });
52 expect(svg.getAttribute("aria-hidden")).toBe("false");
53 });
54});
55
56describe("createIcon element", () => {
57 it("forwards a ref to the root svg", () => {
58 const ref = createRef<SVGSVGElement>();
59 const { container } = render(<TestIcon ref={ref} />);
60 expect(ref.current).toBe(container.querySelector("svg"));
61 });
62
63 it("sets displayName", () => {
64 expect(TestIcon.displayName).toBe("TestIcon");
65 });
66
67 it("defaults to a 24 grid and lets a caller change it", () => {
68 expect(renderSvg().getAttribute("viewBox")).toBe("0 0 24 24");
69 expect(renderSvg({ viewBox: "0 0 16 16" }).getAttribute("viewBox")).toBe(
70 "0 0 16 16",
71 );
72 });
73
74 it("keeps the BEM root class when a caller passes className", () => {
75 const tokens = [...renderSvg({ className: "custom" }).classList];
76 expect(tokens).toContain("custom");
77 expect(tokens.some((token) => /^_?BHT__Icon(_\w+)?$/.test(token))).toBe(
78 true,
79 );
80 });
81});