UI kit

Sluurp ships a UI kit: the shadcn/ui components, rebuilt without React and served by the binary. Import them in any page or island; nothing to install.

islands/signup.tsx
import { Button } from "sluurp/kit/button.js";
import { Dialog } from "sluurp/kit/dialog.js";
import { Input } from "sluurp/kit/input.js";

What’s included:

  • Forms: Input, Textarea, Checkbox, Switch, Radio group, Select, Combobox, Date picker (single day, or a range over two side-by-side months), Time picker (hour/minute columns, AM/PM per locale), Slider, Tag input, OTP input, and Questionnaire (one question at a time, with progress).
  • Layout: Card, Sheet, Drawer, Dialog, Tabs, Accordion, Resizable panels, Sidebar, Scroll area.
  • Feedback: Toasts, Alerts, Tooltips, Hover cards, Progress, Skeletons, Badges, Notifications stack (grouped cards that expand into a list, animated with Motion).
  • Data: Table, Spreadsheet, Pagination, Charts, Calendar, Carousel, Avatars, Emoji picker.
  • Menus: Dropdown, Context menu, Menubar, Navigation menu, Command palette.

Every component supports light and dark themes, keyboard and screen readers, and works on an iPhone. The admin UI is built with the same components.

Two-way binding

Pass a signal as value for two-way binding, like v-model or bind:value: the field shows the signal and writes back on input. No onInput needed.

app.tsx
const title = signal("");
<Input value={title} placeholder="What needs doing?" />

Same for Textarea, Switch and Checkbox (checked), Toggle (pressed), Collapsible (open), Select, Combobox, RadioGroup, ToggleGroup, Tabs, Accordion, Slider, InputOTP, DatePicker, Calendar and TimePicker. Number inputs write numbers. A plain value or function is one-way, so derived values can’t be overwritten by accident.

A slider can show its value in a bubble over the thumb while it’s hovered, dragged or focused:

<Slider ariaLabel="Volume" value={volume} showValue={(v) => `${v}%`} />

Components built in a function

A function child redraws when a signal it reads changes. If that function builds a component, wrap it in fresh so the old component’s effects end with it:

import { fresh } from "sluurp/ui";

<div>{fresh(() => Preview(story(), args()))}</div>

Without fresh, the old nodes are removed but a component built inside the function keeps listening. Plain values and markup don’t need it.

Testing components

A story’s play uses the component the way a person would. Find elements by role and name, act on them, and check the result:

dialog.stories.tsx
import { expect, userEvent, within } from "sluurp/test";

export const Cancelled = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(await canvas.findByRole("button", { name: "Open the dialog" }));
    await expect(await canvas.findByRole("heading", { name: "Offer times" })).toBeVisible();
    await userEvent.click(await canvas.findByRole("button", { name: "Cancel" }));
  },
};

In the admin’s UI kit, the Test tab runs the play. Each step shows as passed or failed with its line in the story file, and you can step back, forward, pause or stop. Record writes a play for you: click and type in the preview, Alt+click an element to check it, then Save as story.

On the Test tab, Run runs the plays of the component you’re looking at, and Run suite runs every story’s play, the kit’s and your app’s. Each one is timed. Show all results or only the failures, and click one to open its story. Run is off for a component with no plays.

While you move between components, the kit watches what each one starts and leaves running once it’s gone. That covers timers and animation frames that repeat, intervals, listeners on the window or the document, and observers of the whole page. It shows a notification naming the component and the line that started each one. Clean up with onDetached from sluurp/primitives, which runs when an element is taken off the page, or is never put on it.

A play can check layout as well as behaviour. Measure elements and compare their edges, so a change that moves things out of line fails the test:

chat.stories.tsx
export const LinedUp = {
  play: async ({ canvasElement }) => {
    const c = within(canvasElement);
    const left = (el: Element) => Math.round(el.getBoundingClientRect().left);
    await expect(left(await c.findByText(/How plants/))).toBe(left(c.getByRole("button", { name: /Search notes/ })));
  },
};

Motion

sluurp/kit/tween.js moves things the way GSAP does: to, from and fromTo tweens, staggers, labels and GSAP’s eases, one after another in a timeline. It moves position, scale and rotation, and it also fades, changes colours, types text and draws lines. onView plays a timeline while its element is on screen, and scrub ties it to scrolling. When someone asks for reduced motion, the timeline shows its final frame and doesn’t play.

import { timeline, onView } from "sluurp/kit/tween.js";

const tl = timeline({ repeat: -1 })
  .from(".card", { y: 30, opacity: 0 }, { stagger: 0.1, ease: "back.out" })
  .to(".card", { scale: 1.05 }, { at: "+=0.3" });
