--- 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 `` 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="…"`, where it's also `Buttons.ToggleButton`. A row where only one can be on is a radio group, so it's `SegmentedControl` (below), never toggles that switch each other off. A setting saved with a form is a `Switch` or a `Checkbox`. A row of buttons where only one can be on (text alignment, a list or grid view, monthly or yearly billing) is `SegmentedControl`: use it rather than `Buttons hasAddons` with `isActive` or a fill you move yourself. Give it an `aria-label`, put one `SegmentedControl.Segment value="…"` inside per option, and hold the choice with `defaultValue`, or `value` with `onChange(value)`. A screen reader hears a radio group, and the single tab stop, the arrow keys, Home and End come built in, so add no `onKeyDown` or `role` of your own. An icon-only segment needs its own `aria-label`, as an icon-only `ToggleButton` does. The arrow keys check the segment they land on, so `onChange` fires on each, and pressing the checked segment keeps it checked. It carries no form value: a choice the form submits is `Radios`. 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, 'color'>, Omit { 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 (
{children}
); } ``` 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). On `Title`/`SubTitle`, `as` sets the level and `size` picks it too, so an `

` at size 4 is `as="h3" textSize="4"` | | `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.