Build interactive SVG hex grids with cube coordinates, hit testing and framework-agnostic helpers.
hex-grid-kit is a clean-room toolkit for apps that need a hexagonal board without committing to a specific framework. It gives you the coordinate math, generated cells, SVG geometry and a small DOM mount helper, so you can integrate a selectable hex grid in a browser UI quickly.
- TypeScript types are generated from the source.
- ESM-only package with no runtime dependencies.
- Marked as side-effect free for bundlers.
- Browser-friendly implementation with no Node-only APIs.
- CI runs
npm ci,typecheck,build, andtest. - Tested on Node.js 20 and 22 with GitHub Actions.
Try the interactive demo: packages.wasta-wocket.fr/hex-grid-kit.
The demo runs the published npm package in the browser and shows shape switching, cube and axial labels, fixed or responsive SVG sizing, per-cell fills, selection, neighbors, rings and line helpers.
<div id="board"></div>
<script type="module">
import { mountHexGrid } from 'hex-grid-kit';
mountHexGrid(document.querySelector('#board'), {
shape: 'hexagon',
radius: 3,
cellSize: 32,
selectable: true,
showCoordinates: true,
onCellClick(cell) {
console.log(cell.id, cell.coord);
}
});
</script>Use honeycomb-grid if you mainly need a mature headless coordinate library.
Use hex-grid-kit when you want a practical interactive board layer:
- cube-first coordinate helpers, with axial
{ q, r }shorthand when you want it; - hexagon, rectangle, parallelogram and custom grid shapes;
- SVG polygon generation with safe labels and attributes;
- hit testing from pointer coordinates to cells;
- neighbors, ranges, rings and straight hex lines;
- optional DOM mount helper for selection and pointer callbacks.
npm install hex-grid-kitimport { createHexGrid } from 'hex-grid-kit';
const grid = createHexGrid({
shape: 'hexagon',
radius: 3,
cellSize: 28,
orientation: 'pointy'
});
console.log(grid.cells.length); // 37
console.log(grid.neighborsOf('0,0,0').map((cell) => cell.id));
console.log(grid.range('0,0,0', 2).map((cell) => cell.id));import { createHexGrid, renderHexGridSvg } from 'hex-grid-kit';
const grid = createHexGrid({
shape: 'rectangle',
columns: 8,
rows: 5,
cellSize: 24
});
const svg = renderHexGridSvg(grid, {
title: 'Level editor board',
showCoordinates: true,
selectedIds: ['0,0,0', '1,0,-1'],
highlightedIds: grid.line('0,0,0', '4,0,-4').map((cell) => cell.id)
});
document.querySelector('#board')!.innerHTML = svg;import { mountHexGrid } from 'hex-grid-kit';
const board = mountHexGrid(document.querySelector('#board')!, {
shape: 'hexagon',
radius: 4,
cellSize: 30,
selectable: true,
multiSelect: true,
showCoordinates: true,
onCellClick(cell) {
console.log('clicked', cell.id, cell.coord);
},
onSelectionChange(ids) {
console.log('selected cells', ids);
}
});
board.setSelected(['0,0,0', '1,-1,0']);
// Later:
board.update({ radius: 5 });
board.destroy();The mount helper only needs a normal DOM element. It is intentionally small, so React, Vue, Svelte or plain JavaScript apps can either use it directly or render grid.cells themselves.
hex-grid-kit does not own your UI state. In framework apps, you can keep selection and hover state in your component, then render from the generated cells:
import { createHexGrid, getHexGridViewBox } from 'hex-grid-kit';
const grid = createHexGrid({ shape: 'hexagon', radius: 3, cellSize: 28 });
export function Board({ selectedId, onSelect }) {
return (
<svg viewBox={getHexGridViewBox(grid)}>
{grid.cells.map((cell) => (
<polygon
key={cell.id}
points={cell.points}
data-hex-id={cell.id}
className={cell.id === selectedId ? 'hex selected' : 'hex'}
onClick={() => onSelect(cell)}
/>
))}
</svg>
);
}const cell = grid.hitTest({ x: pointerX, y: pointerY });
if (cell) {
console.log(cell.id, cell.coord.q, cell.coord.r, cell.coord.s);
}hitTest converts pixels back to cube coordinates and verifies that the point is inside the hex polygon. This matters when you use spacing to create visible gaps between cells.
createHexGrid({ shape: 'hexagon', radius: 2 });
createHexGrid({ shape: 'rectangle', columns: 10, rows: 6 });
createHexGrid({ shape: 'parallelogram', columns: 10, rows: 6 });
createHexGrid({
shape: 'custom',
coordinates: [
{ q: 0, r: 0, s: 0 },
{ q: 1, r: 0, s: -1 },
{ q: 1, r: -1, s: 0 }
]
});You do not have to generate a complete board. Use shape: 'custom' when your app only needs specific cells:
const grid = createHexGrid({
shape: 'custom',
cellSize: 32,
coordinates: [
{ q: 0, r: 0, s: 0 },
{ q: 1, r: -1, s: 0 },
{ q: 1, r: 0, s: -1 },
{ q: 0, r: 1, s: -1 },
{ q: -1, r: 1, s: 0 }
]
});This is useful for irregular maps, unlocked areas, tactical boards, level editors and dashboards where only part of the hex space exists. Neighbor, ring, range and line helpers automatically return only cells that are present in the generated grid when you call them through the grid instance:
grid.neighborsOf('0,0,0'); // existing neighboring cells only
grid.range('0,0,0', 2); // existing cells inside the range onlyAttach app-specific data when you want fills, labels, click handlers or framework components to read terrain, cost, ownership or any other domain state from the cell itself.
const grid = createHexGrid({
shape: 'custom',
coordinates: [
{ q: 0, r: 0, s: 0, data: { terrain: 'castle', cost: 1 } },
{ q: 1, r: -1, s: 0, data: { terrain: 'forest', cost: 2 } }
]
});
const svg = grid.toSvg({
cellFill(cell) {
return cell.data?.terrain === 'forest' ? '#86efac' : '#dbeafe';
},
renderLabel(cell) {
return String(cell.data?.cost ?? '');
}
});Generated shapes can also compute data from each coordinate:
createHexGrid({
shape: 'hexagon',
radius: 4,
data(coord) {
return { zone: coord.s === 0 ? 'road' : 'plain' };
}
});The public API is cube-first:
type HexCoordinateInput = {
q: number;
r: number;
s?: number;
};Canonical cell IDs use q,r,s, for example 0,0,0 or 1,-1,0. When s is omitted, it is computed as -q - r, so { q: 1, r: -1 } remains a convenient shorthand for { q: 1, r: -1, s: 0 }. If you pass s, the package validates that q + r + s === 0.
Useful helpers:
import {
cubeToPixel,
pixelToCube,
formatHexCoord,
getHexNeighbors,
hexDistance,
hexLine,
hexRange,
hexRing
} from 'hex-grid-kit';By default, SVG output is responsive: the grid keeps its internal cellSize geometry, but the browser can scale the SVG visually through CSS.
Use svgSize: 'fixed' when the rendered SVG should keep the intrinsic size derived from cellSize, spacing, grid shape and padding:
const grid = createHexGrid({
shape: 'hexagon',
radius: 4,
cellSize: 32
});
const svg = grid.toSvg({
svgSize: 'fixed'
});This writes width and height attributes on the SVG. The total rendered size comes from grid.bounds + padding, so a larger board takes more space instead of shrinking cells to fit the container.
When rendering your own SVG, use the helpers instead of rebuilding the viewBox calculation:
import { getHexGridSvgMetrics, getHexGridViewBox } from 'hex-grid-kit';
const viewBox = getHexGridViewBox(grid);
const { width, height } = getHexGridSvgMetrics(grid);By default, rendered SVG includes small built-in styles. Disable them when you want full control:
const svg = grid.toSvg({
includeStyles: false,
classPrefix: 'game-board',
selectedIds: ['0,0,0']
});The generated SVG uses predictable classes:
.hex-grid__cell.hex-grid__cell--selected.hex-grid__cell--highlighted.hex-grid__cell--disabled.hex-grid__label
Use cellFill when each cell needs its own terrain, state or visual marker:
const svg = grid.toSvg({
cellFill(cell) {
if (cell.id === '0,0,0') {
return { type: 'image', href: '/tiles/castle.png' };
}
if (cell.id === '1,0,-1') {
return '#86efac';
}
return undefined;
}
});String returns are treated as color fills. Image fills generate the SVG <defs><pattern /></defs> automatically and use fill="url(#...)" on the matching hexagon.
grid.toSvg({
cellFill(cell) {
return {
type: 'image',
href: `/tiles/${cell.id}.png`,
preserveAspectRatio: 'xMidYMid slice'
};
}
});Return { type: 'none' } when a specific cell should be transparent.
Labels, titles and attributes rendered through renderHexGridSvg are escaped. The package does not evaluate user-provided markup.
The GitHub Actions workflow runs on Node.js 20 and 22:
npm cinpm run typechecknpm testnpm run build
MPL-2.0