Introduction#
createPattern(host, config) mounts a pattern into an element you own and returns a controller for it. The React and Vue components, the Svelte action and the web component are each a lifecycle around this one call, so the config is the settings they take, with the same names as in React (the Concepts page says what each one does). The redraw timer and its reduced-motion, hidden-tab and off-screen gates live in the controller, so nothing here needs reimplementing.
Installation#
One package, with css-doodle inside it and no framework.
npm install tabbiedThe core is tabbied; the designs are tabbied/patterns, one named export each, so a bundler ships only the ones you import.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';Quick start#
Size a host, then mount a design into it. The MCP server's get_design writes this form for any design; from a pattern page in the gallery, carry its seed, palette and options across.
// <div id="banner"></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#banner');
// The box fills its parent by default; height: 320 pins one axis.
Object.assign(host.style, resolveBoxStyle({ height: 320 }));
const controller = createPattern(host, { pattern: radius, seed: 'k9Pz' });Sizing#
A pattern has no size of its own: it fills the box it is given, and an empty box draws nothing. The box settings size it, fill, width, height, maxWidth, maxHeight and aspectRatio, which resolveBoxStyle() turns into the host's inline style. Numbers are px and strings are any CSS length. By default the box fills its parent, width: 100%; height: 100%, so the parent needs a height; in a parent that sizes to its content, give the pattern a height or an aspectRatio.
Whatever the box, the pattern is fitted into it without distortion, and fit picks how:
grid, the default, re-derives the cell grid from the measured box, so any box is tiled edge to edge with whole, near-square cells.density(0 coarse to 1 fine) orcellSize(px) sets how fine.coverdraws one render and scales it uniformly to fill the box, keeping the proportions of fixed-px strokes. The render follows the box's shape, so nothing is cut off mid-cell.fixeddraws a canvas of an exact size,widthandheightin px (360 by 540 unless set): what the editor uses.
Each column below is one mode, drawing the same design at the same seed into a landscape box and a portrait one.
fit: gridThe cell grid is re-derived per box, so cells stay square in both.fit: coverOne render, scaled uniformly to fill. Fixed-px strokes keep their proportions.fit: fixedA canvas of its own size, here 150 x 225, which each box crops.// <div id="grid"></div> <div id="cover"></div> <div id="fixed"></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { vitrail } from 'tabbied/patterns';
const host = (id, box) => {
const element = document.querySelector(id);
Object.assign(element.style, resolveBoxStyle(box));
return element;
};
// grid (default): the cell grid adapts to the box.
createPattern(host('#grid', { height: 240 }), { pattern: vitrail, fit: 'grid' });
// cover: one render, scaled uniformly to fill the box.
createPattern(host('#cover', { height: 240 }), { pattern: vitrail, fit: 'cover' });
// fixed: a canvas of an exact size, what the editor uses.
createPattern(host('#fixed', { width: 360, height: 540 }), {
pattern: vitrail,
fit: 'fixed',
width: 360,
height: 540,
});Sizing, case by case draws and measures eighteen combinations: a width and a height, a ratio with both set, a parent with no height, a min-height, flex parents, a pattern behind content and a fixed canvas.
resolveBoxStyle() takes the box settings and returns the style to give the host, or size it in your own CSS instead:
import { resolveBoxStyle } from 'tabbied';
// Fill the width, cap it, and let the ratio set the height.
Object.assign(host.style, resolveBoxStyle({ maxWidth: 600, aspectRatio: '3 / 2' }));
// Pin one axis; numbers are px, strings are CSS.
Object.assign(host.style, resolveBoxStyle({ height: 320 }));aspectRatio takes CSS's 3 / 2, a number, or the editor's 3:2.
Settings#
Beside its box, every design takes the same settings: a palette, its options and a seed. Each page in the gallery sets them with controls and writes the code for you.
Colors
palette recolors a design: the background (color 0) first, then the inks. Fewer colors than the design was drawn with is fine: the inks cycle, so a two-color palette redraws the whole design in two colors. Below, one design at one seed in three palettes from the gallery's library; Ocean has four colors to the design's six, so its inks repeat.
SunsetOceanBauhaus// <div class="swatch"></div> <div class="swatch"></div> <div class="swatch"></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { mixtape } from 'tabbied/patterns';
// The background (color 0) comes first, then the inks.
const palettes = [
['#2b1d3a', '#ff6b6b', '#ffd23e', '#ff3d8b', '#7048e8'],
['#0b2545', '#8da9c4', '#eef4ed', '#13a8a8'],
['#f4f1ea', '#d7263d', '#1b6ca8', '#f7b32b', '#232529'],
];
document.querySelectorAll('.swatch').forEach((host, i) => {
Object.assign(host.style, resolveBoxStyle({ height: 180 }));
createPattern(host, { pattern: mixtape, seed: 'k9Pz', palette: palettes[i] });
});Any CSS color works for a slot, transparent included, which drops the ground: whatever is behind the box shows through.
// <div id="overlay"></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#overlay');
Object.assign(host.style, resolveBoxStyle({ height: 240 }));
// Any CSS color works for a slot, 'transparent' included: the ground drops
// out and whatever is behind the box shows through.
createPattern(host, { pattern: radius, palette: ['transparent', '#232529', '#ff3d8b'] });Options
options are the controls the editor shows, keyed by option id; an option left out keeps the design's default. A design's option ids and their values are on its definition (pattern.options) and on its page in the gallery.
thickness: 4thickness: 14// <div class="maze"></div> <div class="maze"></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { maze } from 'tabbied/patterns';
// Option ids come from the design: maze takes a grid, a frequency and a thickness.
const thicknesses = [4, 14];
document.querySelectorAll('.maze').forEach((host, i) => {
Object.assign(host.style, resolveBoxStyle({ height: 180 }));
createPattern(host, { pattern: maze, seed: 'k9Pz', options: { thickness: thicknesses[i] } });
});Seeds
seed picks the arrangement: the same design, seed and options draw the same picture at any size, so a seed is a design you can keep. Leave it out for a new one each time the pattern mounts.
Updates, redraw and export#
The controller changes the pattern already on the page: update() merges settings in, and only what changed reaches the page, so calling it on every change is cheap.
// Only what changed reaches the page.
controller.update({ palette: ['#FFF4E6', '#E8590C'], options: { frequency: 0.6 } });
controller.redraw(); // a new seed, morphing into the new arrangement
controller.redraw('k9Pz'); // or a seed of your own
const { svg } = await controller.exportSvg();
await controller.exportImage({ scale: 2, download: true });
// When the host leaves the page: stops its timers and observers.
controller.destroy();Under the measured fits, grid (the default) and cover, the pattern can only be drawn once the host has a size, so it mounts after the first resize observation rather than inside createPattern(). Until then element is null and an export has nothing to read. Anything that needs the drawn pattern belongs in onReady, which runs once, after the first render; under fit: 'fixed' the canvas size is given, so it mounts at once.
const controller = createPattern(host, {
pattern: radius,
// Under the measured fits (grid, the default, and cover) the pattern
// mounts once the host has a size, a frame or so later.
onReady: async () => {
const { svg } = await controller.exportSvg();
},
});// <div id="art"></div> <button id="redraw">Redraw</button> <button id="export">Export PNG</button>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { blossom } from 'tabbied/patterns';
const host = document.querySelector('#art');
Object.assign(host.style, resolveBoxStyle({ height: 280 }));
const art = createPattern(host, { pattern: blossom, palette: ['#fff0f6', '#ff3d8b', '#7048e8', '#3eecff', '#ffd23e'], fit: 'cover' });
document.querySelector('#redraw').addEventListener('click', () => art.redraw());
document.querySelector('#export').addEventListener('click', () => art.exportImage());exportImage() saves a PNG at any scale; bump it for print. exportSvg() converts the drawing to a vector SVG (real shapes and gradients, no foreignObject) that opens in design tools and scales to any size; { download: true } saves either as a file. A few designs use smooth conic sweeps an SVG cannot express: they set svgExport: false, which supportsSvgExport(pattern) from tabbied checks.
Motion and accessibility#
Ambient motion
redrawInterval reseeds on a timer, in milliseconds, morphing from one arrangement to the next: the gallery's shimmer. Ticks are skipped while the tab is hidden or the pattern is off screen, so a long page of moving patterns pays only for what is on screen. paused holds the timer without losing its place.
// <div id="shimmer"></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { quilt } from 'tabbied/patterns';
const host = document.querySelector('#shimmer');
Object.assign(host.style, resolveBoxStyle({ height: 280 }));
// A new seed every 2 seconds. Ticks are skipped while the tab is hidden or
// the box is off screen; under prefers-reduced-motion the timer never starts.
createPattern(host, { pattern: quilt, fit: 'cover', redrawInterval: 2000 });Accessibility
The core leaves the element as you wrote it, so say what it is: aria-hidden="true" for decoration, or role="img" and an aria-label for a pattern that is content.
decorativeHidden from assistive technology.labelledRead as an image: "Generative pattern of rings".// <div id="decoration"></div> <div id="figure"></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { ring } from 'tabbied/patterns';
const decoration = document.querySelector('#decoration');
const figure = document.querySelector('#figure');
for (const host of [decoration, figure]) Object.assign(host.style, resolveBoxStyle({ height: 180 }));
// The core leaves the host as you wrote it: say what it is.
decoration.setAttribute('aria-hidden', 'true');
figure.setAttribute('role', 'img');
figure.setAttribute('aria-label', 'Generative pattern of rings');
createPattern(decoration, { pattern: ring });
createPattern(figure, { pattern: ring });Reduced motion
Under prefers-reduced-motion: reduce nothing moves, with nothing to configure: the timer never starts, and the designs' own cell transitions are muted, so a re-render cuts to the new arrangement instead of easing into it. That matters more than it sounds: grid and cover re-derive their cells when the box changes, so turning a phone would otherwise animate every cell on the page. The preference is watched, not read once, so changing it takes effect at once.
Server rendering#
createPattern() needs a browser: it measures the host and mounts a custom element into it. Importing tabbied on a server is safe, though, and resolveBoxStyle() is pure, so a server template can write the host's size inline from the same box settings. The page then arrives with the box at its final size, and the pattern mounts into it in the browser without moving anything. Give the host the pattern's background color inline too, and the box shows it until the pattern arrives.
Recipes: layout#
Whole files, imports included, ready to paste, each with its result drawn live above it. Swap the design for any slug in the gallery.
A hero behind a headline
The host is taken out of the flow with position: absolute; inset: 0, so the section is as tall as its copy and the pattern covers all of it. isolation: isolate keeps its z-index: -1 inside the section.
Patterns for every page
The section is as tall as this copy; the pattern fills it.
// <section class="hero" style="position: relative; isolation: isolate; padding: 96px 24px">
// <div class="hero-art"></div>
// <h1>Patterns for every page</h1>
// </section>
import { createPattern } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('.hero-art');
host.style.cssText = 'position: absolute; inset: 0; z-index: -1';
createPattern(host, {
pattern: radius,
seed: 'launch',
palette: ['#0B1020', '#1D3A8A', '#3E8BFF', '#3FFFB2'],
density: 0.3,
});One design, a different card each
One controller per element. A seed per card, here its id, gives every card its own picture, the same on every visit.
First post
Second post
Third post
// <article data-id="post-1"><div class="card-art"></div><h3>First post</h3></article>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { quilt } from 'tabbied/patterns';
for (const card of document.querySelectorAll('article[data-id]')) {
const host = card.querySelector('.card-art');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '16 / 9' }));
createPattern(host, { pattern: quilt, seed: card.dataset.id });
}A section divider
A full-width strip: resolveBoxStyle({ height: 48 }) keeps the width at 100% and pins the height. A higher density keeps the cells small at a short height.
The end of one section.
The start of the next.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { ortho } from 'tabbied/patterns';
for (const host of document.querySelectorAll('.divider')) {
Object.assign(host.style, resolveBoxStyle({ height: 48 }));
createPattern(host, { pattern: ortho, seed: 'divider', density: 0.9 });
}Over a photograph
A palette that starts with transparent leaves the ground clear, so the parent's background image shows through. A lower frequency leaves more of it showing.
// <div style="background: url(/images/harbor.jpg) center / cover"><div id="overlay"></div></div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#overlay');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '21 / 9' }));
createPattern(host, {
pattern: radius,
palette: ['transparent', '#FFFFFF', '#3FFFB2'],
options: { frequency: 0.4 },
});One palette across several designs
A palette is just an array, so a brand's colors can dress any design: background first, then the inks. Each host names its design in the markup, and one loop mounts them all.
// <div class="tiles"> (display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px)
// <div data-design="radius"></div>
// <div data-design="quilt"></div>
// <div data-design="vitrail"></div>
// <div data-design="ortho"></div>
// </div>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { ortho, quilt, radius, vitrail } from 'tabbied/patterns';
const DESIGNS = { radius, quilt, vitrail, ortho };
const BRAND = ['#0B1020', '#3E8BFF', '#3FFFB2', '#FF3D8B'];
for (const host of document.querySelectorAll('.tiles [data-design]')) {
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '1' }));
createPattern(host, { pattern: DESIGNS[host.dataset.design], seed: 'brand', palette: BRAND });
}Sized from a stylesheet
Skip resolveBoxStyle() and the host keeps whatever your CSS gives it, breakpoints included: the pattern fills the box the stylesheet draws.
// <div class="banner"></div>
//
// .banner { height: 160px; border-radius: 12px; }
// @media (min-width: 768px) { .banner { height: 288px; } }
import { createPattern } from 'tabbied';
import { radius } from 'tabbied/patterns';
createPattern(document.querySelector('.banner'), { pattern: radius });A pattern that means something
The core leaves the host as you wrote it. A pattern that is only decoration wants aria-hidden; one that is content, say an illustration in an article, is an image with a label.
// <figure>
// <div id="art"></div>
// <figcaption>Radius, seed k9Pz.</figcaption>
// </figure>
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#art');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '1' }));
host.setAttribute('role', 'img');
host.setAttribute('aria-label', 'Quarter circles in blue and green, packed edge to edge');
createPattern(host, { pattern: radius, seed: 'k9Pz' });Recipes: interaction#
Patterns that answer to state: colors, seeds, designs, options, motion and export.
Recolor with a theme switch
update() merges a change into the pattern already on the page, which redraws through the design's own transition; nothing is rebuilt.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const PALETTES = {
light: ['#FFF4E6', '#E8590C', '#1C1C1C'],
dark: ['#0B1020', '#3E8BFF', '#3FFFB2'],
};
let theme = 'dark';
const host = document.querySelector('#banner');
Object.assign(host.style, resolveBoxStyle({ height: 240 }));
const banner = createPattern(host, { pattern: radius, seed: 'k9Pz', palette: PALETTES[theme] });
document.querySelector('#theme').addEventListener('click', () => {
theme = theme === 'dark' ? 'light' : 'dark';
banner.update({ palette: PALETTES[theme] });
});Follow the system color scheme
Read prefers-color-scheme once for the first palette, then update on every change.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const LIGHT = ['#FFF4E6', '#E8590C', '#1C1C1C'];
const DARK = ['#0B1020', '#3E8BFF', '#3FFFB2'];
const query = window.matchMedia('(prefers-color-scheme: dark)');
const host = document.querySelector('#banner');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '3 / 1' }));
const banner = createPattern(host, { pattern: radius, seed: 'k9Pz', palette: query.matches ? DARK : LIGHT });
query.addEventListener('change', () => banner.update({ palette: query.matches ? DARK : LIGHT }));A shuffle button
redraw() picks a new seed and morphs to it; pass one to choose it, say a seed you saved.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#art');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '3 / 2' }));
const art = createPattern(host, { pattern: radius });
document.querySelector('#shuffle').addEventListener('click', () => {
const seed = Math.random().toString(36).slice(2, 8);
art.redraw(seed);
localStorage.setItem('hero-seed', seed);
});Let people pick the design
Import the designs on offer and hand one to update({ pattern }); it replaces the drawing in place.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { quilt, radius, vitrail } from 'tabbied/patterns';
const designs = { radius, quilt, vitrail };
const host = document.querySelector('#art');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '3 / 2' }));
const art = createPattern(host, { pattern: radius, seed: 'k9Pz' });
document.querySelector('#design').addEventListener('change', (event) => {
art.update({ pattern: designs[event.target.value] });
});Sliders for an option and the density
Option ids come from the design (radius has frequency, 0.2 to 1); density is the cell size, 0 coarse to 1 fine. Each update() redraws in place.
// <div id="art"></div>
// <input id="frequency" type="range" min="0.2" max="1" step="0.1" value="0.8">
// <input id="density" type="range" min="0" max="1" step="0.05" value="0.5">
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#art');
Object.assign(host.style, resolveBoxStyle({ height: 280 }));
// Start where the sliders start.
const art = createPattern(host, { pattern: radius, seed: 'k9Pz', options: { frequency: 0.8 }, density: 0.5 });
document.querySelector('#frequency').addEventListener('input', (event) => {
art.update({ options: { frequency: Number(event.target.value) } });
});
document.querySelector('#density').addEventListener('input', (event) => {
art.update({ density: Number(event.target.value) });
});Slow motion that pauses on hover
Leave seed out and set redrawInterval: the pattern reseeds on a timer and morphs between arrangements. update({ paused }) holds it; it also stops on its own off screen, in a hidden tab, and for anyone who asks for reduced motion.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#ambient');
Object.assign(host.style, resolveBoxStyle({ height: 240 }));
const ambient = createPattern(host, { pattern: radius, redrawInterval: 4000 });
host.addEventListener('mouseenter', () => ambient.update({ paused: true }));
host.addEventListener('mouseleave', () => ambient.update({ paused: false }));Download buttons
exportImage() saves a PNG at any scale and exportSvg() a vector file. Enable them from onReady, once there is a drawing to export, and check supportsSvgExport(): a few designs have no vector form.
import { createPattern, resolveBoxStyle, supportsSvgExport } from 'tabbied';
import { radius } from 'tabbied/patterns';
const png = document.querySelector('#png');
const svg = document.querySelector('#svg');
const host = document.querySelector('#art');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '3 / 2' }));
const art = createPattern(host, {
pattern: radius,
seed: 'k9Pz',
onReady: () => {
png.disabled = false;
svg.disabled = !supportsSvgExport(radius);
},
});
png.addEventListener('click', () => art.exportImage({ scale: 2, download: true, name: 'banner' }));
svg.addEventListener('click', () => art.exportSvg({ download: true, name: 'banner' }));Send the SVG to your server
Without download, exportSvg() resolves to the markup and its size, to store or post anywhere.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
const host = document.querySelector('#art');
Object.assign(host.style, resolveBoxStyle({ width: 600, height: 400 }));
const art = createPattern(host, { pattern: radius, seed: 'k9Pz' });
document.querySelector('#save').addEventListener('click', async () => {
const { svg, width, height } = await art.exportSvg();
await fetch(`/api/artwork?width=${width}&height=${height}`, {
method: 'POST',
headers: { 'Content-Type': 'image/svg+xml' },
body: svg,
});
});Recipes: in an app#
Where the pattern meets the rest of an app.
Inside a custom element of your own
Mount in connectedCallback and destroy in disconnectedCallback: a destroyed controller stops its timers and observers, and the element can be moved or removed freely.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
class BrandBanner extends HTMLElement {
#controller = null;
connectedCallback() {
Object.assign(this.style, { display: 'block' }, resolveBoxStyle({ aspectRatio: '3 / 1' }));
this.#controller = createPattern(this, {
pattern: radius,
seed: this.getAttribute('seed') ?? 'brand',
palette: ['#0B1020', '#3E8BFF', '#3FFFB2'],
});
}
disconnectedCallback() {
this.#controller?.destroy();
this.#controller = null;
}
}
customElements.define('brand-banner', BrandBanner);Clean up on a route change
In a single-page app, destroy what a view mounted when it goes, or its timers and observers outlive it.
import { createPattern, resolveBoxStyle } from 'tabbied';
import { radius } from 'tabbied/patterns';
export function mountView(root) {
const host = root.querySelector('.view-art');
Object.assign(host.style, resolveBoxStyle({ height: 200 }));
const controller = createPattern(host, { pattern: radius, redrawInterval: 5000 });
// Call this when the view unmounts.
return () => controller.destroy();
}Designs chosen at runtime, loaded on demand
Each design is a module of its own at tabbied/patterns/<slug>: list the ones you offer as dynamic imports and a bundler splits them, so a page loads only the one it shows.
import { createPattern, resolveBoxStyle } from 'tabbied';
const loaders = {
radius: () => import('tabbied/patterns/radius'),
quilt: () => import('tabbied/patterns/quilt'),
vitrail: () => import('tabbied/patterns/vitrail'),
};
export async function mountPattern(host, slug) {
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '3 / 2' }));
const { default: pattern } = await loaders[slug]();
return createPattern(host, { pattern });
}Sizing, case by case#
Each case below was measured in a browser, in an 800px-wide parent: the drawing is the box the pattern gets, to scale, and the code is all it takes. Every case uses radius; any design behaves the same.
- A pattern has no size of its own: it fills the box it is given, and an empty box draws nothing.
- By default the box fills its parent,
width: 100%; height: 100%(thefillprop), so the parent needs a height. In a parent that sizes to its content, give the pattern aheightor anaspectRatio. widthandheightreplace one axis each. Numbers are px; a string is any CSS length.aspectRatioworks out the axis that has no size. Whenwidthandheightare both set, the ratio is ignored, andfillcounts as setting the width.fill: falsedrops the 100%s, which is what a ratio from a height, a ratio under amaxHeight, or a flex item needs.- With
fit="fixed"the box is the canvas, 360 by 540 unless set.
A fixed width and height
Both axes are set, so the box is exactly that size wherever it is. Numbers are px; a string is any CSS length.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ width: 400, height: 300 }));
createPattern(host, { pattern: radius });Full width, a fixed height
Only height is given, so fill keeps the width at 100% of the parent. The usual shape for a banner or a section divider.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ height: 320 }));
createPattern(host, { pattern: radius });Full width at a ratio
The safest default when the parent has no height: the width is the parent's and the height follows from aspectRatio. It takes 3 / 2, 1.5, or the editor's 3:2.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ aspectRatio: '3 / 2' }));
createPattern(host, { pattern: radius });A ratio with a maximum width
maxWidth caps the width, and the height still follows the ratio. Add margin: 0 auto in style to center it.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({
maxWidth: 600,
aspectRatio: '3 / 2',
}));
createPattern(host, { pattern: radius });A fixed width and a ratio
The width is set and the height is worked out from the ratio: 400px at 2 / 1 is 200px tall.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({
width: 400,
aspectRatio: '2 / 1',
}));
createPattern(host, { pattern: radius });Width, height and a ratio
When both width and height are set, the box is that size and aspectRatio is ignored: CSS only uses a ratio to work out an axis that has no size of its own.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({
width: 400,
height: 300,
aspectRatio: 1,
}));
createPattern(host, { pattern: radius });A height and a ratio
A trap: fill sets the width to 100%, so both axes have a size and the ratio is ignored. The box is the parent's width by 300px, not 600 by 300.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ height: 300, aspectRatio: 2 }));
createPattern(host, { pattern: radius });A height and a ratio, width from the ratio
fill: false drops the 100% width, so the width is worked out from the height and the ratio: 600 by 300.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({
fill: false,
height: 300,
aspectRatio: 2,
}));
createPattern(host, { pattern: radius });A ratio with a maximum height
Another trap: the width stays at 100% and maxHeight clamps the height, so the box loses its ratio, 800 by 200 rather than square.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ maxHeight: 200, aspectRatio: 1 }));
createPattern(host, { pattern: radius });A ratio with a maximum height, kept square
With fill: false there is no 100% width to hold on to, so the width shrinks with the capped height and the ratio holds: 200 by 200.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({
fill: false,
maxHeight: 200,
aspectRatio: 1,
}));
createPattern(host, { pattern: radius });Any CSS length
Strings are written through as CSS, so percentages, viewport units, min() and calc() all work. Here, half the parent by 40% of the window.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ width: '50%', height: '40vh' }));
createPattern(host, { pattern: radius });Filling a parent that has a height
With no box props the pattern fills its parent, 100% by 100%. That works when the parent has a height of its own, here 300px, or a grid row or flex column that gives it one.
// <div style="height: 300px">
// <div id="pattern"></div>
// </div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({}));
createPattern(host, { pattern: radius });A parent with no height
The most common reason a pattern draws nothing. A pattern has no content to give it a height, and 100% of a parent that sizes to its content is 0px, so the box is 0px tall and nothing is drawn. In development the console says so once. Give the pattern a height or an aspectRatio, or the parent a height.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({}));
createPattern(host, { pattern: radius });A parent with only a min-height
A min-height is not a height: 100% of it still resolves to nothing, and the pattern is 0px tall. Make the parent a flex column instead (next).
// <div style="min-height: 250px">
// <div id="pattern"></div>
// </div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({}));
createPattern(host, { pattern: radius });A min-height parent, as a flex column
In a flex column the pattern can grow to the parent's height with flex: 1. fill: false drops the 100% height that would otherwise stand in its way.
// <div style="min-height: 250px; display: flex; flex-direction: column">
// <div id="pattern"></div>
// </div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ fill: false }));
host.style.cssText += '; flex: 1';
createPattern(host, { pattern: radius });Beside content in a flex row
100% of a flex row that sizes to its content is nothing again, so the pattern stays 0px tall beside a 300px column of copy. Drop fill and give it flex: 1: it takes the rest of the row and stretches to the row's height, 600 by 300.
// <div style="display: flex">
// <div style="width: 200px; height: 300px">Copy</div>
// <div id="pattern"></div>
// </div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ fill: false }));
host.style.cssText += '; flex: 1';
createPattern(host, { pattern: radius });Behind a block of content
For a background, take the pattern out of the flow with position: absolute; inset: 0 in style: it covers whatever height the content gives the parent. isolation: isolate on the parent keeps the pattern's z-index: -1 inside it.
// <div style="position: relative; isolation: isolate; padding: 40px">
// <div id="pattern"></div>
// <h2>Headline</h2>
// <p>The copy sets the height.</p>
// </div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({}));
host.style.cssText += '; position: absolute; inset: 0; z-index: -1';
createPattern(host, { pattern: radius });A fixed canvas
With fit="fixed" the pattern is drawn at an exact canvas size and the box is that canvas: 360 by 540 unless numeric width and height say otherwise. It never fills its parent.
// <div id="pattern"></div>
const host = document.querySelector('#pattern');
Object.assign(host.style, resolveBoxStyle({ width: 360, height: 540 }));
createPattern(host, { pattern: radius, fit: 'fixed', width: 360, height: 540 });API reference#
createPattern(host, config)
config is the React component's props less the box ones: pattern (required), seed, palette, options, fit, density, cellSize, width and height (for a fixed canvas), coverRender, redrawInterval, paused and onReady. It returns the controller:
| Member | What it does |
|---|---|
| update(config) | Merges config changes in. Only real differences reach the page, so calling it on every change is cheap. |
| redraw(seed?) | A new seed, or the one given, morphing through the design's own transition. |
| exportSvg(options?) | Vector SVG, as { svg }. Waits for a redraw in flight. Not available for the designs the catalog marks as raster only. |
| exportImage(options?) | PNG through css-doodle, at scale; download: true saves it. |
| destroy() | Removes the pattern and stops its timers and observers. A destroyed controller ignores every call. |
| element | The live <css-doodle> element, or null before it mounts and after destroy(). |
resolveBoxStyle(box)
Takes fill, width, height, maxWidth, maxHeight and aspectRatio and returns the style for the host, as an object of camel-cased CSS properties. Pure, so it runs on a server too.
hydratePatterns(options)
Mounts every [data-pattern] element on the page from its data attributes, which Plain HTML lists. It is idempotent, so call it again after adding patterns; an element it already mounted is skipped.
data-pattern="radius"data-pattern="vitrail"import { hydratePatterns } from 'tabbied';
import { radius, vitrail } from 'tabbied/patterns';
const mounted = hydratePatterns({
patterns: { radius, vitrail }, // the designs the markup names
root: document.querySelector('main'), // optional: where to look
defaults: { redrawInterval: 5200 }, // optional: merged into every one
onError: (error, element) => element.remove(),
});
// Each element with its controller.
mounted.forEach(({ controller }) => controller.redraw());patterns is the only required option: the designs to resolve slugs against, as a record or an array. root scopes the search, selector replaces [data-pattern], and defaults is merged into every config after the attributes. A slug missing from patterns goes to onError (a console warning by default) and the rest still mount.