Hexpunk
InstallConceptsElementsProsehp-cellhp-codehp-copyhp-hexhp-latexhp-visually-hiddenhp-buttonhp-checkboxhp-formhp-labelhp-radiohp-selecthp-sliderhp-togglehp-toggle-grouphp-badgehp-taghp-progresshp-spinnerhp-backgroundhp-clusterhp-collapsiblehp-demohp-gridhp-scroll-areahp-separatorhp-sidebarhp-toolbarhp-avatarhp-iconhp-pixelhp-linkhp-menubarhp-navigation-menuhp-tabshp-alert-dialoghp-context-menuhp-dialoghp-dropdown-menuhp-hover-cardhp-popoverhp-tooltiphp-bannerhp-toasthp-tetherhp-module-handlehp-unfold-listhp-unfold-overlayhp-unfold-pagehp-bondhp-link-nodePaletteAnimationsReleases

hp-grid

The invisible hex coordinate space — slotted children with q and r attributes are positioned by CSS transforms. Drag any hex to move it; it snaps to the nearest empty axial slot, BFS-searches for the nearest free neighbour when the target is occupied, and fires hp-grid-move / hp-grid-drop / hp-grid-bond events on a successful drop.

Drag a hex to move it. Drag empty space to pan the canvas (Miro/Figma style — the hexes stay at their q/r coords; the viewport shifts).

Examples API Intent Changelog

Drag any hex onto another and it BFS-snaps to the nearest empty axial neighbour. Empty cells are direct drops. Drag is opt-in via the draggable attribute — without it the grid is static and pointerdowns on empty space do nothing.

