Skip to content

How to build real interactive UIs with bejamas/ui and data-slot

A guide to runtime-owned state, Stimulus and Alpine integration, and coordinating multiple UI surfaces with bejamas/ui and data-slot.

← Blog

Published

Draft

How to build real interactive UIs with bejamas/ui and data-slot

A guide to runtime-owned state, Stimulus and Alpine integration, and coordinating multiple UI surfaces with bejamas/ui and data-slot.

Thom Krupa
TK
Thom KrupaCo-founder
Mojtaba Seyedi
MS
Mojtaba SeyediContent Writer

Real interactive UI usually means more than one surface reacts to the same user action. A selected tab might update a summary card, a saved preference, an analytics event, or a URL parameter. That is the point where clear state ownership matters more than the primitive itself.

In this stack, the cleanest model is to keep the boundaries explicit:

  • bejamas/ui owns markup and styling.
  • @data-slot owns primitive interaction state and emits events when that state changes.
  • Your app layer only owns derived, persisted, or cross-cutting state.

The model

bejamas/ui components render the HTML structure you actually want to ship. @data-slot attaches the behavior for patterns like tabs, select, dialog, toggle, and combobox. That behavior includes the primitive’s internal state, keyboard interactions, ARIA attributes, and DOM updates.

The important consequence is that the primitive should stay in charge of its own open, selected, and active values. If another part of the page needs to react to that value, listen to the primitive’s committed events and derive what you need from there.

That gives you one clear contract:

Layer Owns
bejamas/ui Markup, composition, styling
@data-slot Primitive interaction state, accessibility, emitted events
App layer Derived UI, persistence, URL state, server mutations, cross-surface coordination

Which framework should own the glue

The best fit depends on how much derived state you need and how much of the page is already server-rendered.

Stimulus

Stimulus is the best default when a page is mostly server-rendered and one primitive event needs to update multiple nearby surfaces. A controller can own a stable subtree, listen to custom events like select:change or tabs:change, keep a small derived value, and update sibling DOM without taking over the primitive’s state machine.

This repo already uses Stimulus in exactly that role for larger HTML-first flows.

Alpine

Alpine fits well when the page only needs local reactive glue. If a primitive event should change a badge label, a preview note, or a couple of classes, Alpine’s x-data layer is often enough. The key is the same: Alpine mirrors derived state, not the primitive’s internal active state.

Plain scripts

For isolated enhancements, plain scripts are still a good option. If one tabs:change listener updates one summary panel, you may not need a framework at all.

State ownership rules

  1. Let the primitive own open, selected, and active state that belongs to the component itself.
  2. Move state into Stimulus or Alpine only when another surface needs a derived view of that state.
  3. Use runtime events to move state outward: tabs:change, select:change, toggle:change, and similar events are the handoff point.
  4. Use programmatic control events to move state inward: tabs:set, select:set, and toggle:set tell the primitive to restore or sync to an external value.
  5. Do not create split-brain state. If both your framework and @data-slot try to own the same selected value, one of them will eventually fall out of sync.

The practical rule is simple: the app can remember or summarize state, but the primitive should still be the one applying it to its own DOM contract.

Demo 1: Runtime events without a framework

This example stays at the smallest useful layer. Tabs own the selected value. A plain script listens to tabs:change, updates a separate summary card, and supports restore buttons through tabs:set.

Derived summary

Draft Runtime-owned selected state

Keep the active value inside the tabs primitive until another surface actually needs it.

Waiting for events...
RuntimeTabsDemo.astro
---
import { Button } from "@bejamas/ui/components/button";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@bejamas/ui/components/tabs";
---
<div class="flex gap-2">
<Button type="button" data-runtime-tabs-set="draft">Restore draft</Button>
<Button type="button" data-runtime-tabs-set="publish">Jump to publish</Button>
</div>
<Tabs id="workflow-tabs" defaultValue="draft">
<TabsList>
<TabsTrigger value="draft">Draft</TabsTrigger>
<TabsTrigger value="review">Review</TabsTrigger>
<TabsTrigger value="publish">Publish</TabsTrigger>
</TabsList>
<TabsContent value="draft">Draft stage</TabsContent>
<TabsContent value="review">Review stage</TabsContent>
<TabsContent value="publish">Publish stage</TabsContent>
</Tabs>
<div data-runtime-summary>
<p data-runtime-tabs-label>Runtime-owned selected state</p>
<p data-runtime-tabs-note></p>
</div>