onView(section, tl);

Every frame comes from the time alone, so a play can go to any moment of a timeline and check it there:

tl.seek(0);
await expect(card.style.opacity).toBe("0");
tl.progress(1);
await expect(card.style.transform).toContain("scale(1.05");

TimelineView shows a timeline the way a motion tool does. Each element gets a track and each tween a bar, and you can drag the playhead to scrub. The kit also has ready-made scenes for product sites: IsoStack, TerminalReplay, FanOut, IsoGrid and BuildUp. Each one puts its timeline on its element as el.timeline.

Design review

The Playground’s Design tab checks the story the way a designer would: contrast, type sizes, alignment, touch targets, overflow and spacing. It reviews as soon as you open it, and again when you switch between desktop, tablet and phone. It gives a score out of 100 and a ranked list of what to change (must, should, could); hovering a line outlines the element it is about. On a phone-sized screen, words or buttons closer than 12px to the side are a must-fix. In Multi, each frame gets its own score. Preview fixes applies the fixes it can, and Undo takes them back.

The preview

The Playground previews a story at full width, tablet (768px) or phone (390px). A tablet or phone preview sits on a canvas you can move around: drag with the middle mouse button (or hold Space and drag), scroll with the wheel, and zoom with Ctrl, ⌘ or Shift and the wheel, or with the zoom buttons. It’s fitted to the space when you pick it. Toggling light and dark only changes the theme; the story isn’t reloaded.

The same canvas is the kit’s InfiniteCanvas, for anything you want to lay out and move around:

<InfiniteCanvas>{frames}</InfiniteCanvas>

Controls

Controls are made from the story’s args and the component’s props. A choice of three or fewer values shows as a segmented control. Text with line breaks gets a multi-line code editor; give it a language to colour it:

code-view.stories.tsx
export default {
  component: CodeView,
  args: { code: "export default () => <p>Hi</p>;" },
  argTypes: { code: { control: { type: "code", language: "tsx" } } },
};

The editor is the kit’s CodeView with editable, which you can use too:

<CodeView code={source} editable onChange={(text) => source.set(text)} />

The agent

The sparkle in the UI kit’s header opens the agent. You can also type in the field floating over the preview. Ask it for a component, a change, or a better design:

A star rating, 1 to 5, that works on phones, with a test for the keyboard.

It looks at the kit before writing anything, and reuses what is there. New components go in your app’s components/ folder. Changes to an existing file appear in its Source tab before they are saved. It shows the component in the Playground, runs the tests and the design review in every frame, and fixes what fails.

Source is editable by hand too: Edit, then Save, while you develop.

Stages

A story can sit on a stage that is not part of it, using Storybook’s decorators. The stage is drawn around the story in the previews, but it is not in the story’s code or in what you export:

liquid-glass.stories.tsx
const stage = (story) => (
  <div data-glass-ground class="grid h-full place-items-center bg-[linear-gradient(90deg,#fde047,#fb7185)]">{story()}</div>
);

export const Pill = { decorators: [stage], args: { width: 320, height: 48 } };

Export

Export, on the Source tab, downloads the story as a web component in a zip:

  • one module that defines a <sluurp-name> tag, with nothing else to load;
  • a demo page that works opened from disk.

The module holds Sluurp’s runtime, the kit components the story uses, the CSS for every class they can show (a menu open or closed) and the font, all inside a shadow root. It works in React, Vue, Svelte or plain HTML. data-theme="dark" on the tag draws it dark, and CSS variables on the tag theme it:

<script type="module" src="./sluurp-rating.js"></script>
<sluurp-rating style="--primary: oklch(0.55 0.2 260)"></sluurp-rating>

AI components

The kit’s AI components are the ones Sluurp’s own AI screens use:

  • ChatPanel holds a conversation. Feed it from sluurp/ai’s assistant().
  • AgentSidebar is where an AI sits: beside the page, resizable, and a sheet on a phone.
  • AgentAskField is the field that floats at the foot of what the AI works on.
  • AgentToolSteps shows what the AI did between two answers: “Used 3 tools (1 failed)”, each opening to what it was given and what came back. Mark a step with error: true when its tool failed; it’s shown in red, with its error.
import { assistant } from "sluurp/ai";
import { ChatPanel } from "sluurp/kit";

const ai = assistant({ client: sluurp, system: () => "You help with homework.", load, save, words });
<ChatPanel title="Assistant" turns={ai.turns} asking={ai.asking} writing={ai.writing} onAsk={ai.ask} onStop={ai.stop} />;

A questionnaire

