bestax-custom-component · diff
git:20261012.582aff0 to git:20261012.0c8fb10
3 added, 3 removed. Audit A to A.
---
name: bestax-custom-component
description: Build a custom React component in the bestax/Bulma style. In an app using @allxsmith/bestax-bulma — compose existing components, helper props, public hooks (useBulmaClasses, usePrefixedClassNames), and --bulma-* CSS variables. In the bestax monorepo — the full component pipeline (SCSS partial, stories, tests, docs, wiring). Use when creating a component beyond stock Bulma or extending one.
license: MIT
---
# Building a custom component the bestax way
This skill teaches how to build a component that isn't in the library — composed from bestax
pieces in an app, or as a full library "extra" inside the bestax monorepo.
## Which context are you in?
- **The bestax monorepo** (the repo contains `bulma-ui/src/`) → follow
`references/library-contributor.md` instead of this file: five-file layout, SCSS partial,
stories, jest tests, docs page, wiring.
- **An app depending on `@allxsmith/bestax-bulma`** (e.g. scaffolded by `npm create bestax`) →
continue here. Everything below assumes public package imports and a plain Vite app.
For **form** components (Field/Control/Input/etc.) use the `bestax-form` skill instead.
## Check for an existing component first
Before building anything, **search the library for a component that already does this — or a
close synonym** — and tell the user what you found. Many requests are covered by an existing
element, or are best built by composing existing ones. Building a near-duplicate (a "label" when
`Tag` exists, a "banner" when `Notification` exists) fragments the API and is usually the wrong
call.
Where to look:
- `references/component-catalog.md` — **start here.** Every documented component with a one-line
purpose, grouped by category. Scan it for the name and its synonyms before anything else.
- https://bestax.io/docs/api — one doc page per shipped component (full props).
Then decide, and **surface the decision to the user**:
- **Exact / synonym match exists** → recommend using it. Don't build a duplicate. (E.g. a small
colored label/badge/chip → `Tag` / `Tags` already exist.)
- **Partial overlap** → prefer **composing** the existing pieces inside your new component
rather than re-implementing them. (E.g. a "profile card" → there's no `ProfileCard`, but
`Card`, `Image`, `Title`, `SubTitle`, and `Content` exist; build `ProfileCard` to compose them.)
- **Genuine gap** → build the new component using the pattern below.
State plainly which case applies before writing code, e.g. _"`Tag` already covers a colored
label — use that instead"_ or _"No `ProfileCard` exists; I'll build one composing the existing
`Card`/`Image`/`Title` elements."_
## Composition first
Build from existing components before writing any CSS: `Box`, `Card`, `Title`, `SubTitle`,
`Icon`, `Block`, `Content`, `Tag`, plus the shared Bulma helper props (spacing, color,
typography, flexbox). Some compound sub-parts are the exception — `Modal.Card`, `Tabs.Tab`,
`Message.Body` take **no Bulma helper props**, just `className` + HTML attributes
plus their own few (`Tabs.Tab` requires `index={i}` and has built-in `disabled` and
`icon`/`iconLibrary`/`iconVariant`/`iconSize`/`iconFeatures` — don't nest an `<Icon>` there) —
so put helper props on the parent or on an element inside them, never invent them there.
(`Card.*` sub-parts do take helper props, like `Table.*`/`Menu.*`/`Hero.*`.) Most "custom components" are a composition function — zero new styles.
See `examples/stat-card.tsx` for a complete worked example.
Rendering something only the browser knows (the reader's time zone, a `localStorage` value)?
Wrap it in the library's `ClientOnly` rather than checking `typeof window`: it renders a fallback
until the page has hydrated, so the server and client markup match. `references/api.md` has the
details.
A button that turns something on or off and stays that way (Bold, Mute, Show archived) is
`ToggleButton`: use it rather than `Button isActive`, which only draws a shade and sets no
`aria-pressed`. It's always `type="button"`, holds its own state (`defaultPressed`) or takes
`pressed` with `onPressedChange`, and fills with `color` (default `link`) only while pressed.
Its label stays the same in both states, and an icon-only one needs an `aria-label`. A set goes
- in `Buttons hasAddons role="group" aria-label="…"`. A row where only one can be on is a radio
- group, so it's `Radios`, never toggles that switch each other off. A setting saved with a form is
- a `Switch` or a `Checkbox`.
+ in `Buttons hasAddons role="group" aria-label="…"`, where it's also `Buttons.ToggleButton`. A row
+ where only one can be on is a radio group, so it's `Radios`, never toggles that switch each other
+ off. A setting saved with a form is a `Switch` or a `Checkbox`.
A panel opened from a button (a filter form, share options, an inline edit) is `Popover`: use
it rather than building one. It anchors the panel, moves focus in and back, and closes on
Escape and outside presses.
A preview that shows up on hover (a profile card on a username, a summary on a link) is
`HoverCard`: use it rather than wiring pointer events to a `Popover`. It opens on hover and
keyboard focus after a delay, lets the pointer move onto the card, closes on Escape, and ignores
touch unless `openOnTap` is on, which suits only a trigger with no tap action of its own (a
`Button` with no `onClick`, never a link). Its `trigger` has to be a link or a button, and a trigger in running text takes
`appendToBody`. A form never goes in one; that's `Popover`.
A menu of actions that opens on a right-click (Rename and Delete on a table row, Archive on a
card) is `ContextMenu`: use it rather than an `onContextMenu` handler that positions a
`Dropdown`. It opens at the point, also on Shift+F10, the context menu key and a long press,
and its items are `ContextMenu.Item` and `ContextMenu.Divider`, the same components as
`Dropdown.Item` and `Dropdown.Divider`. Its `trigger` is cloned, not wrapped, so a `Table.Tr`
works, and the menu needs `aria-label`. Put the same items in a visible `Dropdown` as well:
nothing tells a screen reader user a context menu is there, and on macOS only VoiceOver opens
it from the keyboard.
Building something else that floats (a command palette)? Render it through the library's
`Portal` rather than `createPortal`: it renders nothing on the server and during hydration, so
the server and client markup match. `references/api.md` has the details.
Building some other overlay that holds focus until it is dismissed (a command palette)? Use the
library's `useFocusTrap` rather than a hand-rolled Tab handler: it finds the tab stops the
browser visits and hands focus back on close. `references/api.md` has the signature and a worked
panel.
## The component spine
Same shape the library itself uses, with all imports from the package. Every reusable
component gets it — including pure compositions with zero CSS (a heading block, a labeled
wrapper): extend `BulmaClassesProps`, run your props through `useBulmaClasses`, merge its
`bulmaHelperClasses` into `className`, and spread the `rest` **it** returns — spreading the raw
props instead leaks helper props onto the DOM and emits none of their classes. The
`usePrefixedClassNames` root class is needed only when component-scoped CSS (or a variant
class) targets it — a zero-CSS composition may omit that call. File at
`src/components/MyComponent.tsx`:
```tsx
import type React from 'react';
import {
classNames,
usePrefixedClassNames,
useBulmaClasses,
type BulmaClassesProps,
} from '@allxsmith/bestax-bulma';
export interface MyComponentProps
extends
Omit<React.HTMLAttributes<HTMLDivElement>, 'color'>,
Omit<BulmaClassesProps, 'color'> {
color?: 'primary' | 'link' | 'info' | 'success' | 'warning' | 'danger';
}
export function MyComponent({
color,
className,
children,
...props
}: MyComponentProps) {
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
const mainClasses = usePrefixedClassNames('mycomponent', {
[`is-${color}`]: !!color,
});
return (
<div
className={classNames(mainClasses, bulmaHelperClasses, className)}
{...rest}
>
{children}
</div>
);
}
```
This gives your component the full Bulma helper-prop surface (`m`, `p`, `textAlign`, …) for
free. `references/api.md` documents the helpers.
## Styling ladder — use the lowest rung that works
**Rung 1 — helper props only (default).** House rules: never `style={{}}`. Layout with
`Block`/`Box` and `display="flex"`, `flexDirection`, `alignItems`, `justifyContent`, and space
the children with `gap`. Before writing `style={{ … }}` anywhere, translate each declaration:
| Inline style you're about to write | Helper props instead |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `marginTop: '1rem'` (any margin/padding) | `mt="4"` — `m`/`mt`/`mx`/`p`/`py`/… scale: `1`=0.25rem, `2`=0.5rem, `3`=0.75rem, `4`=1rem, `5`=1.5rem, `6`=3rem (nearest step) |
| `textAlign: 'center'` | `textAlign="centered"` (also `left`, `right`, `justified`) |
| `color: '#…'` | `textColor` with the nearest Bulma color: `primary`, `link`, `info`, `success`, `warning`, `danger`, `white`, `black`, `grey` (+ `grey-light`, `grey-dark`, …) |
| `backgroundColor: '#…'` | `bgColor` (same palette) |
| `fontSize: …` | `textSize="1"`…`"7"` (`1` largest) — for headings use `Title`/`SubTitle` `size` |
| `fontWeight: …` | `textWeight`: `light`, `normal`, `medium`, `semibold`, `bold` |
| `textTransform`, italics | `textTransform`: `uppercase`, `lowercase`, `capitalized`, `italic` |
| `display: 'flex'` + flex properties | same-named props: `display="flex"`, `flexDirection`, `justifyContent`, `alignItems`, `flexWrap` |
| `height: '100%'` on a flex child | `flexGrow="1"` |
| `display: 'none'` | `visibility="hidden"`, or responsive `display*` props (`displayMobile`, `displayTablet`, …) |
| `gap: '1rem'` (flex or grid) | `gap="2"`, or one axis: `columnGap`, `rowGap`; gap scale: `1`=0.5rem, `2`=1rem, `4`=2rem, half steps (`"1.5"`) |
Spacing, typography, and flex helpers are on every component; `textColor`/`bgColor` are on
the content components you'll compose with (`Box`, `Block`, `Title`, `Content`, `Card`, …) —
the ones with a semantic `color` variant (`Tag`, `Tabs`, `Panel`) take `color` instead.
`Notification` is the mixed case: it takes `textColor`, but its background comes from the
semantic `color` prop, not `bgColor`.
A value with no helper equivalent (`maxWidth: 720`, a one-off gradient) moves you to rung 2 —
a named class in a stylesheet — never to inline `style`.
**Rung 2 — a plain CSS file**, scoped under the component's class, consuming `--bulma-*`
variables — never literal colors, so `Theme` and dark mode keep working:
```css
/* src/components/MyComponent.css — import from the .tsx file */
.mycomponent {
/* component-scoped custom props, initialized from Bulma tokens:
any ancestor (or Theme) can re-theme by overriding them */
--mycomponent-radius: var(--bulma-radius);
--mycomponent-accent: var(--bulma-primary);
border-radius: var(--mycomponent-radius);
border: 1px solid var(--bulma-border);
background: var(--bulma-scheme-main);
color: var(--bulma-text);
}
.mycomponent .mycomponent-value {
color: var(--mycomponent-accent);
}
```
Caveat: with the prefixed CSS flavor / `ConfigProvider classPrefix`, `usePrefixedClassNames`
prefixes your classes too — your CSS selectors must match (or build them with plain
`classNames` instead).
**Rung 3 — real Sass (optional).** `npm i -D sass` — nothing else; Vite compiles imported
`.scss` zero-config, and `bulma` is resolvable because it's a runtime dependency of
bestax-bulma. Then the full `register-vars`/`getVar` pattern from
`references/library-contributor.md` works in-app. Prefixed flavor:
`@use 'bulma/sass/utilities/initial-variables' with ($class-prefix: 'bestax-')`.
## Verify in the browser
Types don't see layout. Run `npm run dev`, render the component, and actually look at it:
vertical centering of inline text (use `display="flex" alignItems="center"`, not line-height
hacks), balanced padding, nothing clipping, every color/size variant, and **dark mode**
legibility. Fix what you see, then re-check. No browser available (headless)? Fall back to
`npm run build` plus a Node `renderToString` smoke render, grep the emitted HTML for the
expected classes, and flag the visual pass as not done.
## Tests and stories in an app
The scaffolded app has **no test runner and no Storybook** — do not install or scaffold them
unasked. If the app already has vitest/jest + Testing Library, write the four test shapes:
render, prop→class mapping, helper-prop passthrough (`m="3"` → `m-3`), and the
`ConfigProvider classPrefix` case if the app uses a prefix.
## Checklist
- [ ] Inventory checked (catalog + bestax.io/docs/api) and the decision surfaced to the user.
- [ ] All imports from `@allxsmith/bestax-bulma` (no deep/internal paths).
- [ ] Composition first — existing components + helper props before any CSS; every reusable component gets the spine.
- [ ] No inline `style={{}}` anywhere — translate via the rung-1 mapping table; values with
no helper equivalent get a named class (rung 2).
- [ ] Lowest sufficient ladder rung (helper props → scoped CSS vars → Sass).
- [ ] All colors/radii derived from `--bulma-*` variables — no literals.
- [ ] Renders correctly via `npm run dev`, including dark mode.