The important part is not the script itself. It is the ownership boundary: tabs still own the active item, and the rest of the page reacts after the runtime emits a change.

Demo 2: Stimulus for derived, multi-surface state

Stimulus becomes useful when a committed primitive event should update several pieces of UI in one server-rendered subtree. Here the controller listens to select:change, stores a small derived value in a Stimulus value, updates a checklist, and restores the primitive with select:set when the app asks for it.

Choose a state boundary

Stimulus listens to one primitive event and derives the rest of the UI in the same server-rendered subtree.

Local runtime state

local

The primitive owns open, selected, and active state. The controller only derives adjacent UI after the runtime commits a change.

  • Keep the selected value inside the primitive when no other surface needs it.
  • Mirror only the summary or CTA text that depends on that selection.

Next step

If the rest of the page does not care, stop at the primitive.

Waiting for events...
StimulusStateBridgeDemo.astro
---
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@bejamas/ui/components/select";
---
<section
data-controller="blog-state-bridge"
data-blog-state-bridge-pattern-value="local"
>
<Select
data-blog-state-bridge-target="select"
defaultValue="local"
data-action="select:change->blog-state-bridge#patternChanged"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="shared">Shared</SelectItem>
<SelectItem value="persisted">Persisted</SelectItem>
</SelectContent>
</Select>
<button
type="button"
data-action="click->blog-state-bridge#restore"
data-blog-state-bridge-pattern-param="persisted"
>
Restore persisted
</button>
<p data-blog-state-bridge-target="title"></p>
<p data-blog-state-bridge-target="summary"></p>
<script>
import "@/stimulus/blog";
</script>
</section>

This is the Stimulus sweet spot: one controller owns a subtree, listens to stable custom events, and updates several derived surfaces after the primitive has already settled on the committed value.

Demo 3: Alpine for local reactive glue

Alpine is a good fit when the page only needs a small reactive layer close to the markup. In this example, ToggleGroup still owns which item is on. Alpine only mirrors the selected density into local state so a badge, note, and preview list can react.

Tune the preview density

Alpine mirrors toggle-group:change into local state and leaves the primitive in charge of selection.

Local Alpine state

Balanced

Mirror the primitive event into a small reactive object and let Alpine update nearby text or classes.

Preview list

Document the runtime contract in the markup.
Mirror only the summary state that belongs to this view.
Let the primitive keep its own active item.
Waiting for events...
AlpineToggleGroupDemo.astro
---
import { ToggleGroup, ToggleGroupItem } from "@bejamas/ui/components/toggle-group";
---
<section
x-data='{
density: "balanced",
labels: { calm: "Calm", balanced: "Balanced", dense: "Dense" },
notes: {
calm: "Use Alpine for a little local glue around stable markup.",
balanced: "Mirror the primitive event into nearby reactive UI.",
dense: "Do not re-implement the primitive selection logic.",
},
}'
>
<ToggleGroup
variant="outline"
defaultValue="balanced"
x-on:toggle-group:change="density = ($event.detail.value[0] || 'balanced')"
>
<ToggleGroupItem value="calm">Calm</ToggleGroupItem>
<ToggleGroupItem value="balanced">Balanced</ToggleGroupItem>
<ToggleGroupItem value="dense">Dense</ToggleGroupItem>
</ToggleGroup>
<p x-text="labels[density]"></p>
<p x-text="notes[density]"></p>
</section>

Alpine works well here because the extra state is strictly local. It changes copy and classes inside one small area, while the primitive still owns the selected toggle item.

A practical rule of thumb

When you build with bejamas/ui and @data-slot, start by asking one question:

Does this state belong to the primitive, or does the rest of the app need to remember and react to it?

If it belongs to the primitive, keep it there. If the rest of the app needs it, listen to the primitive’s committed event, derive what you need, and restore through tabs:set, select:set, toggle:set, or the matching runtime event when external state needs to flow back in.

That approach keeps the DOM contract clear, keeps framework state smaller, and makes interactive UI easier to reason about as the page grows beyond a single component.

Last updated: