Skip to main content

Migrating from plain Bulma classes

Most React apps that use Bulma never picked a wrapper library. They import Bulma's stylesheet and write its classes on plain JSX:

<div className="columns is-mobile">
<div className="column is-half">
<a className="button is-primary is-large" href="/signup">
Sign up
</a>
<p className="has-text-centered mt-4">No card needed</p>
</div>
</div>

That works, and it stays working. What you give up is the part React is good at: typed props, autocomplete on every color and size, and components you can find in your editor instead of class strings you have to remember. The bulma-classes codemod moves that markup onto @allxsmith/bestax-bulma:

<Columns isMobile>
<Column size="half">
<Button as="a" color="primary" size="large" href="/signup">
Sign up
</Button>
<Paragraph textAlign="centered" mt="4">
No card needed
</Paragraph>
</Column>
</Columns>

bestax renders Bulma's own classes, so the page renders the same. The codemod converts an element only when the component renders the markup the element did: same tag, same classes, same attributes, with only the order of the classes free to change. Everything else stays as you wrote it, with a TODO(bestax-migrate) comment when there's a decision to make.

Run the codemod​

# Preview the changes and the TODO report without writing anything
pnpm dlx bestax-migrate bulma-classes src/ --dry

# Apply it
pnpm dlx bestax-migrate bulma-classes src/

(npx bestax-migrate … works the same.) The flags are the same as for the library sources: --print, --extensions, --css bestax|bulma|keep for the stylesheet target, and --no-deps to leave package.json alone.

Your styling stays yours. This source defaults to --css keep, because your Bulma stylesheet already styles every class a converted element renders: your stylesheet imports, your Sass and your Bulma version stay as they are, and package.json just gains @allxsmith/bestax-bulma. Moving to Bulma v1 is its own step, whenever you want it. --css bestax swaps your stylesheet import for bestax's bundle, moves a Bulma 0.9 Sass setup onto v1's @use form, bumps a pre-1.0 Bulma and swaps node-sass for sass; --css bulma does the same but keeps Bulma's stock stylesheet.

What converts​

  • Components: button, buttons, columns, column, grid, cell, container, section, hero and its parts, title, subtitle, box, content, block, notification, tag, tags, level and its parts, media and its parts, card and its parts, navbar and most of its parts, field and its parts, control, input, textarea, select, breadcrumb, image, menu and its label, list and items, the parts inside a modal but its close button, pagination and its list, links and ellipses, panel and its heading, tabs and blocks, tabs, icon, icon-text, file inside a field, delete, progress, skeleton-block, footer and table become their bestax components, with their modifier classes as props (is-primary → color="primary", is-half → size="half").
  • Helper classes become helper props on those components (mt-4 → mt="4", has-text-centered → textAlign="centered"), and on the plain tags bestax wraps: <p> becomes Paragraph, <span> becomes Span, and so on.
  • Wrappers a component renders itself fold into it: a .table-container around a table becomes <Table isResponsive>, and a .fixed-grid around a grid becomes <Grid isFixed fixedCols={3}>, as long as the wrapper holds nothing else and carries nothing of its own.
  • An element a component renders inside itself converts with it: a .select and its <select> become one SelectBase with the <select>'s attributes, a .breadcrumb and its <ul> become one Breadcrumb around the same <li>s, and an .image and its <img> become one <Image size="64x64" src="…" alt="…" />. An .image around anything else converts too, with its children kept as they are.
  • Children a component renders itself go: a .skeleton-lines holding bare, empty <div>s becomes <Skeleton variant="lines" lines={5} />, which renders those <div>s from the count.
  • Classes added under a condition in a clsx or classnames call: a flag becomes its prop, set to the condition (clsx('button', busy && 'is-loading') → <Button isLoading={busy}>), and whatever else the call adds stays in it.
  • Your own classes stay in className, which every bestax component passes through.

The full tables, class by class, are in the migrate skill's component map and prop map.

