I remember years ago when I first used React Select, I wished it came with fewer assumptions. Instead of simply adding what I needed, I had to start by undoing what was already there.
But on the flip side, having no styles at all isn’t much better. Totally unstyled components can take some time to get right, sometimes that is even more work than undoing some styles.
So there’s this tension: you want to ship a sensible baseline of styles, but avoid going too far into the details.
Modern component libraries often ship with both pre‑styled components and unstyled (headless) variants.
Tailwind Labs for example offers Headless UI—unstyled, accessible components for React and Vue—and Catalyst, a React UI kit built with Tailwind (and using Headless UI) that provides opinionated, production‑ready building blocks.
MUI introduced the unstyled component library Base UI fairly recently in its history.
And Adobe maintains React Aria—unstyled components and hooks focused on behavior, accessibility, and internationalization—alongside React Spectrum, the fully styled React implementation of Adobe’s Spectrum design system.
With System42, I want to take a similar approach: a single package, sys42/ui
, that provides both components with thoughtful, themeable defaults and, at the same time, hooks and clear guidance so you can easily create app‑local variants with full control over styles and markup.
For every component, there are two hooks you can use to build your own component on top of the existing one: one named after the component (for example, useButton
), which includes styling and styling‑related props; and another prefixed with Base
(for example, useBaseButton
), which is free of styling opinions.
Use the first when you just want to alter the rendering logic—for example, to create a button component that renders an a
instead of a button
:
import { createComponent } from "../helpers";
import { ButtonProps, useButton } from "./useButton";
export const ButtonA = createComponent<ButtonProps, "a">("a", (hookOptions) => {
const { elementProps, elementRef } = useButton(hookOptions);
return <a {...elementProps} ref={elementRef} />;
});
When using the base hook, you can pass a middleware‑like function as the second argument to apply style‑related props to the rendered element. Here’s an example of a Button
with custom styles using CSS Modules:
import { BaseButtonProps, createComponent, useBaseButton } from "@sys42/ui";
import { cn } from "@sys42/utils";
import styles from "./styles.module.css";
export const Button = createComponent<BaseButtonProps, "button">(
"button",
(hookOptions) => {
const { elementProps, elementRef } = useBaseButton(hookOptions, (draft) => {
draft.elementProps.className = cn(
draft.elementProps.className,
styles.button,
);
});
return <button {...elementProps} ref={elementRef} />;
},
);
One drawback of this approach: if you name your local components identically to the library’s (for example, a local Button
), you must ensure your imports reference the local Button
rather than the library’s default Button
export.
One solution is to create a local alias that points to a file where you export your local components; most editors will prefer that path for auto‑imports.
Additionally, you could use an ESLint rule that warns when a library component is used while a local version exists.
I hope you found reading this interesting. If you have any thoughts or ideas on this topic, let me know!
Top comments (0)