import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createHash } from "node:crypto"; import { KanbanBoard } from "../examples/hono-datastar/adapter/kanban"; import { DragGroup } from "../examples/hono-datastar/adapter/drag-group"; import { BentoWorkspace } from "../examples/hono-datastar/adapter/bento"; import type { DatastarEventBinding } from "../examples/hono-datastar/adapter/event-binding"; import { renderHTML } from "../examples/hono-datastar/adapter/render"; import { SortableList } from "../examples/hono-datastar/adapter/sortable-list"; import { SortableTree, type FileNode } from "../examples/hono-datastar/adapter/sortable-tree"; import { ContextMenu } from "../examples/hono-datastar/adapter/context-menu"; import { InlineEdit } from "../examples/hono-datastar/adapter/inline-edit"; import { kanbanContract } from "../contracts/kanban"; import { sortableListContract } from "../contracts/sortable-list"; import { dragGroupContract } from "../contracts/drag-group"; import { bentoContract } from "../contracts/bento"; import { sortableTreeContract } from "../contracts/sortable-tree"; import { contextMenuContract } from "../contracts/context-menu"; import fixture from "../examples/hono-datastar/fixture.json"; import { buildSourceIndex } from "./build-source"; import { ensureRuntime } from "../scripts/fetch-datastar-rocket"; import { browserBundles, rocketModule } from "../browser-bundles"; const root = join(import.meta.dir, ".."); const output = join(root, "dist/site"); await ensureRuntime(); const bundle = await readFile(join(root, "dist/rocket-kit.js"), "utf8"); const bundleSizes = new Map( await Promise.all( browserBundles.map(async ({ file }) => [file, (await stat(join(root, "dist", `${file}.br`))).size] as const), ), ); const fakeBackend = await Bun.build({ entrypoints: [join(import.meta.dir, "fake-backend.ts")], target: "browser" }); if (!fakeBackend.success || fakeBackend.outputs.length !== 1 || !fakeBackend.outputs[0]) { throw new AggregateError(fakeBackend.logs, "rocket-kit: site fake backend build failed"); } const backendBundle = await fakeBackend.outputs[0].text(); const keyboardHelpBundle = await Bun.build({ entrypoints: [join(import.meta.dir, "keyboard-help.ts")], target: "browser", minify: true, }); if (!keyboardHelpBundle.success || !keyboardHelpBundle.outputs[0]) { throw new AggregateError(keyboardHelpBundle.logs, "rocket-kit: keyboard help build failed"); } const keyboardHelp = await keyboardHelpBundle.outputs[0].text(); const customAtmosphereBundle = await Bun.build({ entrypoints: [join(import.meta.dir, "custom-atmosphere.ts")], target: "browser", minify: true, }); if (!customAtmosphereBundle.success || !customAtmosphereBundle.outputs[0]) { throw new AggregateError(customAtmosphereBundle.logs, "rocket-kit: custom atmosphere build failed"); } const customAtmosphere = await customAtmosphereBundle.outputs[0].text(); const trashSparksBundle = await Bun.build({ entrypoints: [join(import.meta.dir, "trash-sparks.ts")], target: "browser", minify: true, }); if (!trashSparksBundle.success || !trashSparksBundle.outputs[0]) { throw new AggregateError(trashSparksBundle.logs, "rocket-kit: trash sparks build failed"); } const trashSparks = await trashSparksBundle.outputs[0].text(); const inlineEditBundle = await Bun.build({ entrypoints: [join(import.meta.dir, "inline-edit-demo.ts")], target: "browser", }); if (!inlineEditBundle.success || !inlineEditBundle.outputs[0]) { throw new AggregateError(inlineEditBundle.logs, "rocket-kit: inline edit demo build failed"); } const inlineEditDemo = await inlineEditBundle.outputs[0].text(); const assetVersion = createHash("sha256") .update(bundle) .update(backendBundle) .update(keyboardHelp) .update(customAtmosphere) .update(trashSparks) .update(inlineEditDemo) .update(await readFile(join(import.meta.dir, "site.css"))) .update(await readFile(join(root, "examples/hono-datastar/demo.css"))) .digest("hex") .slice(0, 12); const kanbanMove: DatastarEventBinding = { event: kanbanContract.events.move, attrs: { "data-on:rocket-kanban-move": "$cardId = evt.detail?.['cardId'] ?? null; $col = evt.detail?.['col'] ?? null; $before = evt.detail?.['before'] ?? null; @post('./move')", }, }; const customKanbanMove: DatastarEventBinding = { event: kanbanContract.events.move, attrs: { "data-on:rocket-kanban-move": "$cardId = evt.detail?.['cardId'] ?? null; $col = evt.detail?.['col'] ?? null; $before = evt.detail?.['before'] ?? null; @post('./custom-move')", }, }; const transmissions = [ { id: "signal-01", label: "Chart the quiet sector", code: "MAP / 01", lane: 0, symbol: "✦" }, { id: "signal-02", label: "Tune the night antenna", code: "AUDIO / 02", lane: 0, symbol: "◌" }, { id: "signal-03", label: "Collect the blue hour", code: "FIELD / 03", lane: 1, symbol: "◈" }, { id: "signal-04", label: "Send a postcard to orbit", code: "POST / 04", lane: 1, symbol: "↗" }, { id: "signal-05", label: "Leave a light on", code: "BEACON / 05", lane: 2, symbol: "✳" }, ] as const; function CustomKanban() { const lanes = [ { title: "Uncharted", code: "01 / DISCOVER", glyph: "◎" }, { title: "In orbit", code: "02 / IN MOTION", glyph: "◐" }, { title: "Transmitted", code: "03 / COMPLETE", glyph: "✳" }, ]; return ( {lanes.map((lane, index) => (
{lane.code}

{lane.title}

{String(transmissions.filter((card) => card.lane === index).length).padStart(2, "0")}
{transmissions .filter((card) => card.lane === index) .map((card) => (
{card.code}
))}

END OF CHANNEL

))}
); } const sortableMove: DatastarEventBinding = { event: sortableListContract.events.move, attrs: { "data-on:rocket-sortable-move": "$itemId = evt.detail?.['itemId'] ?? null; $before = evt.detail?.['before'] ?? null; @post('./list-move')", }, }; const groupMove: DatastarEventBinding = { event: dragGroupContract.events.move, attrs: { "data-on:rocket-drag-group-move": "$itemId = evt.detail?.['itemId'] ?? null; $fromList = evt.detail?.['fromList'] ?? null; $toList = evt.detail?.['toList'] ?? null; $before = evt.detail?.['before'] ?? null; @post('./group-move')", }, }; const bentoMove: DatastarEventBinding = { event: bentoContract.events.move, attrs: { "data-on:rocket-bento-move": "$bento = evt.detail; @post('./bento-move')" }, }; const bentoResize: DatastarEventBinding = { event: bentoContract.events.resize, attrs: { "data-on:rocket-bento-resize": "$bento = evt.detail; @post('./bento-resize')" }, }; const treeMove: DatastarEventBinding = { event: sortableTreeContract.events.move, attrs: { "data-on:rocket-tree-move": "$tree = evt.detail; @post('./tree-move')" }, }; const nestedGroupMove: DatastarEventBinding = { event: dragGroupContract.events.move, attrs: { "data-on:rocket-drag-group-move": "$itemId = evt.detail?.['itemId'] ?? null; $fromList = evt.detail?.['fromList'] ?? null; $toList = evt.detail?.['toList'] ?? null; $before = evt.detail?.['before'] ?? null; @post('./nested-group-move')", }, }; const nestedListMove: DatastarEventBinding = { event: sortableListContract.events.move, attrs: { "data-on:rocket-sortable-move": "$itemId = evt.detail?.['itemId'] ?? null; $before = evt.detail?.['before'] ?? null; @post('./nested-list-move')", }, }; const trashMove: DatastarEventBinding = { event: dragGroupContract.events.move, attrs: { "data-on:rocket-drag-group-move": "$itemId = evt.detail?.['itemId'] ?? null; $fromList = evt.detail?.['fromList'] ?? null; $toList = evt.detail?.['toList'] ?? null; $before = evt.detail?.['before'] ?? null; @post('./trash-move')", }, }; const menuAction: DatastarEventBinding = { event: contextMenuContract.events.action, attrs: { "data-on:rocket-menu-action": "$menu = evt.detail; @post('./menu-action')" }, }; const tropes = [ { id: "trope-skeleton", label: "A skeleton for one word", stamp: "ALMOST READY", glyph: "▤" }, { id: "trope-loading", label: "A loader that never resolves", stamp: "STILL LOADING", glyph: "◌" }, { id: "trope-sync", label: "Duplicated logic that drifts", stamp: "OUT OF SYNC", glyph: "≋" }, { id: "trope-bundle", label: "Megabytes of JavaScript", stamp: "BUNDLE: HUGE", glyph: "▥" }, { id: "trope-browser", label: "Rebuilding the browser in JS", stamp: "DIY PLATFORM", glyph: "▣" }, ] as const; type Shortcut = { keys: string; action: string }; function KeyboardHelp({ id, title, shortcuts }: { id: string; title: string; shortcuts: readonly Shortcut[] }) { return ( <>

{title} shortcuts

Focus an item first.

{shortcuts.map(({ keys, action }) => (
{keys}
{action}
))}
); } const page = renderHTML( PD rockets · guide and reference # choose one of the following: `}

Get the Rocket runtime ↗ ·{" "} Upstream MIT notice ↗

The import map resolves pd-rockets/rocket to the pinned upstream module. If your page supplies a separate Rocket ES module, map that specifier to its URL instead; it must export{" "} rocket and use the same Datastar instance as the page. The guide uses the latest pinned upstream Datastar + Rocket bundle (v1.0.4) with its MIT notice.

Sizes are Brotli-compressed kilobytes (1 kB = 1,000 bytes). Use the regular .js file in script tags; the optional .br file is for servers configured to serve precompressed JavaScript with Content-Encoding: br.

PD rockets license ↓

STEP 02 / LIVE EXAMPLE

Kanban board

Each lane carries a numeric{" "} data-col ; cards carry stable IDs. Drag a card into another lane, or focus a card and press Alt+ →. The emitted event describes the target lane and the card to insert before; it does not perform a mutation. Plain arrows navigate focus; use Alt+h/j/ k/l to stage keyboard moves, then release Alt to commit.

LIVE / KANBAN{" "}
drag or use Alt + arrows
                  {`
  
rocket-kanban-move → { cardId, col, before }`}

Kanban Rocket source ↗ ·{" "} JSX template ↗

STEP 03 / LIVE EXAMPLE

Sortable list

A sortable list uses the shared pointer lifecycle but chooses its own target geometry and semantic event. Drag above or below an item to insert at that position. Up/down arrows or j/k navigate focused items; Home/End jump to the first or last item. Alt + up/down stages a reorder; release Alt to commit or press Escape to cancel.

LIVE / SORTABLE
drag onto an item
                  {`
  
First item
Second item
rocket-sortable-move → { itemId, before }`}

Sortable Rocket source ↗ ·{" "} JSX template ↗

STEP 04 / LIVE EXAMPLE

Move between lists

A drag group coordinates several lists without assigning Kanban columns or card semantics. Move an item within a list or into another list, including the space after its last item. Each group is its own drag scope; the page decides how to apply the emitted move. Plain arrows move focus within and between lists. Focus an item and use Alt + arrows (or h/j/k/l), then release Alt to commit. Escape cancels the staged move.

LIVE / DRAG GROUP
drag between lists
                  {`
  
Sketch a card
rocket-drag-group-move → { itemId, fromList, toList, before }`}

Drag group Rocket source ↗ ·{" "} JSX template ↗

COMPOSITION / LIVE EXAMPLE

Nested Rockets

A sortable list sits inside an item of a drag group. Drag or use Alt + arrows on an inner item to reorder only that list; drag the outer item to move the whole group item. Each host emits its own event, and the page patches the matching example over SSE. Semantic events still bubble; when nesting two hosts of the same surface, the page should check the event target before invoking an outer action.

LIVE / NESTED HOSTS

First region

Move this whole item
Another outer item

Second region

Destination item

Host ownership source ↗ ·{" "} Pointer lifecycle ↗

STEP 05 / LIVE EXAMPLE

Bento grids

Two CSS grids share one drag scope. Drop a tile on a cell in either grid, or use its ↘ handle to resize it. Displaced tiles preview their new cells while you drag or resize. Rocket sends every changed position on commit; the backend applies them and returns HTML. Plain arrows navigate tiles within and across grids with arrows or h/j/k/l. Focus a tile: Alt + arrows move it by a cell and cross a board boundary at an edge; Alt + Page Up/Down switches grids directly, and Shift + arrows resize. Release the modifier to commit; Escape cancels.

The browser proposes positions for its live preview. The synthetic backend checks the complete resulting grid for bounds and overlap before accepting them; a consuming backend validates its own layout rules.

LIVE / BENTO{" "}
drag between grids or resize ↘
                  {`
  
Traffic
rocket-bento-move → { itemId, fromGrid, toGrid, updates: [{ itemId, grid, col, row, width, height }] } rocket-bento-resize → { itemId, grid, updates: [{ itemId, grid, col, row, width, height }] }`}

Bento Rocket source ↗ ·{" "} Placement rule ↗ ·{" "} JSX template ↗

STEP 06 / LIVE EXAMPLE

File tree

Reorder files and folders, or drop onto a folder to move an entry inside it—even when it is empty. Nested entries move with their folder. Plain up/down arrows (or j/k) navigate visible rows; right expands or enters a folder, and left collapses it or returns to its parent. Focus a row: Alt + up/down reorders among siblings, Alt + right moves it into the preceding folder, and Alt + left moves it out. At the first or last child, Alt + up/down also moves it before or after the parent folder. Release Alt to commit; Escape cancels.

LIVE / FILE TREE{" "}
drag between directories
                  {`
  
src
…files…
rocket-tree-move → { itemId, fromParent, toParent, before }`}

Tree Rocket source ↗ ·{" "} JSX template ↗

STEP 07 / SERVER HANDOFF

Server round trip

Bind each semantic event to a Datastar action in your page. Your server handler validates the target, updates authoritative state, and sends complete HTML over SSE. On this page, a site-only fetch shim stands in for that handler and returns a{" "} datastar-patch-elements {" "} event. Datastar performs the morph; Rocket animates items from their prior positions to the new ones. Try a demo gesture: the small activity queue shows the Rocket event, the Datastar POST and the SSE patch returned by the fixture, without recording item text or request content.

                  {`event: datastar-patch-elements
data: selector #kanban-demo
data: mode outer
data: elements 
…complete example…
`}

Browser fixture source ↗ ·{" "} Go SSE handler ↗

The page seeds only interaction-detail signals. Board, list and grid content live in rendered DOM, not signals.

STEP 08 / YOUR DESIGN SYSTEM

Make it yours

Pick the surface bundle you need and render its light-DOM contract with your own components, classes, and content. PD rockets supplies interaction behavior, not a required stylesheet. Keep the host tag, stable item IDs, focusable items, and the data-* hooks; style everything around them to fit your product.

If your HTML morph keys elements by DOM id, give each card a stable, board-scoped ID too. That keeps an in-flight animation attached to the same card when another card leaves its lane.

Your server-rendered markup is the design surface. Use your own component classes and CSS custom properties for colors, spacing, and shape; Rocket’s data-* attributes expose the interaction states. There is no mandatory theme or token set.

◈   PD / SIGNAL STATION   ·   LIVE EXPERIMENT 008

Make some waves.

Same Kanban Rocket. A different universe. Drag a transmission or move it with the keyboard.

Move a card between channels: the demo’s page-owned handler updates its model and morphs confirmed markup over SSE. The moving hologram and destination label are rendered from the two template outlets below. The animated backdrop is a decorative canvas; cards, focus, and drag targets remain HTML. See the canvas source ↗ and{" "} theme CSS ↗.

MINI EXPERIMENT / 002

Delete the clichés.

Some SPA tropes deserve the bin. Drag one across, or focus it and use Alt + →.

THE BACKLOG / 05 LEFT {tropes.map((trope) => (
{trope.label} {trope.stamp}
))}
Elsa said it best... Drop the baggage. Release to remove it from the model.

This is a rocket-drag-group with a playful destination. The page handler interprets a move to bin as deletion, then returns the remaining HTML over SSE. The poof is decoration; the model change is confirmed by the morph. A short canvas particle burst celebrates the bin without adding anything to the Rocket core.{" "} Particle source ↗

Set shortcuts on the host

This sortable list keeps arrow keys and replaces Vim j/k with n/ p. It stages reorders with Alt + those same keys. Render the attributes with the host; bindings are resolved when the component connects. The event is an intent: your page applies it and patches the confirmed HTML from its backend.

                  {`

Queue

First task
Next task
`}

The rocket-sortable-move detail is {`{ itemId, before }`}; an empty{" "} before appends. The example route and signals belong to the page, not the bundle. Set a shortcut attribute to an empty string to disable that intent; see the{" "} keyboard reference for the other surfaces and Kanban’s legacy aliases.

Style the states, not the internals

Scope styles under your component class. The host and items are ordinary light-DOM elements; preview and target attributes are styling hooks. By default the floating preview is a clone attached to the document body, so an item class lets it keep your theme outside the host. Give pointer items{" "} touch-action: none and a visible keyboard focus state:

                  {`.project-queue, .queue-item[data-drag-preview] {
  --queue-accent: var(--color-accent, #256c62);
  --queue-surface: var(--color-surface, #fff);
}
.project-queue rocket-sortable-list {
  display: grid;
  gap: .5rem;
  position: relative;
}
:is(.project-queue [data-sortable-item], .queue-item[data-drag-preview]) {
  position: relative;
  padding: .75rem 1rem;
  border: 1px solid var(--queue-accent);
  border-radius: var(--radius-card, .5rem);
  background: var(--queue-surface);
  cursor: grab;
  touch-action: none;
}
.project-queue [data-sortable-item]:focus-visible {
  outline: 2px solid var(--queue-accent);
  outline-offset: 2px;
}
.project-queue [data-dragging] { opacity: .45; }
.queue-item[data-drag-preview] { box-shadow: 0 12px 24px #0003; }
.project-queue [data-drop-before]::before,
.project-queue rocket-sortable-list[data-drop-end]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 3px;
  background: var(--queue-accent);
  pointer-events: none;
}
.project-queue [data-drop-before]::before { top: -5px; }
.project-queue rocket-sortable-list[data-drop-end]::after { bottom: -5px; }`}
                

Replace the preview or target markup

For richer affordances, render inert <template> fragments with your items and host. A direct-child data-rocket-preview template on an item replaces that item’s floating clone; its class is copied onto the preview wrapper, which moves under document.body. Its size is yours to style; --rocket-source-width and --rocket-source-height expose the original dimensions if useful. Direct-child data-rocket-target templates on the host supply target decorations. Rocket inserts their content into a noninteractive{" "} [data-rocket-target-indicator] wrapper at the active target for both pointer and keyboard staging. Give target items and containers position: relative so you can position the indicator inside them:

                  {`




`}
                
                  {`.queue-preview[data-drag-preview] {
  display: grid;
  place-items: center;
  width: max-content;
  min-height: var(--rocket-source-height);
  border: 2px solid var(--color-accent, #256c62);
  border-radius: var(--radius-card, .5rem);
  background: var(--color-surface, #fff);
}
.project-queue [data-rocket-target-indicator] {
  left: 0;
  right: 0;
  color: var(--queue-accent);
}
.project-queue [data-rocket-target-indicator="before"] { top: -1.5rem; }
.project-queue [data-rocket-target-indicator="end"] { bottom: -1.5rem; }
.project-queue .queue-target { display: block; }`}
                

Without a preview template Rocket clones the source item. Without a target template the existing{" "} data-drop-* states remain available for CSS-only markers like those above. When using a template indicator, replace those pseudo-element marker rules with your indicator styles. Other target kinds are into for tree folders and cell for bento grids; a bare{" "} data-rocket-target template can serve every kind on a host. Geometry and the semantic move event still belong to the surface.

Match the affordance to the layout

Surface Your markup & layout Rocket styling hooks
Kanban [data-kanban-lane] and [data-kanban-lane-cards] set lane geometry. [data-drop-active], [data-drop-before], [data-drop-end]
Sortable list Style the host and [data-sortable-item] rows. [data-drop-before] on an item; [data-drop-end] on the host
Drag group [data-drop-list] regions contain [data-drag-item]. [data-drop-active], [data-drop-before], [data-drop-end]
Bento [data-bento-grid] provides tracks and rows; tile positions come from your model. [data-bento-target], [data-bento-projecting],{" "} [data-bento-resizing]
File tree [data-tree-children] nests rows; honor [hidden] on collapsed folders. [data-tree-before], [data-tree-into], [data-tree-end]

All surfaces expose [data-dragging] on the source, [data-drag-preview] on the detached clone, and [data-key-staging] on the host during keyboard moves. Bento also needs data-columns to match its CSS grid tracks, a fixed grid-auto-rows, and tile grid-column/grid-row styles that match their rendered position data. See the example CSS ↗ for complete layout and state rules.

STEP 09 / CONTEXTUAL ACTIONS

A menu at the point of intent

Right-click a row or use its Actions button. The server renders one inert template; Rocket clones it on demand, binds the row’s contextId, handles focus and nested menus, and emits a semantic action. The page decides what that action means and returns a small SSE patch. No menu fetch is needed for these shared, non-sensitive actions.

                  {`
rocket-menu-action → { action, contextId }`}

Menus may nest as deeply as your markup needs. Opening focuses the menu; Down starts at the first item (Up starts at the last). Arrow keys or h/j/k/l move through a level, Right opens a submenu, and Left, Backspace or Escape returns one level. Escape at the root closes the menu; Enter or Space on the menu activates its first item. Home/End and per-host data-key-* overrides also work. On macOS, Ctrl+n / Ctrl+p also move next / previous, following familiar Control-key text navigation; Command-key browser shortcuts remain untouched. On other platforms the Control pair can be opted into with data-key-focus-next="ArrowDown j Ctrl+n" and{" "} data-key-focus-previous="ArrowUp k Ctrl+p". Native popovers provide the top layer and light dismiss; CSS anchors position the menu and flip nested panels at viewport edges, with measured coordinates as a fallback. Your CSS owns the presentation. Context placeholders bind in text,{" "} data-menu-param-*, aria-label, and title attributes; executable directives are left as server-rendered.

For fresh options, fetch page-owned HTML before opening and morph the template, or render a direct child marked data-rocket-menu-content for live server markup. Then call{" "} menu.openFor(trigger). Live markup stays in the host after close; use{" "} menu.closeMenu(refocus?) and menu.isOpen() when coordinating a page-owned menu lifecycle. rocket-menu-scope emits {`{ root, active }`} when its keyboard scope opens or closes. Additional non-sensitive values can be passed as{" "} {`menu.openFor(trigger, undefined, { label: "Example" })`} and used as{" "} {`{label}`} in the template. The Rocket never fetches menus or makes authorization decisions. See menu behavior ↗ and{" "} JSX adapter ↗.

INTERACTION / INLINE EDIT

Edit a title in place

Double-click the title, change it, then press Enter or leave the field. Escape cancels. The Rocket recognizes the two title presses even when a parent captures the pointer; it emits request, commit and cancel events. The page owns edit mode, input state and saving.

FIELD NOTE / EXAMPLE A small observation

Double-click the title to try the page-owned local demo.

                  {`
  Title from server
  


rocket-inline-edit-request → { contextId }
rocket-inline-edit-commit → { contextId, value }
rocket-inline-edit-cancel → { contextId }`}
                

The element leaves classes and layout to your CSS; it never submits a request or stores a second copy of the title. See editor behavior ↗ and{" "} JSX adapter ↗.

QUICK REFERENCE / 02

Reference

These are browser-facing contracts. Action bindings, permissions and transport configuration belong to the consuming application.

All surfaces support unmodified arrow-key focus navigation and macOS Ctrl+n /{" "} Ctrl+p for next / previous focus; other platforms can opt in per host. List, group, grid and tree surfaces also support Home/End. Alt + arrows stage moves, where supported, without changing focus until the morph.

Keyboard attributes

Every host accepts space-separated data-key-<intent> bindings; an empty attribute disables that intent. Focus intents are focus-next, focus-previous, focus-left, focus-right, focus-first and{" "} focus-last; movement uses move-up/down/left/right and cancel. Each surface uses only its applicable directions. Bento also accepts{" "} resize-up/down/left/right and grid-previous/next. The shared defaults are in{" "} core/keyboard.ts.

Kanban also accepts the original data-key-select-* aliases below; a corresponding{" "} data-key-focus-* takes precedence.

Attribute Default Purpose
data-key-select-next ↓ / j Focus next card
data-key-select-previous ↑ / k Focus previous card
data-key-select-left ← / h Focus card in previous lane
data-key-select-right → / l Focus card in next lane
data-key-move-up Alt + ↑ / k Move above previous card
data-key-move-down Alt + ↓ / j Move below next card
data-key-move-left Alt + ← / h Move to previous lane
data-key-move-right Alt + → / l Move to next lane
data-key-cancel Esc Clear target marks

Override a slot with space-separated key tokens, e.g.{" "} data-key-select-next="ArrowDown j" . Kanban compatibility defaults come from{" "} contracts/kanban.ts .

DOM and events

Host Descendants Emitted event
rocket-kanban-board [data-kanban-lane][data-col], [data-kanban-card] rocket-kanban-move {" "} {`{ cardId, col, before }`}
rocket-kanban-select {" "} {`{ cardId }`}
rocket-sortable-list [data-sortable-item] rocket-sortable-move {" "} {`{ itemId, before }`}
rocket-drag-group [data-drop-list] + [data-drag-item] rocket-drag-group-move {" "} {`{ itemId, fromList, toList, before }`}
rocket-bento-workspace [data-bento-grid] + [data-bento-item] rocket-bento-move / rocket-bento-resize
rocket-sortable-tree [data-tree-node] + [data-tree-children] rocket-tree-move {" "} {`{ itemId, fromParent, toParent, before }`}

Events bubble and cross the custom-element boundary.{" "} before: "" {" "} means append. Keep IDs stable across renders so morph and FLIP can match items.

SERVER EXAMPLES / 04

Contractual obligations

The Hono JSX adapter packages the required markup and event binding into a component; this guide uses it to send moves to its in-browser fixture. The Go example writes the same DOM with html/template {" "} and handles moves on the server. Your page chooses the Datastar action that receives each event.

Hono JSX

                  {`const move = {
  event: "rocket-kanban-move",
  attrs: {
    "data-on:rocket-kanban-move":
      "$cardId = evt.detail?.['cardId'] ?? null; " +
      "$col = evt.detail?.['col'] ?? null; " +
      "$before = evt.detail?.['before'] ?? null; @post('/move')",
  },
};

`}
                

View the JSX adapter source ↗

Go template

                  {`
  {{range .Columns}}
    
{{range .Cards}}
{{.Title}}
{{end}}
{{end}}
`}

View the Go server source ↗

TAKE IT FURTHER / 05

Run locally

The static docs use a browser-only fixture. The Hono JSX and Go examples show two server renderers for the same contract.

                  {`# Static guide + in-browser SSE fixture
bun run serve:site

# Hono JSX demo
bun run demo

# Go demo (after bun run build:client)
cd examples/go && go run .`}
                

Site server source ↗ ·{" "} Hono server source ↗ ·{" "} Go server source ↗

, ); await rm(output, { recursive: true, force: true }); await mkdir(output, { recursive: true }); // Reuse the server-rendered sections for focused pages. The original long guide // remains available at /, including its existing hash links. const docs = [ { slug: "index", title: "Overview", group: "Start", sections: ["guide"] }, { slug: "getting-started", title: "Getting started", group: "Start", sections: ["install", "server", "try-it"] }, { slug: "kanban", title: "Kanban board", group: "Drag and drop", sections: ["kanban"] }, { slug: "sortable-list", title: "Sortable list", group: "Drag and drop", sections: ["sortable"] }, { slug: "drag-group", title: "Drag group", group: "Drag and drop", sections: ["drag-group", "nested"] }, { slug: "bento", title: "Bento grids", group: "Drag and drop", sections: ["bento"] }, { slug: "tree", title: "File tree", group: "Drag and drop", sections: ["tree"] }, { slug: "context-menu", title: "Context menu", group: "Menus", sections: ["context-menu"] }, { slug: "inline-edit", title: "Inline edit", group: "Editing", sections: ["inline-edit"] }, { slug: "customize", title: "Make it yours", group: "Guides", sections: ["customize"] }, { slug: "reference", title: "Reference", group: "Guides", sections: ["reference"] }, { slug: "examples", title: "Examples", group: "Guides", sections: ["examples"] }, ] as const; const sectionPaths = new Map(); for (const doc of docs) for (const section of doc.sections) sectionPaths.set(section, doc.slug); sectionPaths.set("keyboard", "reference"); sectionPaths.set("events", "reference"); const sidebar = (active: string) => { let group = ""; return ( docs .map((doc) => { const heading = doc.group !== group ? `${(group = doc.group).toUpperCase()}` : ""; return `${heading}${doc.title}`; }) .join("") + 'Full guide ↗' ); }; const catalog = `

