Layout

Grid

A grid of fixed columns, or as many as fit at a minimum width, with token gaps and spanning items.

Built on
CSS gridauto-fitContainer queries
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.8 kB
Dependencies
React
Revenue
Churn
Seats
MRR
NPS
Try its options

Loading its options.

Code
import { Grid, GridItem } from "@/components/equip/grid";

const cell = {
  display: "grid",
  placeItems: "center",
  minHeight: 36,
  borderRadius: "var(--eq-radius-s)",
  background: "var(--eq-tint-strong)",
  color: "var(--eq-ink-2)",
  fontSize: "var(--eq-text-xs)",
};

export default function Demo() {
  return (
    <Grid columns={3} gap={2} style={{ width: 208 }}>
      <GridItem span={2} style={cell}>Revenue</GridItem>
      <GridItem style={cell}>Churn</GridItem>
      <GridItem style={cell}>Seats</GridItem>
      <GridItem style={cell}>MRR</GridItem>
      <GridItem style={cell}>NPS</GridItem>
    </Grid>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div class="eq-grid" style="--_columns:3;width:208px" data-mode="fixed" data-gap="2">
  <div class="eq-grid-item" style="--_span:2;display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)" data-span="n">Revenue</div>
  <div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">Churn</div>
  <div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">Seats</div>
  <div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">MRR</div>
  <div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">NPS</div>
</div>

Examples

A product grid

min="10rem" fits as many columns as the space allows, from one on a phone to five on a wide screen, with no media query.

  • Linen overshirt
    €89
  • Merino crew
    €120
  • Canvas tote
    €35
  • Wool beanie
    €29
  • Cord trousers
    €95
  • Rain shell
    €160
Code
import { Grid, GridItem } from "@/components/equip/grid";

const products = [
  { name: "Linen overshirt", price: "€89" },
  { name: "Merino crew", price: "€120" },
  { name: "Canvas tote", price: "€35" },
  { name: "Wool beanie", price: "€29" },
  { name: "Cord trousers", price: "€95" },
  { name: "Rain shell", price: "€160" },
];

export default function Example() {
  return (
    <Grid as="ul" min="10rem" gap={4} rowGap={5} style={{ width: "100%" }}>
      {products.map((product) => (
        <GridItem as="li" key={product.name}>
          <div
            style={{
              aspectRatio: "1",
              marginBottom: 8,
              borderRadius: "var(--eq-radius-m)",
              background: "var(--eq-tint-strong)",
            }}
          />
          <div style={{ fontSize: "var(--eq-text-m)", fontWeight: "var(--eq-weight-strong)" }}>{product.name}</div>
          <div style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>{product.price}</div>
        </GridItem>
      ))}
    </Grid>
  );
}
HTMLworks in any framework, with the part's CSS
<ul class="eq-grid" style="--_min:10rem;width:100%" data-mode="fit" data-gap="4" data-row-gap="5">
  <li class="eq-grid-item">
    <div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
    <div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Linen overshirt</div>
    <div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€89</div>
  </li>
  <li class="eq-grid-item">
    <div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
    <div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Merino crew</div>
    <div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€120</div>
  </li>
  <li class="eq-grid-item">
    <div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
    <div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Canvas tote</div>
    <div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€35</div>
  </li>
  <li class="eq-grid-item">
    <div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
    <div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Wool beanie</div>
    <div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€29</div>
  </li>
  <li class="eq-grid-item">
    <div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
    <div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Cord trousers</div>
    <div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€95</div>
  </li>
  <li class="eq-grid-item">
    <div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
    <div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Rain shell</div>
    <div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€160</div>
  </li>
</ul>

A dashboard with spans

columns=4 with min caps the grid at four columns; below 32rem the spanning panel runs full width instead of making columns the grid does not have.

Active users
18,204
Conversion
3.8%
Refunds
12
Uptime
99.98%
Signups this week
Top plan
Team, 61% of seats
Code
import { Grid, GridItem } from "@/components/equip/grid";

const panel = {
  padding: 12,
  border: "1px solid var(--eq-line)",
  borderRadius: "var(--eq-radius-m)",
  background: "var(--eq-surface)",
};

const stats = [
  ["Active users", "18,204"],
  ["Conversion", "3.8%"],
  ["Refunds", "12"],
  ["Uptime", "99.98%"],
];

export default function Example() {
  return (
    <Grid columns={4} min="9rem" gap={3} style={{ width: "100%" }}>
      {stats.map(([label, value]) => (
        <GridItem key={label} style={panel}>
          <div style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{label}</div>
          <div style={{ fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}>{value}</div>
        </GridItem>
      ))}
      <GridItem span={3} style={{ ...panel, minHeight: 120 }}>
        <div style={{ fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" }}>Signups this week</div>
        <div style={{ height: 72, marginTop: 8, borderRadius: "var(--eq-radius-s)", background: "var(--eq-tint)" }} />
      </GridItem>
      <GridItem style={panel}>
        <div style={{ fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" }}>Top plan</div>
        <div style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Team, 61% of seats</div>
      </GridItem>
    </Grid>
  );
}
HTMLworks in any framework, with the part's CSS
<div class="eq-grid" style="--_columns:4;--_min:9rem;width:100%" data-mode="fit" data-gap="3">
  <div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
    <div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Active users</div>
    <div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">18,204</div>
  </div>
  <div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
    <div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Conversion</div>
    <div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">3.8%</div>
  </div>
  <div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
    <div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Refunds</div>
    <div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">12</div>
  </div>
  <div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
    <div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Uptime</div>
    <div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">99.98%</div>
  </div>
  <div class="eq-grid-item" style="--_span:3;padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface);min-height:120px" data-span="n">
    <div style="font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Signups this week</div>
    <div style="height:72px;margin-top:8px;border-radius:var(--eq-radius-s);background:var(--eq-tint)"></div>
  </div>
  <div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
    <div style="font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Top plan</div>
    <div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">Team, 61% of seats</div>
  </div>
</div>

Fixed columns and a full row

Fixed columns use minmax(0, 1fr), so long content wraps inside its cell instead of widening the grid.

First name
Last name
Billing address
Code
import { Grid, GridItem } from "@/components/equip/grid";

const field = { display: "grid", gap: 4, fontSize: "var(--eq-text-s)" };
const box = {
  height: 30,
  border: "1px solid var(--eq-line-strong)",
  borderRadius: "var(--eq-radius-m)",
  background: "var(--eq-raised)",
};

export default function Example() {
  return (
    <Grid columns={2} gap={3} style={{ width: "100%", maxWidth: 480 }}>
      <GridItem style={field}>
        First name
        <div style={box} />
      </GridItem>
      <GridItem style={field}>
        Last name
        <div style={box} />
      </GridItem>
      <GridItem span="full" style={field}>
        Billing address
        <div style={box} />
      </GridItem>
    </Grid>
  );
}
HTMLworks in any framework, with the part's CSS
<div class="eq-grid" style="--_columns:2;width:100%;max-width:480px" data-mode="fixed" data-gap="3">
  <div class="eq-grid-item" style="display:grid;gap:4px;font-size:var(--eq-text-s)">First name<div style="height:30px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised)"></div></div>
  <div class="eq-grid-item" style="display:grid;gap:4px;font-size:var(--eq-text-s)">Last name<div style="height:30px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised)"></div></div>
  <div class="eq-grid-item" style="display:grid;gap:4px;font-size:var(--eq-text-s)" data-span="full">Billing address<div style="height:30px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised)"></div></div>
</div>

Props

PropTypeDefaultWhat it does
as"div" | "section" | "ul" | "ol""div"The root element. Use ul or ol with GridItem as="li".
columnsnumber1Fixed equal columns. With min as well, the most columns there will be.
minstringnoneAuto-fit: as many columns as fit, each at least this wide, e.g. "14rem". Never wider than the grid.
gap0 | 1 | 2 | 3 | 4 | 5 | 64Space between cells, from --eq-space-1 to --eq-space-6.
rowGap0 | 1 | 2 | 3 | 4 | 5 | 6noneSpace between rows when it should differ from gap.
align"start" | "center" | "end" | "stretch"noneBlock alignment of cells within their row.
...restComponentProps<"div">noneEvery native attribute of the root element.
GridItemas?, span?: number | "full", rowSpan?: numbernoneA cell that covers several columns or rows. full runs edge to edge.

Anatomy

root.eq-gridThe grid and the size container its items query. data-mode is fixed or fit.
item.eq-grid-itemOnly needed for a cell that spans.

Notes

  • Renders on the server with no client JavaScript.
  • Cells are placed in DOM order and never reordered, so reading and tab order follow what people see.
  • Responsive by its own width rather than the viewport, so the same grid works in a page, a sidebar or a dialog.
  • In an auto-fit grid narrower than 32rem a spanning item runs the full width. Wider than that, a span larger than the columns that fit adds an empty track rather than widening the grid; use span="full" for an item that should always run edge to edge.
  • Fixed columns are minmax(0, 1fr), so long words wrap inside a cell instead of widening the grid.
  • The root is a size container, so give it a width from its parent; it does not grow to fit its content.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/grid.tsx79 lines
import type { ComponentProps, CSSProperties } from "react";
import "./grid.css";

type Space = 0 | 1 | 2 | 3 | 4 | 5 | 6;

export type GridProps = ComponentProps<"div"> & {
  /** The element at the root. Use ul or ol when the children are li. */
  as?: "div" | "section" | "ul" | "ol";
  /**
   * A fixed number of equal columns. With min as well, it is the most
   * columns there will be: the grid drops columns when they would get narrower than min.
   */
  columns?: number;
  /** Auto-fit: as many columns as fit, each at least this wide. Any CSS length, e.g. "14rem". */
  min?: string;
  /** Space between cells, read from --eq-space-1 to --eq-space-6. */
  gap?: Space;
  /** Space between rows when it should differ from gap. */
  rowGap?: Space;
  /** Block alignment of the cells inside their row. */
  align?: "start" | "center" | "end" | "stretch";
};

export function Grid({
  as = "div",
  columns,
  min,
  gap = 4,
  rowGap,
  align,
  className,
  style,
  ...rest
}: GridProps) {
  // The root changes element but never its attributes, so the div typing holds for all four.
  const Root = as as "div";
  const vars: Record<string, string | number> = {};
  if (columns != null) vars["--_columns"] = columns;
  if (min != null) vars["--_min"] = min;
  const mode = min != null ? "fit" : "fixed";

  return (
    <Root
      {...rest}
      className={["eq-grid", className].filter(Boolean).join(" ")}
      style={{ ...(vars as CSSProperties), ...style }}
      data-mode={mode}
      data-gap={gap}
      data-row-gap={rowGap}
      data-align={align}
    />
  );
}

export type GridItemProps = ComponentProps<"div"> & {
  /** The element at the root. li inside a ul or ol grid. */
  as?: "div" | "li" | "article" | "section";
  /** Columns to cover. "full" runs edge to edge. Falls back to one column when the grid is narrow. */
  span?: number | "full";
  /** Rows to cover. */
  rowSpan?: number;
};

export function GridItem({ as = "div", span, rowSpan, className, style, ...rest }: GridItemProps) {
  const Root = as as "div";
  const vars: Record<string, string | number> = {};
  if (typeof span === "number") vars["--_span"] = span;
  if (rowSpan != null) vars["--_row-span"] = rowSpan;

  return (
    <Root
      {...rest}
      className={["eq-grid-item", className].filter(Boolean).join(" ")}
      style={{ ...(vars as CSSProperties), ...style }}
      data-span={span == null ? undefined : span === "full" ? "full" : "n"}
      data-row-span={rowSpan == null ? undefined : true}
    />
  );
}
components/equip/grid.css82 lines
@layer equip {
  .eq-grid {
    --_gap: var(--eq-space-4);
    --_row-gap: var(--_gap);

    box-sizing: border-box;
    display: grid;
    grid-template-columns: repeat(var(--_columns, 1), minmax(0, 1fr));
    gap: var(--_row-gap) var(--_gap);
    min-width: 0;
    margin: 0;
    padding: 0;
    list-style: none;
    /* The grid is the container its items query, so spans can give way when it is narrow. */
    container: eq-grid / inline-size;
  }

  /* Auto-fit: every column at least --_min wide, never wider than the grid itself,
     and never more of them than --_columns when that is set. */
  .eq-grid[data-mode="fit"] {
    grid-template-columns: repeat(
      auto-fit,
      minmax(
        max(
          min(var(--_min), 100%),
          (100% - (var(--_columns, 64) - 1) * var(--_gap)) / var(--_columns, 64)
        ),
        1fr
      )
    );
  }

  .eq-grid[data-gap="0"] { --_gap: 0px; }
  .eq-grid[data-gap="1"] { --_gap: var(--eq-space-1); }
  .eq-grid[data-gap="2"] { --_gap: var(--eq-space-2); }
  .eq-grid[data-gap="3"] { --_gap: var(--eq-space-3); }
  .eq-grid[data-gap="4"] { --_gap: var(--eq-space-4); }
  .eq-grid[data-gap="5"] { --_gap: var(--eq-space-5); }
  .eq-grid[data-gap="6"] { --_gap: var(--eq-space-6); }

  .eq-grid[data-row-gap="0"] { --_row-gap: 0px; }
  .eq-grid[data-row-gap="1"] { --_row-gap: var(--eq-space-1); }
  .eq-grid[data-row-gap="2"] { --_row-gap: var(--eq-space-2); }
  .eq-grid[data-row-gap="3"] { --_row-gap: var(--eq-space-3); }
  .eq-grid[data-row-gap="4"] { --_row-gap: var(--eq-space-4); }
  .eq-grid[data-row-gap="5"] { --_row-gap: var(--eq-space-5); }
  .eq-grid[data-row-gap="6"] { --_row-gap: var(--eq-space-6); }

  .eq-grid[data-align="start"] { align-items: start; }
  .eq-grid[data-align="center"] { align-items: center; }
  .eq-grid[data-align="end"] { align-items: end; }
  .eq-grid[data-align="stretch"] { align-items: stretch; }

  .eq-grid-item {
    box-sizing: border-box;
    min-width: 0;
  }

  .eq-grid-item[data-span="n"] {
    grid-column: span var(--_span);
  }

  .eq-grid-item[data-span="full"] {
    grid-column: 1 / -1;
  }

  .eq-grid-item[data-row-span] {
    grid-row: span var(--_row-span);
  }

  /* An auto-fit grid may be down to one or two columns when narrow, and a span would make
     columns it does not have, so a spanning item runs edge to edge instead. */
  @container eq-grid (width < 32rem) {
    .eq-grid[data-mode="fit"] > .eq-grid-item[data-span="n"] {
      grid-column: 1 / -1;
    }

    .eq-grid[data-mode="fit"] > .eq-grid-item[data-row-span] {
      grid-row: auto;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index