One question at a time: single choice, multiple choice or free text, with progress and Previous / Skip / Next. Letter keys pick choices, and when makes a question conditional on earlier answers.

islands/consent.tsx
import { Questionnaire } from "sluurp/kit/questionnaire.js";

<Questionnaire
  items={[
    { name: "coming", prompt: "Will your child come on the trip?", required: true,
      choices: [{ value: "yes", label: "Yes" }, { value: "no", label: "No" }] },
    { name: "needs", prompt: "Anything we should know?", multiple: true, input: true,
      when: (a) => a.coming === "yes",
      choices: [{ value: "allergy", label: "An allergy" }, { value: "travel", label: "Travel sickness" }] },
  ]}
  onSubmit={(answers) => sluurp.collection("consent").create(answers)}
/>

Each question is a fieldset with the prompt as legend and native radios and checkboxes. Pass a signal as answers to prefill and track them.

Data for components

A component shown on its own, in the component editor, can be given data four ways, as props:

  • Mock values: plain JSON.
  • A snapshot of a collection: {"$collection": "posts", "limit": 5}.
  • A live query: {"$live": "posts", "filter": "published = true"}. The component gets the rows as a function and follows them as they change.
  • Both ways: {"$sync": "posts"} gives rows that can also be written, with create, update and remove. {"$sheet": "cells", "sheet": "budget"} gives a Spreadsheet its cells in a collection.

In an app, list a collection with Records. Add live to keep the list current:

import { Records } from "sluurp/kit/records.js";

<Records from="posts" sort="-created" limit={6} live empty={<p>Nothing yet.</p>}>
  {(post) => <article><h3>{post.title}</h3></article>}
</Records>

To keep a Spreadsheet’s cells in a collection, shared live with everybody, use sheetStore:

import { Spreadsheet } from "sluurp/kit/spreadsheet.js";
import { sheetStore } from "sluurp/kit/sheet-store.js";

<Spreadsheet store={sheetStore("cells", "budget")} />

Tailwind

See Styling and Tailwind for how classes are styled with nothing to set up, and for cn and variants.

Tailwind v4, no build step: the stylesheet is generated from the classes your app uses. Configure it the v4 way, in CSS:

app.css
@theme {
  --color-brand: oklch(0.62 0.19 35);
  --font-display: "Fraunces", serif;
  --breakpoint-3xl: 120rem;
}
@utility content-auto { content-visibility: auto; }
@utility tab-* { tab-size: --value(integer); }
@custom-variant midnight (&:where([data-theme="midnight"] *));

.card { @apply rounded-xl p-4 shadow-sm; @variant hover { @apply shadow-md; } }

--color-brand generates bg-brand, text-brand/50, ring-brand and so on; --font-*, --text-*, --radius-*, --shadow-* and --breakpoint-* work the same way. @apply and @variant are compiled server-side, so the browser gets plain CSS. Newer utilities are supported too: text-shadow-*, inset-shadow-*, inset-ring-*, masks (mask-b-from-50%), 3D transforms (rotate-x-45, perspective-near), bg-radial, bg-conic, scheme-*, wrap-*, and variants like not-*, nth-*, supports-*, min-[…], pointer-coarse, forced-colors, inert and starting. See them live in the admin’s Components screen.

Theming

Colours use shadcn’s tokens: background, foreground, card, popover, primary, secondary, muted, accent, destructive, border, input, ring, chart-1…chart-5 and sidebar-*, each with a -foreground pair. Themes from ui.shadcn.com/themes or tweakcn paste in unchanged:

app.css
:root {
  --radius: 0.625rem;
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
}
.dark, [data-theme="dark"] {
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
}

bg-primary is var(--primary); bg-primary/50 mixes it with transparent, as in Tailwind 4. Any colour format works: oklch(), hsl() or #hex.

shadcn’s seven base colours are included (Neutral, Stone, Zinc, Mauve, Olive, Mist, Taupe), light and dark. Link the stylesheet and pick one on the root:

<link rel="stylesheet" href="/sluurp-base-colors.css">
<html data-base-color="zinc">

Palette Editor

A palette for people to choose and shape: its colours as a strip where each one can be re-rolled on its own with a click, another palette with the dice, colours pasted from Coolors, Color Hunt or Lospec, and a pill that puts the default back. Give it saturation to add a vibrancy slider.

<PaletteEditor value={palette} fallback={() => defaultPalette()} onChange={save} />

Smart Table