EXPLORE THE COLLECTION

Components & guides

${docs .filter((doc) => ["Drag and drop", "Menus", "Editing"].includes(doc.group)) .map( (doc) => `${doc.group}${doc.title}Live example, markup and contract `, ) .join("")}
`; const fullGuide = await new HTMLRewriter() .on(".docs-sidebar nav", { element(element) { element.setInnerContent(sidebar(""), { html: true }); }, }) .transform(new Response(`${page}`)) .text(); await writeFile(join(output, "guide.html"), fullGuide); await mkdir(join(output, "documentation"), { recursive: true }); for (const doc of docs) { const selected = new Set(doc.sections); const index = docs.indexOf(doc); const previous = docs[index - 1]; const next = docs[index + 1]; const html = await new HTMLRewriter() .on("head", { element(element) { element.prepend('', { html: true }); }, }) .on("title", { element(element) { element.setInnerContent(`${doc.title} · PD rockets`); }, }) .on(".docs-sidebar nav", { element(element) { element.setInnerContent(sidebar(doc.slug), { html: true }); }, }) .on(".hero", { element(element) { if (doc.slug !== "index") element.remove(); }, }) .on(".hero-copy h1", { element(element) { if (doc.slug === "index") element.setInnerContent("Interactions for server-rendered pages.", { html: true }); }, }) .on(".hero-lead", { element(element) { if (doc.slug === "index") element.setInnerContent( "A collection of vendorable Rocket components. Explore drag-and-drop surfaces and contextual menus, each with a live example, a browser contract, and server-rendered markup.", ); }, }) .on(".docs-layout", { element(element) { if (doc.slug === "index") element.before(catalog, { html: true }); else element.before( `
Documentation / ${doc.group}

