October 9, 2026
Micra + Tailwind: when state drives the classes
Tailwind owns how an element looks: color, padding, radius — it all sits in classes right on the markup. But when it looks that way — open or closed, active, loading, on — that’s state. And usually you flip it by hand: el.classList.toggle('hidden'), el.classList.add('opacity-100'). Same problem as any manual DOM work: you remove the class in one place and forget the flag it depends on in another.
With one toggle this holds up fine. The cost shows at maintenance time: six months in, the code can no longer answer how the element looks in a given state. Its appearance at any moment is the sum of every classList call that has happened to run, scattered across handlers, and to reconstruct the picture you have to find them all. Add one more flag — “also loading” — and the number of combinations doubles, while the thing keeping them consistent is still you.
Micra closes exactly that seam. State lives in one object, and the Tailwind class string becomes a function of it. Change the state, the classes recompute — and “what does this look like when on is true” has one answer in one place: the body of a method.
The base pattern: state → class string
A toggle. State is a single boolean on; a method assembles the Tailwind class string; data-bind hangs it on class:
<button
data-component="switch"
role="switch"
@click="toggle"
data-bind="class:cls(on), aria-checked:ac(on)"
>
<span data-bind="class:knob(on)"></span>
</button>
Micra.define("switch", {
state: { on: false },
ac(v) { return v ? "true" : "false" },
cls(v) {
const base = "inline-flex h-6 w-11 items-center rounded-full transition-colors "
return base + (v ? "bg-indigo-600" : "bg-zinc-200")
},
knob(v) {
const base = "h-4 w-4 rounded-full bg-white transition-transform "
return base + (v ? "translate-x-6" : "translate-x-1")
},
toggle() { this.state.on = !this.state.on },
})
Micra.start()
data-bind takes comma-separated attribute:expression pairs; cls(on) is a call to a component method with a state field as the argument. You could write cls() and read this.state.on inside, but with the argument the method is a pure function — the markup shows what the attribute depends on, and you can check the method in your head without looking at the state.
The separate ac method isn’t decoration. data-bind treats boolean values as a presence toggle: true sets an empty attribute, false removes it. That’s exactly right for disabled or hidden, but aria-checked on a switch role has to carry the literal string "true" or "false". Bind on directly and a screen reader gets a missing attribute instead of “off”. So the method returns strings.
One more contract worth saying out loud: data-bind="class:…" assigns className wholesale, wiping whatever sat in the static class attribute. That’s why the methods return the full string — base classes plus the state-dependent ones — and the element’s entire look lives in one place. For the other job, adding a single class on top of static ones, Micra has the additive data-class="opacity-50:loading"; don’t combine it with a class: binding on the same element — they’d overwrite each other on every render (Micra warns about this in the console).
Now compare with the manual version. With classList, every state-dependent class is a paired call: toggle('bg-indigo-600', v) and toggle('bg-zinc-200', !v), toggle('translate-x-6', v) and toggle('translate-x-1', !v). Miss one and the element is stuck in a mixed look — the on-track background with the off-position knob. Stuck silently, until the next click. In the method version, a mixed look is unrepresentable: the string is rebuilt whole from a single boolean every time, so intermediate combinations simply don’t exist. One click flips on — and the track, the knob, and aria-checked all re-read it themselves.
Transitions: classes, not display
There’s one subtlety here. data-show toggles display:none, and display doesn’t animate — the CSS transition simply won’t play. The reason is the rendering model itself: an element with display:none generates no boxes, so the browser has nothing to interpolate. On show, the element appears already in its final state; on hide, it vanishes before a transition could start — the animation fails in both directions. So you animate properties that do interpolate — opacity and transform — and keep the element rendered, toggling transition classes:
<div data-component="dropdown" class="relative inline-block">
<button @click="toggle">Menu</button>
<ul data-bind="class:menuCls()">
<li>Profile</li>
<li>Sign out</li>
</ul>
</div>
Micra.define("dropdown", {
state: { open: false },
toggle() { this.state.open = !this.state.open },
menuCls() {
const base = "absolute left-0 top-full z-10 mt-1 transition duration-150 origin-top "
return base + (this.state.open
? "opacity-100 scale-100"
: "opacity-0 scale-95 pointer-events-none")
},
})
Micra.start()
Every class in the closed state earns its place. origin-top puts the scale origin at the top edge, so the menu grows down from the button instead of inflating from its center. And pointer-events-none is a required part of the pattern, not cosmetics: the transparent element is still in the DOM, sitting on top of the content, and without that class it would silently swallow clicks meant for whatever is visually underneath. The classic “the button doesn’t work because an invisible menu is hovering over it” bug comes from exactly here.
The menu never leaves the tree — only opacity and scale change, and the Tailwind transition plays in both directions. There’s a cost, and it’s worth naming: the element also stays in the accessibility tree, and the links inside remain reachable by Tab even while invisible — pointer-events doesn’t apply to the keyboard. If that matters, hide for real: data-if genuinely detaches the node from the DOM — but then there’s no exit animation, because nothing is left to play the transition after removal. For a pointer-driven dropdown the tradeoff is usually acceptable; the point is to take it deliberately.
Dark mode
Tailwind can switch theme with a dark class on the root (darkMode: 'class' in v3; a dark variant via @custom-variant in v4). A directive can’t reach it: data-bind works inside the component’s subtree, and documentElement sits above any component and is shared by all of them. This is the one place you touch the DOM directly, and it’s a deliberate side effect, like a listener on document:
Micra.define("theme", {
state: { dark: false },
toggle() {
this.state.dark = !this.state.dark
document.documentElement.classList.toggle("dark", this.state.dark)
},
})
Micra.start()
Side effects in Micra methods are fine — the only thing you must not do is trigger re-renders by hand; writing a class on the root is the same kind of thing as localStorage.setItem after a state change. A fair question: why keep state.dark at all, when the truth already lives in the class on the root? Because the component usually needs to reflect it locally — a button label via data-text="dark ? 'Light' : 'Dark'", a rotated icon — and all of that binds to state in the usual way. Were the toggle a bare classList.toggle with no state, every such reflection would be back to manual updates. In a real project, persisting the choice lands here too — localStorage in toggle, a read in onCreate — the mechanics don’t change.
From there it’s Tailwind’s job: dark:bg-zinc-900, dark:text-zinc-100 on the markup.
The catch: Tailwind can’t see what isn’t in the source
Tailwind generates CSS by scanning your source for full class names. The scanner doesn’t execute code — it walks the files as text and collects everything that looks like a class name. A name assembled at runtime exists only in the browser, after the CSS has already been built — it won’t make it in. Two rules follow:
- Don’t assemble class names by interpolation.
`bg-${color}-600`is invisible to Tailwind — you’ll get nothing in production. Keep full literals and pick between them by condition, as in the examples above:v ? "bg-indigo-600" : "bg-zinc-200". - Make sure Tailwind scans your JS files. The class strings live in component methods, not just in HTML. In v4 project sources are picked up automatically, but files in
.gitignoreor outside the project need a@sourcedirective; in v3, list your JS in thecontentconfig.
The nasty part is that the failure is silent. There’s no error; the class just matches no styles, and the only way to notice is with your eyes — often already in production, if in dev the name happened to make it into the build from some other file. Full literals also solve a mundane problem along the way: plain project-wide search works on bg-indigo-600, so the place where the menu gets that color turns up in a second — a name glued from fragments gives you nothing to search for.
This isn’t about Micra — it’s how Tailwind works — but it’s exactly what people trip on when classes start depending on state.
When you want the components ready-made
Everything above runs on the free core: a dropdown, a toggle, a theme switch, transitions — all built from state and a couple of methods. When you need the production primitives whole — a modal with a focus trap, a sortable table, a combobox — that’s Micra Pro. It isn’t on sale yet; the page has a waitlist. There the components are headless: behavior separate, look separate and driven by design tokens (--micra-*) — you can recolor and reshape the whole set by overriding a few variables without touching the components’ CSS, and dark mode turns on with a data-theme="dark" attribute. There’s a Tailwind bridge too: the same tokens are exposed as utilities (bg-micra-surface, text-micra-fg, ring-micra-ring), and the components set data-state and string aria-*, so Tailwind’s native variants (data-[state=open]:opacity-100, aria-expanded:rotate-180) work without extra setup. The core requires none of this — the patterns above work as they are.
Docs and live demos are in the docs.