centre east west south-east north-west ne sw <body> <!-- draggable on the grid enables both cell drag and canvas pan --> <hp-grid draggable> <hp-cell variant="anchor" q="0" r="0">centre</hp-cell> <hp-cell variant="action" q="1" r="0">east</hp-cell> <hp-cell variant="action" q="-1" r="0">west</hp-cell> <hp-cell variant="content" q="0" r="1" size="sm">south-east</hp-cell> </hp-grid> <!-- Per-cell overrides: pin individual cells inside a draggable grid --> <hp-grid draggable> <hp-cell variant="anchor" q="0" r="0" draggable="false">pinned</hp-cell> <hp-cell variant="action" q="1" r="0">draggable</hp-cell> </hp-grid> const grid = document.querySelector("hp-grid"); grid.addEventListener("hp-grid-drop", (ev) => { console.log(ev.detail); // { element, from: {q, r}, to: {q, r} } }); grid.addEventListener("hp-grid-bond", (ev) => { console.log(ev.detail); // { moved, partner } }); </body>

sm and lg are pointy-top variants of the same shape (lg is ×2 sm). md is the flat-top rotation — sized between sm and lg, sits at a single axial slot, gives a distinct visual "this one's different" against pointy-top neighbours. Spacing in this demo is generous so every cell has clear breathing room; in real use, md and lg cells need 2-3 axial slots of clearance from neighbours since their bbox extends beyond the sm slot footprint.

sm sm sm md lg sm content md content md status <body> <hp-grid draggable> <hp-cell variant="anchor" q="-3" r="0">sm</hp-cell> <hp-cell variant="anchor" size="md" q="0" r="0">md (flat-top)</hp-cell> <hp-cell variant="anchor" size="lg" q="4" r="0">lg</hp-cell> </hp-grid> </body>

Install

bun add @hexpunk/core lit
import "@hexpunk/core";

Properties

Property Attribute Type Default Description
size size "sm" | "md" | "lg" "sm" Cell size for the grid — `sm` (default `hex-cell-sm`), `md`, `lg`.
tetherable tetherable boolean false Opt into graph-editor semantics. When set, dropping a hex onto another hex toggles an `<hp-tether>` arc between the two instead of BFS-snapping to the nearest empty axial neighbour. The source snaps back to its origin slot; the target stays put. A tether is created if none exists between the pair (in either direction); an existing tether is removed (toggle). Empty-cell drops still move the hex normally. Consumers building layout surfaces leave this off; consumers building graph / node editors turn it on.
draggable draggable boolean false Opt into drag / pan. When unset (default), the grid is static — cells stay where they're authored and empty-space pointerdowns do nothing. When set, all interactive cells become draggable by default and empty-space drags pan the canvas. Per-cell override via the cell's own `draggable` attribute: `draggable` (presence) force-enables drag on that cell (useful for decorative cells that would otherwise be static); `draggable="false"` force-disables drag on that cell (useful for interactive cells that should stay put).
layout layout "free" | "spiral" | "rows" "free" Layout mode. - `free` (default) respects each child's authored `q` / `r` attributes. - `spiral` runs an FFD bin-pack with a spiral-from-origin scan: children are sorted by mask size descending, then each is placed at the first free position picked from a scan ordered by axial distance from `(0, 0)` (ring 0, then ring 1's 6 positions, ring 2's 12, …). Produces a tight roughly-square honeycomb — largest cluster anchors the centre, smaller ones nest around it with a ≥1-hex gap. - `rows` runs the same FFD pack but with a row-major scan capped at `WIDE_HALF_COLS` axial cells wide, so the layout grows as left-to-right rows that wrap downward — roughly-rectangular wide arrangement, ideal for full-page-width surfaces (component index pages) where the spiral's square shape leaves too much horizontal space unused. Children publish their actual filled hexes via `data-fill-cells` (composite elements like `<hp-cluster>` set this on slotchange); children without it are treated as single-hex. The gap check uses hex-adjacency (the 6 axial neighbours) — not rectangular bbox padding — so non-symmetric clusters' empty corners stay available for neighbours to tuck into. Triggered automatically on first render when set to `spiral` or `rows`, and re-runs when the attribute changes. Manual repack via `.pack()`. Drag interactions stay live; calling `.pack()` again re-runs the FFD pack from scratch (dragged positions are ignored).

Events

Event Type Description
hp-grid-move Event Fires immediately on drop. detail: { element, from, to }
hp-grid-drop Event Fires after the snap-back animation completes
hp-grid-bond Event Two cells became axially adjacent. detail: { moved, partner }
hp-grid-unbond Event Previously-adjacent cells separated
hp-grid-pan Event Fires while the canvas is panning
hp-grid-tether Event drag-to-tether created an arc. detail: { source, target, tether }
hp-grid-untether Event drag-to-tether removed an existing arc

Slots

Slot Description
(default) Slotted cells with q / r attributes

CSS Custom Properties

Property Default Description
--hp-cell Cell width

When to use

Use as the root surface for any layout where hexes need axial coordinates: dashboards, quest boards, node editors, game HUDs. Add draggable for surfaces that should accept move / pan; omit it for static layouts. Per-cell draggable attribute overrides the grid default. draggable (presence) force-enables drag on a cell — useful for decorative cells you want to move. draggable="false" force-disables drag on an interactive cell — useful for pinned anchors that should still be clickable but not draggable. Listen to hp-grid-bond / hp-grid-unbond to drive molecule assembly logic — the grid does the adjacency math for you on every drop. Mix sizes carefully. sm cells tile cleanly with each other; md (flat-top) and lg cells sit at a single axial slot but their bbox extends beyond the sm slot footprint, so reserve 2-3 axial units of clearance from neighbouring cells. The rotation on md is the visual cue that it's deliberately different, not poorly positioned. Wrap regular flow content in <hp-grid>. It expects children with q/r attributes — anything else is laid out as if it had q=0, r=0, which collapses overlapping at the origin. Replace <hp-cluster> with a grid for static 5-hex rosettes. Cluster is lighter (no occupancy map, no drag) and the right tool when you don't need rearrangement.

Events

hp-grid-move fires immediately on drop (axial coordinates accepted). hp-grid-drop fires after the 90ms snap-back animation completes — use this for "settled" semantics (autosave, server sync). hp-grid-bond / hp-grid-unbond fire when adjacency changes, with the moved + partner atoms in detail. hp-grid-pan fires while the canvas is being panned. With tetherable, hp-grid-tether / hp-grid-untether fire on drag-toggled arcs — covered on the hp-tether page.

Coordinate space

Pointy-top axial coordinates: x = w·(q + r/2), y = w·√3/2·r, where w is the effective cell width (cell − hex-stroke so adjacent strokes share). Move budgets and BFS for occupancy collision use axial Manhattan distance.

  • 0.1.0-beta 2026-05-24

    Added

    • <hp-cluster> layout attribute — rosette (default) / honeycomb modes. rosette preserves the canonical 5-hex cross via named slots (centre / top / middle-left / middle-right / bottom). honeycomb accepts N default-slot children: the first child is the centre (axial 0, 0); remaining children fill outward clockwise from north — ring 1 holds 6 positions, ring 2 holds 12 (capped at 19 hexes total in v1). Drag handling stays in <hp-grid> via drag-handle="..."; canonical handle is :first-child for honeycomb mode, [slot='centre'] for rosette. Auto-sizes the host: 2-ring footprint for rosette, 4-ring for honeycomb. Existing rosette usages render unchanged.
    • <hp-grid> pack() public method — runs the FFD bin-pack against the grid's current children. Triggered automatically on first render and on layout attribute changes; consumers can call it manually after dynamic child additions / filters / drags.
    • <hp-grid> recenter() fits content to the viewport. Walks every [q][r] child's per-cell fill mask (data-fill-cells) to compute the visible bbox, picks the zoom that frames the layout with ≤ 1 cell of padding around the outermost hex, then pans the bbox midpoint to viewport centre. Max zoom = 1 (never zooms past native scale). The recenter control button now snaps to "show everything" after any pan / zoom interaction. Replaces the previous reset-to-zoom-1 + pan-to-bbox-midpoint behaviour.
    • <hp-grid> skip-hidden plumbing. pack(), recenter(), and computePanBounds() all skip children carrying the hidden attribute. Filter-controlled visibility (e.g. the showcase components page's search) now plays cleanly with the layout pipeline — hidden clusters don't claim slots, don't drag the fit-bbox off-centre, and don't constrain pan range.
    • <hp-grid layout="spiral"> / layout="rows"> — FFD bin-pack with per-cell occupancy and hex-adjacency gap rule, sharing a common algorithm and differing only in scan order. On first render and on explicit .pack() calls, direct children are sorted by mask size (largest first), then each is placed at the first free position the chosen strategy returns. spiral scans outward from the origin in honeycomb rings — the largest cluster anchors (0, 0), smaller ones nest around it with ≥1-hex gaps, producing a tight roughly-square honeycomb. rows scans row-major with a width cap (10 axial cells half-width) — the layout grows as left-to-right rows that wrap downward, ideal for full-page-width surfaces. Children publish their actual filled hexes via data-fill-cells (space-separated "q,r" pairs); composite elements like <hp-cluster> publish these on slotchange. Children without the attr are treated as a single hex. The gap check uses hex-adjacency (the 6 axial-distance-1 neighbours) — not rectangular 3×3 padding — so non-symmetric cluster shapes leave their empty corners available for neighbours to tuck into. No auto-repack on resize / slotchange — only on explicit .pack() call or layout attribute toggle. Strategy modules live at src/elements/layout/hp-grid/layouts/{spiral,rows}.ts with shared primitives in layouts/index.ts; full unit-test coverage in layouts/{spiral,rows,layout}.test.ts (20+ tests covering hex-adjacency rejection, rect-diagonal acceptance, the layout's compactness bound, the components-page 12-cluster snapshot, and the row-wrap behaviour).
    • <hp-cluster> per-cell fill mask — both layout="rosette" and layout="honeycomb" now publish their actual filled hexes as data-fill-cells="q,r q,r …" host attribute (rosette: 5 fixed positions; honeycomb: first N entries of the fill-order table, derived from child count on slotchange). Bounding-box mirror attrs data-axial-q-min / q-max / r-min / r-max retained alongside for cheap consumers that only care about overall footprint dimensions. Consumed by <hp-grid layout="spiral"> / layout="rows"> for the tight bin-pack — empty bbox corners of non-symmetric cluster shapes (e.g. honeycomb with only NE ring-2 hexes filled) stay available for neighbour clusters to tuck into.
    • Showcase: /components index page switched to <hp-grid layout="rows"> — drops the hand-tuned per-category POSITIONS map; adding or removing categories in sitemap.ts now auto-reflows the cluster grid with a 1-cell gap between every cluster.
    • Showcase: /components index page — full-page-width <hp-grid draggable> filling the main column. Each category renders one <hp-cluster layout="honeycomb" drag-handle=":first-child"> with a centre anchor cell (<hp-cell variant="anchor" filled>) for the category label and one <a>-wrapped action cell per component. Web Awesome-style centred header (title + tagline + filter input), filter input live-filters cells by name substring, clusters whose entire ring filters out auto-hide, and a 300ms debounce kicks off a repack so the layout reflows around just the visible clusters. Sitemap's Components branch gains /components as the branch path so the sidebar heading is itself clickable.

    Changed

    • <hp-cluster> host pointer-events. Host is now pointer-events: none; ::slotted(*) re-enables pointer-events: auto. The cluster's rectangular bbox no longer intercepts pointerdowns through its empty corners — pan drags on the surrounding <hp-grid> reach the host correctly, and at the page bottom-right the cluster bbox no longer overlaps hp-grid's zoom / recenter controls. Only the actual hex cells catch clicks; the drag-handle child is still interactive because the ::slotted rule re-enables pointer-events on every positioned hex.
    • <hp-grid> drag-handle gating. Resolution switched from event.target.closest(selector) to target.querySelector(selector). The closest()-from-event-target approach matched :first-child ancestors transitively — e.g. inside <a><hp-cell></hp-cell></a>, the hp-cell is its <a> parent's first child, so closest() returned it and the drag-handle gate passed even when the click was outside the cluster's actual centre handle. querySelector resolves to the cluster's first matching descendant in DOM order (the actual centre), and the gate checks whether the pointerdown landed on it or one of its descendants.
    • <hp-grid> pan range swaps when content overflows the viewport. Previously, when the layout was bigger than the visible area, the pan-bounds rectangle collapsed to its midpoint — the user could centre the content but couldn't pan to see the overflow. Now, when minX > maxX (or minY > maxY), the two values swap so the legal range covers "child's left edge at viewport's left" through "child's right edge at viewport's right".

    Fixed

    • <hp-grid> recenter no longer clips content larger than the viewport. The previous algorithm reset zoom to 1 and panned to the bbox midpoint — fine if content fit at native scale, but layouts taller than the viewport stayed clipped because zoom never adjusted. The new fit-to-content pass zooms out to frame everything inside the viewport with ≤ 1 cell of padding (capped at zoom = 1).
    • <hp-grid> drag-handle resolution false-positive inside anchor wrappers. With drag-handle=":first-child" on a cluster whose centre cell sat inside <a><hp-cell></hp-cell></a>, the previous event.target.closest(":first-child") walk matched the wrapped hp-cell (it's its parent's first child), so clicking an outer link incorrectly initiated a cluster drag instead of navigating. Resolution now uses the cluster's own querySelector and asserts that the pointer landed inside the resolved handle.
esc