DEV Community

SEN LLC
SEN LLC

Posted on

A Visual CSS Grid Builder in 500 Lines — How fr, minmax, and repeat Actually Compute Sizes

Almost every developer can copy grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); and have it "work." Fewer can say why it works. I built a 500-line vanilla JS visual editor for grid-template-* — drag-free, click to add tracks, real CSS Grid renders the preview. No build step, no chart lib, no Tailwind. The same generator feeds both the preview and the copy-out, so what you see is literally what you'd paste.

🌐 Demo: https://sen.ltd/portfolio/css-grid-builder/
📦 GitHub: https://github.com/sen-ltd/css-grid-builder

Screenshot

Generator ⟂ UI

grid.js     ← normalizeTrack / renderTracks / buildGridCSS / validateTrack (no DOM)
presets.js  ← 7 layout configs (holy grail / sidebar / responsive / etc.)
app.js      ← UI glue: track rows, preset switch, <style> tag injection
Enter fullscreen mode Exit fullscreen mode

grid.js has neither document nor <style>. It takes a { columns, rows, gap, justifyItems, alignItems } config and returns a CSS string. 22 unit tests cover every branch under node --test.

Collapse equal row-gap / column-gap into gap:

Naively emitting row-gap: 12px; column-gap: 12px; works, but a human reviewing the copied CSS will rewrite it as gap: 12px;. So emit it that way upfront:

const gap = config.gap || {};
if (gap.row && gap.column && gap.row === gap.column) {
  lines.push(`  gap: ${normalizeTrack(gap.row)};`);
} else {
  if (gap.row)    lines.push(`  row-gap: ${normalizeTrack(gap.row)};`);
  if (gap.column) lines.push(`  column-gap: ${normalizeTrack(gap.column)};`);
}
Enter fullscreen mode Exit fullscreen mode

The rule: match what a human would write. That's the quality bar for any "copy this CSS" tool.

The same logic kills justify-items: stretch and align-items: stretch from the output (they're the defaults):

if (config.justifyItems && config.justifyItems !== "stretch") {
  lines.push(`  justify-items: ${config.justifyItems};`);
}
Enter fullscreen mode Exit fullscreen mode

The user can pick "stretch" in the UI, the copied CSS doesn't carry a no-op. Omit defaults → output looks idiomatic.

Track validation without writing a CSS parser

CSS Grid tracks are a syntax jungle: 1fr, 200px, 50%, auto, min-content, max-content, minmax(100px, 1fr), repeat(3, 1fr), repeat(auto-fit, minmax(140px, 1fr)), fit-content(200px).

The validator strategy: recognise the shape, defer the inside to the browser.

export function validateTrack(value) {
  const t = normalizeTrack(value);
  if (t === "") return null;
  if (/^(auto|min-content|max-content)$/.test(t)) return null;
  if (/^\d+(\.\d+)?(fr|px|%|em|rem|vh|vw|ch)$/.test(t)) return null;
  if (/^minmax\([^)]+\)$/i.test(t)) return null;
  if (/^repeat\([^)]+\)$/i.test(t)) return null;
  if (/^fit-content\([^)]+\)$/i.test(t)) return null;
  return `unrecognized track: ${t}`;
}
Enter fullscreen mode Exit fullscreen mode

Implementation cost: 1/10 of a real CSS parser. If the user types minmax(banana, 1fr), the validator accepts the shape, the browser ignores the broken style, the preview just doesn't render that track. The tool doesn't crash, and the user immediately sees that their typo isn't working.

What fr actually computes

Type grid-template-columns: 200px 1fr 200px into the tool. The middle cell expands to fill container_width - 400px. fr is "share of the leftover space", divided by total fr units.

container width: 1000px
fixed:           200px + 200px = 400px
leftover:        600px
1fr =            600px / 1 (total fr units) = 600px
Enter fullscreen mode Exit fullscreen mode

With 1fr 2fr 1fr, total is 1+2+1 = 4. So 1fr = 250px, 2fr = 500px.

The reason fr beats %: fr is computed after fixed sizes AND gaps are subtracted. % is a flat ratio against the parent, so any fixed column or gap throws the total over 100%:

/* Breaks at any container size — 200+50%+200 > 100% */
grid-template-columns: 200px 50% 200px;

/* Works at every container size */
grid-template-columns: 200px 1fr 200px;
Enter fullscreen mode Exit fullscreen mode

Once you internalise fr, the entire "sidebar + flexible content" family of layouts collapses to one line. The Holy Grail preset in the tool is literally that.

repeat(auto-fit, minmax(...)) — responsive without media queries

repeat(auto-fit, minmax(140px, 1fr)) in one line gives you:

  • Minimum cell width: 140px
  • Grow to share extra space (1fr)
  • Column count adjusts to container width automatically

This is "intrinsically responsive grid." No media queries. The browser:

  1. Computes floor(container_width / 140px) → the maximum column count
  2. Distributes leftover space with 1fr across those columns

So at 600px container width: floor(600/140) = 4 columns, each (600 - 3*gap) / 4 wide. At 850px: 6 columns. Bumping the container width pops a new column in without a single line of conditional CSS.

The auto-fit vs auto-fill distinction also makes sense once you see it move: auto-fill keeps empty tracks reserved, leaving blank space if you have fewer items than columns. auto-fit collapses empty tracks, stretching the populated cells to fill. Most "gallery grid" needs want auto-fit.

Preview / output convergence via <style> injection

const liveStyle = document.createElement("style");
liveStyle.id = "live-grid";
document.head.appendChild(liveStyle);

function applyGridCSS() {
  liveStyle.textContent = buildGridCSS(state, "#preview");
  $("output").textContent = buildGridCSS(state, ".grid");
}
Enter fullscreen mode Exit fullscreen mode

Two buildGridCSS() calls, differing only by selector (#preview vs .grid). The preview's CSS comes from the same generator the user copies — so it's structurally impossible for the displayed layout and the copy-out to disagree.

This is the eat-your-own-dog-food bit. The opposite design — a preview that touches element.style.gridTemplateColumns directly — would slowly drift from the copy output. Tools with that drift get one-star "the copied code doesn't work in my actual project" complaints. One generator, two consumers, zero drift.

Try it

Pick the "Responsive auto-fit" preset, resize your browser, watch columns appear and disappear. Then copy the CSS — it's the same repeat(auto-fit, minmax(140px, 1fr)) one-liner that's been doing all the work.

Takeaways

  • Omit defaults (gap collapse, drop stretch alignment) so the copy-out looks idiomatic, not machine-emitted.
  • Validate track shape, not contents. Regex the outer minmax(...) / repeat(...) envelope and let the browser deal with the inside. 1/10 of the implementation cost.
  • fr computes after fixed sizes and gaps are subtracted — that's why it never overflows the way % does in mixed layouts.
  • repeat(auto-fit, minmax(N, 1fr)) is the responsive-grid one-liner. floor(container / N) columns, 1fr to distribute, no media queries.
  • One generator, two consumers (preview ⊕ copy-out) makes "displayed CSS ≠ copied CSS" a structural impossibility.

This is OSS portfolio #246 from SEN LLC (Tokyo). We ship small, sharp tools continuously: https://sen.ltd/portfolio/

Top comments (0)