${doc.title}

`, { html: true }, ); }, }) .on(".docs-content > section", { element(element) { if (!selected.has(element.getAttribute("id") ?? "")) element.remove(); }, }) .on(".docs-content", { element(element) { if (doc.slug !== "index") element.append( ``, { html: true }, ); }, }) .on('a[href^="#"]', { element(element) { const hash = element.getAttribute("href")!.slice(1); const destination = sectionPaths.get(hash); if (destination && destination !== doc.slug) element.setAttribute("href", `./documentation/${destination}.html#${hash}`); }, }) .transform(new Response(`${page}`)) .text(); await writeFile(join(output, "documentation", `${doc.slug}.html`), html); if (doc.slug === "index") { const legacyHashes = [...sectionPaths.keys()].filter((hash) => hash !== "guide"); const landing = html .replace('', '') .replace( "", ``, ); await writeFile(join(output, "index.html"), landing); } } await copyFile(join(root, "examples/hono-datastar/demo.css"), join(output, "demo.css")); await copyFile(join(import.meta.dir, "site.css"), join(output, "site.css")); await mkdir(join(output, "js"), { recursive: true }); await copyFile(join(root, "public/js/datastar-rocket.js"), join(output, "js/datastar-rocket.js")); await copyFile(join(root, "public/js/DATASTAR-LICENSE.md"), join(output, "js/DATASTAR-LICENSE.md")); await copyFile(join(root, "LICENSE"), join(output, "LICENSE")); await buildSourceIndex(root, output, assetVersion); await mkdir(join(output, "downloads"), { recursive: true }); for (const { file } of browserBundles) { const content = file === "rocket-kit.js" ? bundle : await readFile(join(root, "dist", file), "utf8"); await writeFile(join(output, "downloads", file), content); await copyFile(join(root, "dist", `${file}.br`), join(output, "downloads", `${file}.br`)); await writeFile(join(output, file), content); } await writeFile(join(output, "fake-backend.js"), backendBundle); await writeFile(join(output, "keyboard-help.js"), keyboardHelp); await writeFile(join(output, "custom-atmosphere.js"), customAtmosphere); await writeFile(join(output, "trash-sparks.js"), trashSparks); await writeFile(join(output, "inline-edit-demo.js"), inlineEditDemo); console.error(`built ${join(output, "index.html")}`);