Playground
A grid of chunky pixels to paint on, with a 16-colour palette. It's built for server-driven multiplayer: the board state is one attribute, cells (one hex digit per cell, row by row), which the server renders. Painting doesn't change cells. It emits sb-paint, your backend applies it, and the next server frame brings the new cells to everyone. While a pixel is in flight, it's drawn slightly faded; if no frame confirms it within 3 seconds (say the server refused it), it goes back to the board's colour.
Examples
Local mode
local paints the board's own buffer, so no server is needed. Drag to draw, pick a colour below, or focus the board and use the arrow keys plus Space.
<sb-pixel-board local grid size="24" style="--sb-pixel-board-size: 20rem"></sb-pixel-board>
Server-driven (the Showcase board)
This is the wiring the multiplayer board on the Showcase page uses. The server renders cells into every frame of the board's view, which every viewer's render stream carries, and painting is a command:
<sb-pixel-board id="board" size="48" cells="…rendered by the server…"
data-on:sb-paint="@post('/cmd/paint', {
payload: {color: evt.detail.color, cells: evt.detail.cells},
requestCancellation: 'disabled'
})"></sb-pixel-board>
requestCancellation: 'disabled' matters: a stroke posts every 80 ms, and by default Datastar cancels a request that is still running when the next one goes to the same URL, which would drop pixels mid-stroke on a slow connection. No data-preserve-attr here: cells belongs to the server, and every morph brings the latest board.
On the server, /cmd/paint checks the rate limits and sends a command, and the command writes the cells in the single writer's transaction:
// cmdPaint paints cells of the shared board (anyone may paint, rate-limited).
func (s *Server) cmdPaint(w http.ResponseWriter, r *http.Request) {
var p struct {
Color int `json:"color"`
Cells []int `json:"cells"`
}
if err := datastar.ReadSignals(r, &p); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
now := time.Now()
if !s.paintLimit.allow(edge.SessionID(r.Context()), len(p.Cells), now) || !s.paintLimitIP.allow(edge.ClientKey(r), len(p.Cells), now) {
http.Error(w, "painting too fast", http.StatusTooManyRequests)
return
}
s.send(w, r, commands.PaintPixels{Board: commands.BoardName, Color: p.Color, Cells: p.Cells})
}
// PaintPixels paints cells of the shared board. It touches TopicBoard: the
// board's view renders again, and no page does.
type PaintPixels struct {
Board string
Color int
Cells []int
}
func (c PaintPixels) Validate() error {
if c.Board != BoardName {
return errors.New("unknown board")
}
if c.Color < 0 || c.Color > 15 {
return errors.New("colour must be 0-15")
}
if len(c.Cells) == 0 || len(c.Cells) > MaxPaint {
return errors.New("paint 1 to 64 cells at a time")
}
for _, i := range c.Cells {
if i < 0 || i >= BoardSize*BoardSize {
return errors.New("cell out of range")
}
}
return nil
}
func (c PaintPixels) Apply(tx *writer.Tx) error {
tx.Touch(TopicBoard)
now := time.Now().Unix()
for _, i := range c.Cells {
if _, err := tx.Exec(`
INSERT INTO board_cells (board, idx, color, painted_at) VALUES (?, ?, ?, ?)
ON CONFLICT (board, idx) DO UPDATE SET color = excluded.color, painted_at = excluded.painted_at`,
c.Board, i, c.Color, now); err != nil {
return err
}
}
_, err := tx.Exec(`
INSERT INTO boards (board, version, pixels) VALUES (?, 1, ?)
ON CONFLICT (board) DO UPDATE SET version = version + 1, pixels = pixels + excluded.pixels`,
c.Board, len(c.Cells))
return err
}
Watch only
<sb-pixel-board readonly size="16" cells="0000000000000000000000055000000000000055550000000000005775000000000005579550000000000559955000000000055775500000000005555550000000000566665000000000f555555f0000000ff555555ff000000f05333350f0000000003333000000000000eddd0000000000000ee00000000000000000000000" style="--sb-pixel-board-size: 12rem"></sb-pixel-board>
Event
sb-paint bubbles out of the shadow root with detail: { color, cells }, where cells are the indices (y * size + x) touched since the last event. Events are batched every ~80 ms during a stroke, with at most 60 cells each (a quick stroke sends more events, not a bigger one, so a server can cap a command's size), and fast drags are interpolated so strokes have no gaps. A stroke that leaves the board and comes back continues where it re-enters, without a line across.
Only the primary button of one pointer paints: a right click doesn't, and a second finger doesn't join the stroke. On touch screens, two fingers zoom the page. A readonly board leaves touches to the page, which scrolls over it, and doesn't highlight the cell under the pointer.
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size:
--sb-pixel-board-size(default24rem) is the width of the board, which stays square, with the palette below it.sizesets the number of cells per side, not the size on screen. - Colours: the paint colours are the
paletteprop (16 hex colours), not theme tokens. The board has a--sb-borderframe around a--sb-surface-insetbackground; the chosen colour and the focus ring are--sb-brand-light. Withgrid, the cell lines are--sb-pixel-board-grid. They lie on the cells, not on the page, so by default they follow the blank cell colour (the palette's first): faint white on a dark board, faint black on a light one. The swatches are edged in--sb-text-1at 12%, so they stand out on light and dark pages alike. - Parts:
board(the canvas and the palette together),canvasandpalette. Your page's::part()rules win over the component's own, without!important.
<style>
.my-board { --sb-pixel-board-size: 16rem; --sb-brand-light: var(--sb-accent); --sb-pixel-board-grid: rgb(255 255 255 / 0.2); }
.my-board::part(palette) { gap: 2px; }
</style>
<sb-pixel-board class="my-board" local grid size="16"></sb-pixel-board>
Accessibility
The canvas is focusable, with the role application, so a screen reader passes the keys on to it: the arrow keys move a highlighted cursor (starting in the middle), and Space or Enter paints. Other keys, like Tab, are left alone, and the cursor goes away when the board loses focus. The canvas label states the cursor position, that cell's colour and the selected colour, and follows all three, including a new colour choice and cells the server changes. The palette is a group of native radio buttons ("Colour 1" to "Colour 16"): one tab stop, and the arrow keys pick the colour. In forced colours (Windows High Contrast) the swatches keep their colours.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.
<script type="importmap">
{ "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-pixel-board local grid size="24"></sb-pixel-board>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/pixel-board@edabeefe2544/pixel-board.bundle.min.js" integrity="sha384-hVdxffNQDd/fpgxd5d7drDwqVmCoVScO798fdEsW7YLisEY7opOToORQckBwbZ1n"></script>
<sb-pixel-board local grid size="24"></sb-pixel-board>Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/pixel-board@edabeefe2544/pixel-board.min.js": "sha384-UD6WrdAn9U6HJ59+l2INLQL2sg9gWUKnndR0Fq7Hq1HEdMdmFBMTw7zAAVD/LdEv"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/pixel-board@edabeefe2544/pixel-board.min.js" integrity="sha384-UD6WrdAn9U6HJ59+l2INLQL2sg9gWUKnndR0Fq7Hq1HEdMdmFBMTw7zAAVD/LdEv"></script>
<sb-pixel-board local grid size="24"></sb-pixel-board>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/pixel-board@edabeefe2544/pixel-board.bundle.min.js": "sha384-hVdxffNQDd/fpgxd5d7drDwqVmCoVScO798fdEsW7YLisEY7opOToORQckBwbZ1n"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>
<sb-pixel-board local grid size="24"></sb-pixel-board>Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<sb-pixel-board>pixel-board.bundle.min.jsone file 2.7 kB brotli
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/pixel-board/pixel-board.bundle.min.js"></script>
<sb-pixel-board local grid size="24"></sb-pixel-board>Or copy the module files, keeping each component's folder, and load each component's module:
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/pixel-board/pixel-board.min.js"></script>
<sb-pixel-board local grid size="24"></sb-pixel-board>Size
A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli |
|---|---|---|---|
pixel-board.bundle.min.js | 6.0 kB | 3.0 kB | 2.7 kB |
As module files: one request, 2.6 kB minified with brotli
For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
pixel-board.js | 11.1 kB | 4.7 kB | 4.1 kB | 2.6 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
size | number | 48 | Cells per side. |
cells | string | "" | Board state: one hex digit (palette index) per cell, row by row. Usually server-owned. |
palette | array | [
"#080D1D",
"#1B1F3B",
"#283552",
"#7785A8",
"#AEBBDD",
"#F3F4FA",
"#8C6BFF",
"#B09AFF",
"#5B4FD6",
"#65BFFF",
"#2F7BE0",
"#6EF59A",
"#4CC46A",
"#FFD84D",
"#FF9F43",
"#E5484D"
] | 16 colours as a JSON array of #rrggbb. |
color | number | 7 | Selected palette index (the attribute sets it; the user can change it). |
local | boolean | false | Paint the local buffer directly (offline mode). Otherwise painted cells stay pending until cells echoes them. |
readonly | boolean | false | Watch only: no painting, no palette. |
grid | boolean | false | Show faint cell lines. |
Events
| Name | Description |
|---|---|
sb-paint | Painted cells, batched every ~80 ms during a stroke. detail: { color, cells: [index…] }. |