A couple of results look odd until you see why:

  • <h2 className="title is-4"> becomes <Title as="h2" className="is-4">, not <Title size="4">. bestax picks the heading tag from size, so size="4" would render an <h4>.
  • <div className="is-flex mt-4"> doesn't change at all. bestax has no plain <div> component, and the classes are valid Bulma, so there is nothing to do and nothing to flag.
  • A .card converts when an element written directly inside it is, or becomes, one of its parts. Card puts anything else inside a .card-content of its own, so a card whose text sits straight inside it stays markup (children:Card). Bulma's own example card converts whole, its <p> title and <a> footer links through as.
  • A .navbar converts when it carries Bulma's role="navigation" and an aria-label, which Navbar writes too. Its burger and its dropdown's .navbar-link stay markup (family:navbar-burger, family:navbar-link), and a .has-dropdown item becomes a Navbar.Item that keeps the class. Switching those to Navbar.Burger and Navbar.Dropdown is how the navbar gets bestax's toggle and keyboard handling, and it's a change you make by hand.
  • A .menu converts with its labels and lists, and with the items in a list: a <li> holding an <a>, and at most a bare nested <ul>, becomes a Menu.Item, found by where it sits. Menu.Item renders the <li> and the <a> together and splits its props between them, so an item whose attributes sit anywhere else stays as written.
  • A form converts piece by piece into Field, Control, InputBase, TextAreaBase and SelectBase, not into Input, TextArea or Select: those render a .field and a .control of their own, and the markup already has them. The .label and .help stay as written, and so does an input's or a select's color class (is-danger), since bestax's color also sets the text color.

What it leaves for you​

Anything that would change the markup stays as written:

  • Other computed classNames. A ternary between two classes, a template with expressions, a variable or another function is flagged with the component the element would become (dynamic-class:<Target>). Converting it means turning each condition into a prop, which is quick by hand and risky to guess at.
  • Components that render parts of their own. Modal adds dialog attributes, Dropdown renders its own trigger and menu, and so on. These families are flagged once each (family:<class>) and converted by hand.
  • Form markup around bestax already. Field and Control tell bestax's form controls inside them to skip their own wrappers, so a .field or .control that already holds one (from an earlier migration by hand) stays as it is (context:<Target>).
  • Elements bestax would render differently: a ref on a component that doesn't forward one, a spread, a tag the component can't render (<div className="section">), an attribute the component reads as a prop, or an element that is the only child of another component, which may clone it (<Link><a className="button">).
  • Next.js App Router projects. A file without 'use client' is left alone (rsc), because bestax's components are client components, and in an App Router project any module can render as a server component, not only those under app/. Files under pages/ convert.
  • Files React doesn't render the usual way: a component styled with <style jsx>, whose scoped styles would miss a converted element (styled-jsx), JSX that renders through Preact or another runtime (jsx-runtime), and CommonJS files (imports).

The TODO report​

Every TODO sits on the statement around the element, and the run ends with a report grouped by rule. The ones you'll see most:

WhatWhat to do
dynamic-class:<Target>Convert by hand, turning each condition into its prop
family:<class>Rebuild that block from the component's docs page, parts and all
tag:<Target>Change the tag if you want the component, or keep the markup
defaults:DeleteAdd type="button" and a real aria-label to the .delete button, then re-run
ref:<Target>Keep the element; the component won't pass the ref through
rscAdd 'use client' if the file can be a client component, then re-run
legacy:tileRebuild tiles with Grid and Cell; see the Bulma 0.9 → 1 guide

Every rule, with a recipe, is in the skill's unmappables reference. It is safe to re-run the codemod after fixing some of them: converted elements are components now, and a TODO it already wrote is never written twice.

Let an agent do the follow-up

The bestax-migrate Agent Skill runs the codemod and works through the TODOs from the same references:

npx skills add https://github.com/allxsmith/bestax --skill bestax-migrate

Finish the migration​

  1. Install: the codemod rewrote package.json; apply it with npm install (or pnpm/yarn). bestax-bulma needs React 18 or 19, and the report says so if you're older.
  2. Stylesheet: under the default there's nothing to do. If you ran with --css bestax from Bulma 0.9, read the Bulma 0.9 → 1 guide for the styling changes that aren't code. One deliberate difference in bestax.css: its $primary is bestax blue rather than Bulma's turquoise. Keep the stock look with --css bulma, or set your own with the --bulma-primary-* CSS variables.
  3. PurgeCSS: if your build runs it, the report says so. A converted element's classes now come from bestax's code, some of them built from props at runtime, so PurgeCSS has to scan bestax and safelist those patterns; Optimizing CSS has the config.
  4. Snapshot tests: converted elements list their classes in a different order (bestax's own first), so snapshots that compare class strings will change while the page doesn't. Review the diff and update them.
  5. Verify: typecheck, build, and look at the app.
  6. Keep it that way: the ESLint plugin's opt-in no-bulma-component-class rule reports a plain element styled with a Bulma class bestax has a component for, which keeps raw Bulma markup from creeping back in.

Coming from a library instead?​

If the app imports react-bulma-components, rbx or bloomer, use that source instead: react-bulma-components, rbx, bloomer. An app can run more than one: migrate the library first, then run bulma-classes over what's left.