CSS Grid Builder

A visual CSS Grid editor: columns and rows set in fr units, items placed with the mouse or keyboard and snapped to the grid lines, exported as HTML, CSS or Tailwind classes.

Overview

Writing CSS Grid is simple as long as every column is 1fr. As soon as tracks have different weights, it gets hard to see where each line falls and which grid-column to give a block. I built CSS Grid Builder to compose these grids with the mouse. You choose from 1 to 24 columns and rows, set the fr weight of each track in fields placed above and to the left of the surface, place items that snap to the lines, then get the code as HTML, CSS or Tailwind classes. It is a React 18 and TypeScript application built with Vite, running without a server, with its state managed by Zustand.

One calculation for the surface, the guides and the fields

The core of the project is src/lib/unifiedGridEngine.ts. The computeUnifiedTracks function removes the padding from the container size, splits the remaining space in proportion to the fr weights, then adds up the widths to get the position of each grid line (columnLines, rowLines). The surface, the row of fields at the top and the column of fields on the left use these same tracks, and PerfectGridOverlay draws a bordered box for each cell in the same place. They all go through the same Math.round, which keeps a guide and an item from landing on two neighbouring pixels.

Column 6, with a weight of 2, is twice as wide in the row of fields and on the surface, and each item shows the lines it spans.

The repository notes (REFONTE-v2.0.md, FIX-GAP-ALIGNMENT.md) explain this choice. In the first versions, the header, the surface and the guides each computed their own positions, and the space between items was subtracted from the available width: changing the gap shrank the columns and shifted everything. The current engine computes the tracks without taking the gap into account.

A gap that does not move the lines

To keep the guides aligned, the surface keeps its gap property at 0. In UnifiedGridCanvas.tsx, each item is shrunk by the gap value and given a margin of half that value:

style={{
  gridColumn: `${item.startCol} / ${item.endCol}`,
  gridRow: `${item.startRow} / ${item.endRow}`,
  width: `calc(100% - ${gapItems}px)`,
  height: `calc(100% - ${gapItems}px)`,
  margin: `${gapItems / 2}px`,
  boxSizing: 'border-box',
}}

The lines therefore never move when the spacing changes. In the last commit, I chose not to export this trick. The unifiedCodeGenerator.ts generator used to produce pixel tracks and calc() values on every item. It now writes fr tracks and a native gap, code close to what you would write by hand. The trade-off: in the preview, the margin also creates a half-gap on the outer edges, which the exported CSS does not.

Drag, resize, snap

As long as the mouse has not moved more than 3 pixels (DRAG_THRESHOLD), a click stays a selection. Beyond that, mouseToGridPosition removes the padding and looks for the nearest line with pixelToGridLine, which also works with tracks of different widths. snapItemToGrid then rounds the bounds, keeps them inside the grid and makes sure an item covers at least one track.

A selected item shows eight handles named like compass points (nw, n, ne, etc.). Rather than handling eight cases, the code tests the letters of the name: handle.includes('w') moves the left edge, handle.includes('s') the bottom edge, and a corner combines both. On the keyboard, the arrow keys move the item by one track and Shift with the arrows resizes it. A locked item ignores both.

An editor, not just a preview

The src/store/gridStore.ts store keeps a history of 50 snapshots (MAX_HISTORY), browsed with Ctrl+Z and Ctrl+Shift+Z. During a drag, the snapshot is only taken on release, and only if the mouse went past that 3-pixel threshold. Changing the number of columns keeps the weights already entered. The editor also lets you lock, duplicate and rename items, save a project as JSON, start from three templates (Dashboard, Portfolio, Landing Page) and apply preset widths, from 360 to 1280 px. The code can be copied or downloaded as grid-layout.css or as a standalone HTML page. On load, matchMedia('(prefers-color-scheme: dark)') picks the theme, which a button can switch.

The full editor: open, save, history and templates at the top, settings and item list on the left, surface in the centre, generated code on the right with copy, the two exports and the keyboard shortcuts.

What I would rework

The preview works on a fixed surface 600 px high, and 800 px wide as soon as the width is a percentage. It does not show how a fluid container behaves.

The history records the state after the move, not before. According to the code, the first Ctrl+Z after a move therefore does not put the item back where it was.

While simplifying the generator, the Tailwind export lost the column and row templates as well as the placement classes. That is the first thing to fix.

Some controls exist only in the interface. The breakpoint selector is stored, but the responsive field of GridItem is never filled in or exported. The "Gap" field (config.gap) is used neither by the preview nor by the export.

Finally, gridEngine.test.ts imports a ./gridEngine module that is not in the repository, and no test runner is installed. The functions in unifiedGridEngine.ts are pure and would be easy to test with Vitest. Several Markdown notes at the root also describe intermediate states, such as an SVG overlay or a GridCanvasV2 component, that no longer match the code.