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. The exceptions are
deliberate. Font Awesome's own <i aria-hidden="true"> in a .icon that carries nothing else
assistive tech reads becomes Icon, which puts that attribute on the .icon instead, and the
glyph is hidden either way. Such a .icon around a bare glyph, in a link or button its own
aria-label names, becomes Icon too, which hides it, since the label is what a screen reader
reads there. Font Awesome 6's long style names (fa-brands) become Icon's variant
(brands), which renders the short name (fab), the same style. And Bulma 0.9's
is-marginless and is-paddingless, which style nothing under v1, become m="0" and p="0". 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,heroand its parts,title,subtitle,box,content,block,notification,tag,tags,leveland its parts,mediaand its parts,cardand its parts,navbarand most of its parts,fieldand its parts,control,input,textarea,select,breadcrumb,image,menuand its label, list and items, the parts inside amodalbut its close button,paginationand its list, links and ellipses,paneland its heading, tabs and blocks,tabs,icon,icon-text,fileinside afield,delete,progress,skeleton-block,footerandtablebecome their bestax components, with their modifier classes as props (is-primary→color="primary",is-half→size="half"). A component whoseastakes the element's tag converts on it, so<main className="hero">becomes<Hero as="main">and<section className="panel">becomes<Panel as="section">. - Helper classes become helper props on those components (
mt-4→mt="4",has-text-centered→textAlign="centered",is-position-sticky→pos="sticky"), and on the plain tags bestax wraps:<p>becomesParagraph,<span>becomesSpan, and so on. A color shade becomes its color and its shade (has-text-primary-on-scheme→textColor="primary" colorShade="on-scheme"). Bulma 0.9'sis-marginlessandis-paddinglessstyle nothing under v1, so they becomem="0"andp="0", which render v1'sm-0andp-0. - Wrappers a component renders itself fold into it: a
.table-containeraround a table becomes<Table isResponsive>, and a.fixed-gridaround 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
.selectand its<select>become oneSelectBasewith the<select>'s attributes, a.breadcrumband its<ul>become oneBreadcrumbaround the same<li>s, and an.imageand its<img>become one<Image size="64x64" src="…" alt="…" />, a ratio such asis-16by9included. A ratio renders the same markup only on a bestax-bulma whoseImageputs nothing but the ratio class on its container. An older one addshas-ratiothere too, which styles nothing unless that.imagesits inside another ratio.image, so upgrade bestax-bulma along with the migration. An.imagearound anything else converts too, with its children kept as they are. - Children a component renders itself go: a
.skeleton-linesholding 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
clsxorclassnamescall: 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 fromsize, sosize="4"would render an<h4>. To write it as a prop,textSize="4"keeps the<h2>and sizes it the same, with Bulma'sis-size-4, and the TODO on the element says so (modifier:title).- A second color stays in
classNamewith a TODO:<button className="button is-black is-primary">becomes<Button color="black" className="is-primary">and getsmodifier:button, since a component takes onecolor, and Bulma's stylesheet, not the order of the classes, decides how the two look together. <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.- An element with two component classes becomes one component, and the other class stays in
classNamewith a TODO naming its component:<div className="columns box">becomes<Columns className="box">and getssecond-component:Box. The one whose component fits the element's tag best wins, a tag it renders with noasbefore one it reaches throughas, so<section className="hero container">becomes aHeroand<figure className="image container">anImage; between equals, the layout class wins. The tag it renders with noasis the one the classes give it: a ratio putsImageon a<figure>, so<div className="media-left image is-1by1">becomes aMedia.Left, which reaches a<div>throughasjust as thatImagewould. - A
.cardconverts when an element written directly inside it is, or becomes, one of its parts.Cardputs anything else inside a.card-contentof 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 throughas. - A
.navbarconverts when it carries Bulma'srole="navigation"and anaria-label, whichNavbarwrites too. Its burger and its dropdown's.navbar-linkstay markup (family:navbar-burger,family:navbar-link), and a.has-dropdownitem becomes aNavbar.Itemthat keeps the class. Switching those toNavbar.BurgerandNavbar.Dropdownis how the navbar gets bestax's toggle and keyboard handling, and it's a change you make by hand. - A
.menuconverts 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 aMenu.Item, found by where it sits.Menu.Itemrenders the<li>and the<a>together and splits its props between them, so an item whose attributes sit anywhere else stays as written. Anis-activelink converts only beside anaria-currentof its own, sinceactivewritesaria-current="page"otherwise. Addaria-current="page"to the link that marks the page you're on and re-run, and that item converts too (attr:aria-current). - A
.iconaround a bare Font Awesome or Material Design Icons<i>becomes<Icon name="home" />, the<i>'s style and other classes read asvariantandfeatures, spelled as the Icon API writes them:libraryonly for a library other than Font Awesome, and none of the attributesIconrenders by itself.Iconhides a decorative icon witharia-hidden="true"on the.iconitself, so Font Awesome's ownaria-hiddenon the<i>moves up there, unless the.iconcarries something assistive tech reads, such as atitle, aroleor atabIndex. An icon in a link or button its ownaria-labelnames is decoration there, and converts hidden the same way. Any other icon hidden nowhere stays markup (defaults:Icon), and the TODO says what to do: add the attribute to an icon whose link or button is named by its text, give an<a>with nohrefone (or make it a<button>), or name an icon-only link first, then re-run. - A form converts piece by piece into
Field,Control,InputBase,TextAreaBaseandSelectBase, not intoInput,TextAreaorSelect: those render a.fieldand a.controlof their own, and the markup already has them. The.labeland.helpstay as written, and so does an input's or a select's color class (is-danger), since bestax'scoloralso sets the text color. A<label>inside a.select, beside its<select>, keeps the.selectas markup (children:SelectBase):Selectis the component that takes it, aslabelwith its classes inlabelProps, and you convert that one by hand. Inside a.field,Selectdrops its label, and inside a bare.controlit nests the label's.fieldthere, so move the<label>out beside the.selectinstead, or give the<select>anaria-label, and re-run.
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.
Modaladds dialog attributes,Dropdownrenders 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.
FieldandControltell bestax's form controls inside them to skip their own wrappers, so a.fieldor.controlthat already holds one (from an earlier migration by hand) stays as it is (context:<Target>). - Elements bestax would render differently: a
refon 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 underapp/. Files underpages/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:
| What | What 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:Delete | Add type="button" and a real aria-label to the .delete button, then re-run |
defaults:Icon | Put aria-hidden="true" on a decorative .icon, or name an icon-only link, then re-run |
ref:<Target> | Keep the element; the component won't pass the ref through |
rsc | Add 'use client' if the file can be a client component, then re-run |
legacy:tile | Rebuild 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, a TODO it already wrote is never written twice, and one that asked for a re-run goes once it no longer applies.
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
- Install: the codemod rewrote
package.json; apply it withnpm install(or pnpm/yarn). bestax-bulma needs React 18 or 19, and the report says so if you're older. - Stylesheet: under the default there's nothing to do. If you ran with
--css bestaxfrom Bulma 0.9, read the Bulma 0.9 → 1 guide for the styling changes that aren't code. One deliberate difference inbestax.css: its$primaryis bestax blue rather than Bulma's turquoise. Keep the stock look with--css bulma, or set your own with the--bulma-primary-*CSS variables,--bulma-primary-invert-lamong them: bestax's build fixes the text on a primary surface for its dark blue, so a light primary needs it set too. - 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.
- 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.
- Verify: typecheck, build, and look at the app.
- Keep it that way: the ESLint plugin's opt-in
no-bulma-component-classrule reports a plain element styled with a Bulma class bestax has a component for, which keeps raw Bulma markup from creeping back in.
From an HTML page
A lot of Bulma lives in plain HTML: a template, a static site you're moving into React, a page someone mocked up. The same source converts those pages too:
# One page
pnpm dlx bestax-migrate bulma-classes src/pages/login/index.html --dry
# Every page in a folder
pnpm dlx bestax-migrate bulma-classes -e html src/pages/
A page you name always converts. A folder walk takes a page only when -e lists its extension
(-e html, or -e html,htm for .htm pages too), because most
.html files in a React project aren't pages to convert (Vite's index.html, public/), and
a walk without it lists the Bulma pages it skipped (html-file:not-included) so the run doesn't
look like it did nothing.
What it writes
Each page becomes a React module beside it: login/index.html writes login/index.tsx, or
.jsx when the package has no tsconfig.json and doesn't depend on TypeScript. The module's
default export is a component named after the file, or after its folder for an index.html,
with Page on the end so hero.html can't shadow bestax's Hero. The index.html in the folder
the run starts from, with other pages beside it, is the site's home page, so it becomes
HomePage, unless a page beside it would be HomePage too (home.html), when it keeps its
folder's name. One in a folder below keeps its folder's name, so docs/index.html is DocsPage.
This page:
<section class="hero is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns is-centered">
<div class="column is-4">
<div class="box">
<h1 class="title has-text-centered">Please log in</h1>
</div>
</div>
</div>
</div>
</div>
</section>
becomes:
import {
Box,
Column,
Columns,
Container,
Hero,
Title,
} from '@allxsmith/bestax-bulma';
export default function LoginPage() {
return (
<Hero size="fullheight">
<Hero.Body>
<Container>
<Columns isCentered>
<Column size="4">
<Box>
<Title textAlign="centered">Please log in</Title>
</Box>
</Column>
</Columns>
</Container>
</Hero.Body>
</Hero>
);
}
The page's <body> is read the way a browser builds it, written as JSX (class as
className, for as htmlFor, a style string as an object, comments as JSX comments), and
then converted exactly as the same markup written by hand would be, with every rule above. The
page itself is never changed, and an existing module is never overwritten
(html-file:exists), so running it on the page again changes nothing. A re-run that a TODO asks
for goes on the module, and each TODO from the page names it. A page with no Bulma component class
writes nothing, and a server template ({{, {%, <?php, <%) is refused
(html-file:template-syntax) rather than read as HTML. --dry says what it would write, and
--print prints the module, which it still writes unless --dry is set too.
What a component can't carry
A component renders into the app's own document, so the page's <head> doesn't come with it,
and neither do its scripts. Each of those becomes a TODO(bestax-migrate) line, the ones about
the whole page above the component and the rest above its return, and every report entry
names the .html file and the line it came from:
| Rule | What it covers | What to do |
|---|---|---|
html-head:<tag> | <title>, <meta> (a Content Security Policy apart, as html-head:csp), <link>, <style>, <base> | App-wide ones go in the app's index.html, a page's own where the app sets each page's head; write the app's own policy |
html-asset:<kind> | each stylesheet and script the page loads: Bulma's own, Font Awesome, a Bulma extension, jQuery, a web font, and any other | --css bestax and --css bulma import Bulma's in the module; load the rest once for the app, or use the component the TODO names |
html-script:<pattern> | inline scripts and the page's own script files: a burger, a modal, a dismiss button, a carousel, a theme toggle, JSON-LD, the rest | Port the code into the component; the TODO names the bestax component for the usual patterns |
html-extension:<name> | a Bulma extension's own markup: the elements bulma-carousel's script makes carousels of, and its is-covered images | Render a Carousel in the place of each; give is-covered images object-fit: cover in the app's stylesheet |
html-document:<name> | classes and attributes on <html> and <body>, such as has-navbar-fixed-top, data-theme and lang | Put them on the app's own <html>, where the first paint has the scheme; Theme colorMode drives a light/dark toggle |
html-event:<attribute> | onclick="…" and its kind, which React can't take as a string | Port the code into the handler the TODO names (onClick) |
html-element:<kind>, html-attr:<kind> | custom and obsolete elements, attributes JSX can't write, React has no prop for or the element ignores, stray characters, styles React can't apply | Replace them with current markup, or declare a custom element for TypeScript |
html-url:<kind> | javascript: URLs, relative URLs, which resolve against the address a component renders at, and ones whose target isn't beside the page | Move the code into a handler; copy a file into public/ and point at it from the site root, or import it; fix or drop a dead link |
html-parse:<code> | markup the browser repaired, once per kind of error, and each duplicate attribute on its own, naming the copy kept and the one dropped | Check that part of the page renders as meant |
The page's .html stays as it was, so a dropped script's code is still there to port, and the
TODO gives its lines. Every rule, with a recipe, is in the skill's
unmappables reference.
Whitespace
JSX keeps a space only where it's written, so the converter decides which whitespace in the page
renders and writes {" "} where it does. Whitespace beside a block, flex or grid box renders
nothing and is dropped, which is also what lets a wrapper fold into the component inside it. A
box's display comes from its tag, its inline style, and the display Bulma's own classes give it.
The limit is the page's own CSS: a class of yours that turns a block into an inline box (or the
reverse) is something the converter can't see, so check a spot where words now run together or
gain a space.
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.