A table of records you give it as data. Each part can be turned on or off:

  • Toolbar (search, start, actions): search across all columns, a Filters button for the columns marked filter, and slots for your own controls.
  • Headings: click to sort (ascending, descending, off). Drag a heading to reorder (reorderable), drag its edge to resize (resizable). The arrow keys also resize; a double click resets the width.
  • Columns menu (hideable): show and hide columns.
  • Rows: ticks (selectable), your own row actions (rowActions), pages (pageSize) or infinite scroll (paging: "infinite", with loadMore to fetch the next page). Past 200 rows, only the rows in view are drawn.
  • Footer (footer): the row count, number totals and yes counts. With ticks, it sums only the ticked rows.
<SmartTable
  search
  selectable
  columns={[
    { key: "name", label: "Trip" },
    { key: "cost", label: "Cost", type: "number", format: "currency" },
    { key: "paid", label: "Paid", align: "center", cell: (trip) => <Progress value={trip.paid * 100} /> },
  ]}
  rows={trips}
/>

Values are formatted in the reader’s language with format: "currency", "percent", "integer", "date", or Intl options. cell draws a cell your own way.

Widths fit the space available: the rightmost columns shrink first, each down to its minWidth. The table only scrolls sideways once every column is at its minimum. Sorting, hiding, order and widths are the table’s look: saved in the browser under storageKey, or held in your own signal (look) so you can save it anywhere.

Spreadsheet

A spreadsheet component: formulas, selection, in-place editing, copy/paste, column resize/insert/delete/drag/hide, row insert/hide, and a right-click menu. Recalculation is incremental: when a cell changes, only the cells that depend on it re-run.

It doesn’t own its data. Pass it a store:

app.tsx
import { mount } from "sluurp/ui";
import { Spreadsheet, memoryStore } from "sluurp/kit";

const store = memoryStore({ A1: "2", A2: "3", A3: "=SUM(A1:A2)" });
mount("#app", () => <Spreadsheet store={store} />);

A store implements:

Method
cell(ref)Returns a reactive getter for a cell ("A1").
write(ref, patch)Updates a cell.
shape(), setShape(shape)Optional. Persist column/row order, count and visibility. Without them, layout is per screen.

A cell is { formula, bold?, format?, renderer? }.

memoryStore is in-memory. For a shared sheet, back the store with a Sync collection, like the Sheet example. For a non-reactive source (a Map, a cache), use externalStore and notify it with the ref that changed:

const values = new Map();
const listeners = new Set<(ref?: string) => void>();
const store = externalStore({
  get: (ref) => values.get(ref),
  write: (ref, patch) => {
    values.set(ref, { formula: "", ...values.get(ref), ...patch });
    listeners.forEach((l) => l(ref));
  },
  subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
});

Built-in functions: SUM, AVERAGE, MIN, MAX, COUNT, ROUND, ABS, IF, plus comparisons and & for string concatenation. References work as in Excel: inserting or deleting rows and columns shifts them, and when you paste, $ pins a part ($A$1 stays put, $A1 keeps its column, A$1 its row). Add your own functions:

<Spreadsheet store={store} functions={{ ...SHEET_FUNCTIONS, DOUBLE: (x) => Number(x) * 2 }} />

Conditional styles are set per cell from the context menu: a formula that returns a colour name (red, text:green, border:amber, bold) or any Tailwind classes, variants included. It can reference any cells, and THIS is the cell’s own value:

=IF(SUM(A1:A3)>10, "red", "green")
=IF(THIS>100, "bg-orange-500 text-white hover:underline", "")

Classes that aren’t in your app’s stylesheet (it’s generated from your source, and these are typed at runtime) are reported through the classes prop. Pass useClasses from sluurp/classes and it fetches their CSS once and adds it to the page, or to the island’s shadow root:

import { useClasses } from "sluurp/classes";
<Spreadsheet store={store} classes={useClasses} />

Custom renderers draw a cell’s value however you like. Register them by name; users pick one from the context menu. A renderer can take options, stored after its name in the cell (progress 0 100 bg-emerald-500). If it declares them, its menu item asks:

const renderers = {
  progress: {
    options: "min max colour",
    render: (value, ref, sheet, [min = "0", max = "1", colour = "bg-primary"]) => {
      const share = Math.max(0, Math.min(1, (Number(value) - Number(min)) / (Number(max) - Number(min))));
      return (
        <div role="progressbar" aria-valuenow={value} class="h-2 rounded bg-muted">
          <div class={`h-full rounded ${colour}`} style={`width:${share * 100}%`} />
        </div>
      );
    },
  },
};
<Spreadsheet store={store} renderers={renderers} />

There’s no networking inside. For multiplayer, pass others (remote selections, drawn in each user’s colour) and broadcast your own from onSelect. Live cursors come from sluurp/cursors.

Try them

Everything below is the live kit in this page’s theme: click, type, drag. The island is a single file.