tabbied
v0.10.0
Published
Tabbied generative patterns: a framework-agnostic core, a <tabbied-pattern> web component, React and Vue components and a Svelte action, powered by css-doodle.
Maintainers
Readme
tabbied
Generative patterns as data: a framework-agnostic core, a <tabbied-pattern>
web component, a React and a Vue component and a Svelte action, powered by css-doodle. Render any of Tabbied's
preset designs (or your own definition) at any size, reseed them, and export
them to PNG.
Try the designs at tabbied.com.
Install
npm install tabbiedReact and Vue are optional peer dependencies - you only need them for the
tabbied/react and tabbied/vue entry points. tabbied/svelte imports no
framework at all, and the core works in any framework (or none).
The package is ESM only. import works everywhere; require() works on
Node 20.19+ and 22.12+ (which can require an ES module) and throws
ERR_REQUIRE_ESM on older Node.
Entry points
| Import | What it provides |
| ------------------ | -------------------------------------------------------------------------------------- |
| tabbied | The framework-agnostic core: createPattern, sizing/seed helpers, and the type definitions. |
| tabbied/react | The TabbiedPattern React component (and its handle/prop types). |
| tabbied/svelte | The tabbied Svelte action, tabbiedAttributes() for the server render, and patternController(). |
| tabbied/vue | The TabbiedPattern Vue 3 component. |
| tabbied/element | The <tabbied-pattern> custom element, definePatterns() and setPatternsBase(). dist/element/tabbied-element.js is the same element for a CDN, with css-doodle bundled in; it loads two chunks from its own folder, so self-host the whole dist/element/. |
| tabbied/element/react-jsx | Types only: <tabbied-pattern> in React's JSX, for a TypeScript app that uses the tag directly. |
| tabbied/patterns/<slug> | One design as a default export, with no imports: what the element loads by slug. |
| tabbied/patterns | The preset PatternDefinitions (import individually) plus the full patterns record. |
| tabbied/snippets | buildSnippet(setup, input): the code that puts one design on a page in React, Vue, Svelte, the web component, plain HTML or plain JavaScript, as the site's editor and the MCP server write it. |
| tabbied/svg-export | The native SVG converter (doodleToSvg) on its own, for exporting a rendered <css-doodle> you mounted yourself. exportSvg() on a controller or handle loads it for you. |
| tabbied/catalog.json | Every design as data - description, palette, options, SVG-export support. See Finding a design. |
Finding a design
The presets are referenced by slug, and there are a lot of them - the slug
alone (cleat, gnomonwedge, karst) won't tell you what a design looks
like. catalog.json is the searchable index: one entry per design with a
description, closed-vocabulary tags / mood / density / goodFor
metadata to filter on, the authored palette, every option and its accepted
values, whether it exports to SVG, and a stable preview image URL
(https://tabbied.com/previews/<slug>.webp) so you can look before you pick.
// Bundlers (Vite, webpack, Next) import JSON directly. In native Node ESM,
// add the import attribute: `with { type: 'json' }`.
import catalog from 'tabbied/catalog.json';
const heroCandidates = catalog.designs.filter(
(design) =>
design.goodFor.includes('hero-background') && design.density === 'sparse'
);Or from a shell, without writing any code:
npx tabbied list --tag dots --density dense
npx tabbied info radiusThe same data is served at tabbied.com/catalog.json, alongside llms.txt and llms-full.txt - an API contract plus a one-line entry per design, sized to fit in one fetch. If you are pointing a coding assistant at this package, give it that URL.
React
import { TabbiedPattern, type TabbiedPatternHandle } from 'tabbied/react';
import { radius } from 'tabbied/patterns';
import { useRef } from 'react';
export function Example() {
const ref = useRef<TabbiedPatternHandle>(null);
return (
<>
<TabbiedPattern ref={ref} pattern={radius} seed="k9Pz" height={320} />
<button onClick={() => ref.current?.redraw()}>Redraw</button>
<button onClick={() => ref.current?.exportImage()}>Export PNG</button>
<button onClick={() => ref.current?.exportSvg({ download: true })}>
Export SVG
</button>
</>
);
}SVG export
exportSvg() converts the rendered pattern to a native vector SVG - real
<rect>/<path>/gradient elements, no <foreignObject> - so the file opens
in design tools and scales to any resolution. It resolves with
{ svg, width, height, warnings }; pass { download: true } to also save a
.svg file, and { clip: { width, height } } to keep only the top-left box
of a canvas the host clips (the grid fit oversizes its canvas to whole
tracks, so this is what makes the file show what the page showed). A few
designs paint smooth conic-gradient sweeps that SVG cannot
represent - they set svgExport: false on their definition, and
supportsSvgExport(pattern) tells you whether to offer the option. Effects
exported through SVG filters (blur, glow shadows, blend modes) render
correctly in browsers but may degrade when imported into design tools; such
cases are listed in warnings.
Designs with known export limitations describe them in
pattern.svgExportNote (and, for limitations introduced by a toggle option
such as a filter-based shadow, option.svgExportNote) - surface these to the
user before downloading, the way the Tabbied editor's confirmation dialog
does.
The converter itself (about 12 KB gzipped once minified) is not part of
the main bundle: exportSvg() loads it on demand via a dynamic import, so
apps that never export pay nothing for the feature. To call the converter directly (e.g. on a
<css-doodle> you manage yourself), import it from the subpath:
import { doodleToSvg } from 'tabbied/svg-export';Importing presets (tree-shaking)
pattern takes a PatternDefinition object. Import only the presets you
actually render from tabbied/patterns and your bundler ships just those -
not the entire catalog:
import { radius, windowpane } from 'tabbied/patterns';Each preset is a side-effect-free named export, so unused ones are dropped at
build time. Need the whole set (e.g. to build a gallery)? Import the patterns
record - import { patterns } from 'tabbied/patterns' - and accept that it
pulls in every design.
The component is a client component (it registers a browser custom element on import), so in the Next.js App Router render it from a client boundary or rely on its built-in measurable placeholder until it mounts.
Common props
| Prop | Description |
| --------- | --------------------------------------------------------------------------- |
| pattern | A PatternDefinition - import a preset from tabbied/patterns or pass your own. |
| seed | Randomization seed. Omit for a random seed per mount; reseed via the handle. |
| palette | Active colors, background (color0) first. Defaults to the preset palette. |
| options | Option values keyed by option id; unset options use authored defaults. |
| fit | How the drawing meets its box: grid (default), cover, or fixed. |
| box props | How big the box is: fill, width, height, maxWidth, maxHeight, aspectRatio. |
See the inline JSDoc on TabbiedPatternProps for the full list.
Sizing the box
A pattern has no intrinsic size, so it takes the size of the box you give it. By default that box fills its containing block - drop one into a sized parent and you're done:
// .panel is 100% wide and 400px tall; the pattern fills it.
<div className="panel">
<TabbiedPattern pattern={radius} />
</div>| Prop | Type | Default | Description |
| -------------- | ------------------ | ------- | --------------------------------------------------------------- |
| fill | boolean | true | width: 100%; height: 100%. fill={false} leaves sizing to CSS. |
| width | number \| string | - | Box width. Numbers are px. Overrides fill on that axis. |
| height | number \| string | - | Box height. Numbers are px. Overrides fill on that axis. |
| maxWidth | number \| string | - | Upper bound on the width. |
| maxHeight | number \| string | - | Upper bound on the height. |
| aspectRatio | number \| string | - | CSS aspect-ratio, e.g. 3 / 2. Derives the height from the width. |
// Fill the width, cap it, and let the ratio set the height.
<TabbiedPattern pattern={radius} maxWidth={960} aspectRatio={3 / 2} />
// Full-bleed banner.
<TabbiedPattern pattern={radius} height="40vh" />
// Sized by a class name instead.
<TabbiedPattern pattern={radius} fill={false} className="hero-art" />Mixing them works the way the CSS does - they are the CSS, resolved onto the
wrapper element (server render included, so there's no layout shift on mount).
One caveat comes with the territory: height: 100% only resolves against a
parent with a definite height. In a parent that sizes to its content, reach for
height or aspectRatio instead of fill.
With the core API the host element is yours, so size it however you like - or run the same props through the same helper:
import { resolveBoxStyle } from 'tabbied';
Object.assign(host.style, resolveBoxStyle({ maxWidth: 960, aspectRatio: 3 / 2 }));Fit modes
fit says how the drawing meets the box. No fit distorts the pattern -
nothing is ever scaled by a different factor horizontally than vertically.
grid(default) - re-derives the cell grid from the measured box: whole, near-square cells edge to edge at any box shape.cover- draws a fixed-resolution render and scales it uniformly to fill the box (preserving the proportions of fixed-px strokes and shadows). The render follows the box's aspect ratio and re-derives its grid, so the pattern is never cut off mid-cell.fixed- renders at an explicit canvas size (width/heightin px, default 360 × 540). This is what the Tabbied editor uses.
Every design supports all three, so fit is a plain choice - omit it and you
get grid.
Web component
<tabbied-pattern
pattern="radius"
seed="k9Pz"
palette="#0B1020, #3E8BFF, #3FFFB2"
style="display: block; aspect-ratio: 3 / 2; background: #0B1020"
></tabbied-pattern>
<script type="module" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/element/tabbied-element.js"></script><tabbied-pattern> takes the React props as attributes, under the names
hydratePatterns() reads without the data- prefix: pattern, seed,
palette, options, fit, density, cell-size, width, height,
cover-render, redraw-interval, paused. Changing one redraws in place;
removing the element tears it down, and moving it does not.
- One script, only the designs used.
tabbied-element.jshas css-doodle bundled in. Each design is also published on its own asdist/patterns/<slug>.js, with no imports, and the element fetches only the ones the page names, from beside itself. Copydist/element/anddist/patterns/side by side to self-host, or pointsetPatternsBase()at the designs. - The box comes from inline style. Before the script runs the element is
an unknown tag, and its
stylealready applies: writedisplay: block, the size and the ground color there, and nothing shifts when the pattern mounts, on a server-rendered page too. - In a bundled app, import
tabbied/elementand register what it uses, so the bundler ships just those:definePatterns({ radius })withradiusfromtabbied/patterns. Or set thepatternproperty to the definition. Importing it on a server is safe. - Frameworks: React 19, Svelte, Solid and Angular set properties on it
(
palettetakes an array,optionsan object). Vue needscompilerOptions.isCustomElementto match the tag, and AngularCUSTOM_ELEMENTS_SCHEMA. In a TypeScript React app,import 'tabbied/element/react-jsx'once types the tag in JSX (the module is empty at runtime). - Scripting:
redraw(seed?),exportImage(),exportSvg(),refresh()andcontroller; areadyevent after the first render, anderror(reason indetail) for a design that cannot be loaded. It isaria-hiddenunless it carries anaria-label, which makes it an image.
Svelte and SvelteKit
<script>
import { tabbied, tabbiedAttributes, patternController } from 'tabbied/svelte';
import { radius } from 'tabbied/patterns';
const props = { pattern: radius, seed: 'k9Pz', aspectRatio: '3 / 2' };
let host = $state();
</script>
<div bind:this={host} {...tabbiedAttributes(props)} use:tabbied={props}></div>
<button onclick={() => patternController(host)?.redraw()}>Redraw</button>Pass the same props to both parts; they are the React component's props
(above), plus style as a string. The action mounts the pattern, forwards
prop changes to it and destroys it with the element.
An action runs only in the browser, so on its own it would leave a SvelteKit
server render with an empty, unsized <div> and the page would shift when the
pattern mounted. tabbiedAttributes() is pure and runs on the server: it
gives the element its box, the pattern's background color, the data-*
config and the accessibility attributes, and the action then mounts into it.
Put inline style in the props as style rather than in a style attribute
beside the spread: Svelte keeps whichever comes last, and the spread carries
the clipping the pattern needs.
patternController(element) returns the controller (redraw(),
exportImage(), exportSvg()), or null before the action runs. Nothing in
tabbied/svelte imports Svelte, so it works the same in Svelte 4 and 5.
Vue and Nuxt
<script setup>
import { ref } from 'vue';
import { TabbiedPattern } from 'tabbied/vue';
import { radius } from 'tabbied/patterns';
const pattern = ref(null);
</script>
<template>
<TabbiedPattern ref="pattern" :pattern="radius" seed="k9Pz" aspect-ratio="3 / 2" />
<button @click="pattern?.redraw()">Redraw</button>
</template>The props are the React component's, in kebab case in a template; onReady
is the ready event, and a template ref exposes redraw(), exportImage(),
exportSvg() and element. A server render, Nuxt's included, is the sized
placeholder, and the pattern mounts into it in the browser: no
<ClientOnly>, no layout shift. A class or style on the component lands
on its element. Vue 3.3 or later.
Core (framework-agnostic)
The full guide is at tabbied.com/docs/javascript.
import { createPattern } from 'tabbied';
import { radius } from 'tabbied/patterns';
const el = document.querySelector('#stage')!;
const controller = createPattern(el, {
pattern: radius,
seed: 'k9Pz',
// Measured fits (grid/cover) mount asynchronously, after the first
// ResizeObserver tick delivers the host's size - drive the controller from
// onReady rather than immediately after createPattern().
onReady: async () => {
controller.redraw(); // re-randomize the seed
await controller.exportImage();
const { svg } = await controller.exportSvg(); // native vector SVG
},
});
// later: controller.destroy();The controller accepts the same config the React component takes as props,
minus the box props - the host element is yours to size (or run them through
resolveBoxStyle, above). That includes the ambient-redraw timer, so the
framework-free API animates without reimplementing it:
const controller = createPattern(host, {
pattern: radius,
redrawInterval: 5200, // reseed every 5.2s, morphing via the authored transitions
});
// Hold the ticks without losing the redraw phase; resume with `false`.
controller.update({ paused: true });redrawInterval drops ticks while the tab is hidden or the host is scrolled
out of view - a page of animated patterns only pays for the ones somebody is
looking at.
Reduced motion
Under prefers-reduced-motion: reduce the controller suppresses every
source of movement, with no configuration:
- the
redrawIntervaltimer never starts, - the designs' own cell transitions are muted, so anything that re-renders cuts to the new arrangement instead of morphing into it, and
- the keyframe animations a few designs declare are paused on their first frame.
The second half matters more than it sounds. Every design carries a ~400ms
transition, and a re-render is not always something the reader asked for: a
resize re-derives the grid, so turning a phone or dragging a window animates
every cell on the page. That is the passive motion the preference exists for.
A redraw() you call yourself is muted on the same terms.
Nothing is lost - the pattern renders identically, it just stops easing between states. The preference is observed, not read once, so toggling it while the page is open takes effect immediately.
Declarative mounting (no build step)
hydratePatterns() reads a pattern's config off plain data-* attributes,
so a static HTML page can describe its patterns in the markup and bring them
all up with one call:
<div data-pattern="ortho"
data-palette="transparent, #C9C8C1, #8E8E88"
data-fit="grid"
data-cell-size="132"
data-redraw-interval="5200"
style="width: 100%; height: 60vh"></div>
<script type="module">
// Pin a version: a bare `tabbied` on esm.sh is whatever is latest.
// `?exports=` trims the presets to the ones the page names.
import { hydratePatterns } from 'https://esm.sh/[email protected]';
import { ortho } from 'https://esm.sh/[email protected]/patterns?exports=ortho';
hydratePatterns({ patterns: { ortho } });
</script>The attributes are readable rather than a JSON blob - the point is that
somebody editing a page can change a color or a slug without decoding
anything. Values are typed by the pattern's own option metadata, so a
ButtonSelectGroup choice that looks numeric stays a string.
| Attribute | Config field |
| --- | --- |
| data-pattern | The preset slug. Required - everything else is optional. |
| data-seed | seed |
| data-palette | palette - comma separated, background first. Splits at top level, so rgb(0, 0, 0) survives. |
| data-options | options - id: value pairs separated by ; |
| data-fit | fit |
| data-cell-size / data-density | cellSize / density |
| data-width / data-height | fixed canvas size in px |
| data-cover-render | coverRender - 800x800 |
| data-redraw-interval / data-paused | redrawInterval / paused |
Pass root to scope the search to a subtree, selector to override
[data-pattern], and defaults to merge a config into every match.
hydratePatterns returns { element, controller } for each pattern it
mounted, and is idempotent - calling it twice won't double-mount.
An unknown slug is reported through onError (a console.warn by default)
and skipped, and an attribute that doesn't parse falls back to the design's
authored default: a hand-edited template degrades to the pattern's defaults
rather than to a blank box.
patternConfigToAttributes() is the inverse, for generating such markup.
TabbiedPattern uses it on its own placeholder, so a server-rendered React
page already emits everything hydratePatterns needs - which is what lets a
prerendered page be repackaged as a framework-free template.
Use one or the other on a given element:
TabbiedPatternmounts its own controller, so an unscopedhydratePatterns()on a React page would mount a second one on top of it. That's whatrootis for.
CLI
The package ships a tabbied bin for rendering without writing an app -
generate an asset, or a video-ready frame sequence, straight from a shell:
npx tabbied render radius --seed k9Pz --size 1600x900 --out hero.svg
npx tabbied render bight --palette "#0B1020,#3E8BFF" --out banner.png
npx tabbied render radius --frames 120 --reseed-every 30 --size 1920x1080 --out frames/
npx tabbied list --good-for hero-background --density sparse
npx tabbied info radiusRendering runs css-doodle in a headless browser through Playwright, which this
package does not install. The CLI uses whichever of playwright,
playwright-core or @playwright/test it finds, from the current directory
first, then from its own install:
npm i -D playwright && npx playwright install chromium # in a project
npx -y -p tabbied -p playwright tabbied render radius --out hero.svg # nothing installedThe second form needs the browser downloaded once too
(npx playwright install chromium). Pass --browser <path> (or set
TABBIED_CHROMIUM) to use a specific Chromium binary. --out's extension
(or --format svg|png) picks the format, and an SVG is cut to --size in
every fit, as the PNG is; frame sequences are PNG, cut deterministically between
seeds (frames within a reseed window are identical, so encoders can
deduplicate), and named frame-000.png on, so ffmpeg -i frames/frame-%03d.png
reads any sequence of up to 1,000 frames. Every flag also takes --flag=value. Run npx tabbied --help
for every flag.
For AI agents
AGENTS.md and llms.txt ship in this package - the latter is the complete
agent-facing reference (also served at
tabbied.com/llms-full.txt). Point your
coding assistant at either.
License
MIT © Sy Hong and Ye Joo Park
