shshadcn-htmx

Components

Auto Grid

A responsive, intrinsically-wrapping grid of equal cells with no breakpoints. Children flow into as many columns as fit at a configurable minimum item width, then grow to share the leftover space — the card-grid recipe, built on CSS Grid's repeat(auto-fit, minmax()) (the "RAM" pattern). Pure CSS, zero JavaScript.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/auto-grid.json

2. Use it

components/ui/auto-grid.tsx
import { AutoGrid } from "@/components/ui/auto-grid"

// Card grid — as many 16rem columns as fit, no breakpoints.
<AutoGrid>
  <div>…</div>
  <div>…</div>
</AutoGrid>

// Wider items, larger gap, semantic list.
<AutoGrid as="ul" min="20rem" gap={6}>
  <li>…</li>
</AutoGrid>

// Keep empty trailing tracks (auto-fill) instead of stretching.
<AutoGrid fill min="12rem">
  <div>…</div>
</AutoGrid>
Or copy the source manually
components/ui/auto-grid.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Auto Grid — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A responsive, intrinsically-wrapping grid of equal cells with NO media
// queries: children flow into as many columns as fit at a configurable
// minimum item width, then grow to share the leftover space. This is the
// "RAM" pattern (Repeat, Auto, Minmax) — the card-grid recipe most people
// reach for. Pure CSS Grid; zero JavaScript.
//
// Built on (read before editing):
//   repos/web.dev/src/site/content/en/patterns/layout/repeat-auto-minmax/index.md
//     — the canonical recipe: `grid-template-columns: repeat(auto-fit,
//       minmax(150px, 1fr))`. auto-fit collapses empty tracks so filled
//       tracks grow; auto-fill keeps empty tracks (their width reserved).
//   repos/web.dev/src/site/content/en/learn/css/grid/index.md:295-389
//     — `minmax(0, 1fr)` forces equal share minus gaps; the
//       `auto-fill`/`auto-fit` keywords create "as many tracks as will fit"
//       with no media queries; the subtle auto-fill vs auto-fit difference.
//   repos/mdn/files/en-us/web/css/minmax/index.md (minmax function)
//   repos/mdn/files/en-us/web/css/min/index.md
//     — `min(var(--auto-grid-min), 100%)` guards the lower bound so a single
//       wide item can never overflow a container narrower than the min.
//
// shadcn/ui has no Auto Grid (React libraries leave layout to the consumer),
// so there is no class string to mirror — this is a layout primitive.
//
// API shape:
//   - `min`  : the per-item minimum width (any CSS length). Drives how many
//              columns fit. Default "16rem".
//   - `gap`  : the Tailwind gap step (number → gap-<n>) or a class. Default 4.
//   - `fill` : false (default) uses auto-fit — empty tracks collapse and
//              real items stretch to fill the row. true uses auto-fill —
//              empty tracks are kept, so a half-empty last row stays aligned
//              to the column rhythm rather than stretching.
//   - `as`   : the element/role. Default <div>. Use "ul"/"ol" for a list of
//              cards (each child should then be an <li>), or "section".
//
// We publish `--auto-grid-min` on the root and read it in an arbitrary
// `grid-template-columns` utility, exactly like RangeSlider publishes
// `--range-min`/`--range-max`. No runtime; the browser does the layout.

export type AutoGridAs = "div" | "ul" | "ol" | "section"

// repeat(auto-fit|auto-fill, minmax(min(var(--auto-grid-min), 100%), 1fr)).
// The min() guard means: never demand more than the container width, so one
// item in a too-narrow container shrinks instead of forcing a scrollbar.
const FIT_CLASS =
  "grid [grid-template-columns:repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))]"
const FILL_CLASS =
  "grid [grid-template-columns:repeat(auto-fill,minmax(min(var(--auto-grid-min,16rem),100%),1fr))]"

// Gap presets so callers can pass a plain number; any other value (string)
// is treated as an explicit class and appended verbatim.
const GAP_CLASS: Record<number, string> = {
  0: "gap-0",
  1: "gap-1",
  2: "gap-2",
  3: "gap-3",
  4: "gap-4",
  5: "gap-5",
  6: "gap-6",
  8: "gap-8",
  10: "gap-10",
  12: "gap-12",
}

type AutoGridProps = PropsWithChildren<{
  // Minimum per-item width. Any CSS length ("16rem", "200px", "20ch").
  min?: string
  // Gap between cells: a number maps to gap-<n>; a string is used verbatim.
  gap?: number | string
  // auto-fill (keep empty tracks) instead of auto-fit (collapse them).
  fill?: boolean
  // Semantic element / role. "ul"/"ol" for a card list.
  as?: AutoGridAs
  ariaLabel?: string
  ariaLabelledby?: string
  class?: ClassValue
  id?: string
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function AutoGrid(props: AutoGridProps) {
  const {
    min = "16rem",
    gap = 4,
    fill = false,
    as = "div",
    ariaLabel,
    ariaLabelledby,
    class: className,
    children,
    ...rest
  } = props as any
  const Tag: any = as
  const gapClass = typeof gap === "number" ? (GAP_CLASS[gap] ?? "gap-4") : gap
  return (
    <Tag
      data-slot="auto-grid"
      data-fill={fill ? "true" : undefined}
      // Publish the per-item minimum; the grid-template-columns utility reads
      // it. Keeping it a custom property means callers tune density without
      // touching the (uncompilable-at-runtime) arbitrary class.
      style={`--auto-grid-min:${min}`}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      class={cn(fill ? FILL_CLASS : FIT_CLASS, gapClass, className)}
      {...rest}
    >
      {children}
    </Tag>
  )
}

1. Save the file

Copy auto-grid.html into templates/components/.

2. Use it

templates/components/auto-grid.html
{% from "components/auto-grid.html" import auto_grid %}

{% call auto_grid() %}
  <div>…</div>
{% endcall %}

{% call auto_grid(min="20rem", gap="gap-6", tag="ul") %}
  <li>…</li>
{% endcall %}
View source
templates/components/auto-grid.html
{# Auto Grid macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/auto-grid.tsx.

   A responsive, intrinsically-wrapping grid of equal cells with no media
   queries (the RAM pattern: repeat, auto-fit/auto-fill, minmax). Pure CSS.

   Usage:
     {% from "components/auto-grid.html" import auto_grid %}

     {% call auto_grid() %}                      {# default: 16rem min, gap-4 #}
       <div>…card…</div>
     {% endcall %}

     {% call auto_grid(min="20rem", gap="gap-6", fill=true, tag="ul") %}
       <li>…</li>
     {% endcall %}

   Args:
     min       per-item minimum width (any CSS length). Default "16rem".
     gap       a gap-* class (string). Default "gap-4".
     fill      true → auto-fill (keep empty tracks); false → auto-fit. Default false.
     tag       div | ul | ol | section. Default "div".
     attrs     dict of extra attributes (hx-*, data-*, aria-*). #}

{% macro auto_grid(
    min="16rem",
    gap="gap-4",
    fill=false,
    tag="div",
    aria_label=none,
    aria_labelledby=none,
    extra_class="",
    attrs={}
) %}
{%- set tracks -%}
{% if fill %}repeat(auto-fill,minmax(min(var(--auto-grid-min,16rem),100%),1fr)){% else %}repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr)){% endif %}
{%- endset -%}
<{{ tag }}
  data-slot="auto-grid"
  {%- if fill %} data-fill="true"{% endif %}
  style="--auto-grid-min:{{ min }}"
  {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
  {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
  {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
  class="grid [grid-template-columns:{{ tracks }}] {{ gap }} {{ extra_class }}">
  {{ caller() }}
</{{ tag }}>
{% endmacro %}

1. Save the file

Add auto-grid.tmpl alongside your templates.

2. Use it

components/auto-grid.tmpl
{{template "auto-grid" (dict "Body" (htmlSafe $cards))}}
{{template "auto-grid" (dict "Min" "20rem" "Gap" "gap-6" "Tag" "ul" "Body" (htmlSafe $items))}}
{{template "auto-grid" (dict "Fill" true "Min" "12rem" "Body" (htmlSafe $cells))}}
View source
components/auto-grid.tmpl
{{/*
  Auto Grid template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/auto-grid.tsx.

  A responsive, intrinsically-wrapping grid of equal cells with no media
  queries (the RAM pattern: repeat, auto-fit/auto-fill, minmax). Pure CSS.

      type AutoGridArgs struct {
          Min   string // per-item minimum width (any CSS length); default "16rem"
          Gap   string // a gap-* class; default "gap-4"
          Fill  bool   // true = auto-fill (keep empty tracks); false = auto-fit
          Tag   string // div | ul | ol | section; default "div"
          Class string // extra classes appended to the root
          Body  string // inner HTML (use htmlSafe)
      }

  Usage:
      {{template "auto-grid" (dict "Body" (htmlSafe $cards))}}
      {{template "auto-grid" (dict "Min" "20rem" "Gap" "gap-6" "Fill" true "Tag" "ul" "Body" (htmlSafe $items))}}
*/}}

{{define "auto-grid"}}
{{- $min := or .Min "16rem" -}}
{{- $gap := or .Gap "gap-4" -}}
{{- $tag := or .Tag "div" -}}
{{- $tracks := "repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))" -}}
{{- if .Fill}}{{- $tracks = "repeat(auto-fill,minmax(min(var(--auto-grid-min,16rem),100%),1fr))"}}{{end -}}
<{{$tag}} data-slot="auto-grid"{{if .Fill}} data-fill="true"{{end}} style="--auto-grid-min:{{$min}}"{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}{{if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}} class="grid [grid-template-columns:{{$tracks}}] {{$gap}}{{if .Class}} {{.Class}}{{end}}">{{htmlSafe .Body}}</{{$tag}}>
{{end}}

1. Save the file

Drop auto_grid.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/auto_grid.ex
<.auto_grid>
  <div :for={item <- @items}></div>
</.auto_grid>

<.auto_grid min="20rem" gap="gap-6" tag="ul">
  <li :for={item <- @items}></li>
</.auto_grid>

<.auto_grid fill min="12rem">
  <div :for={item <- @items}></div>
</.auto_grid>
View source
lib/my_app_web/components/auto_grid.ex
defmodule ShadcnHtmx.Components.AutoGrid do
  @moduledoc """
  Auto Grid — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/auto-grid.tsx.

  A responsive, intrinsically-wrapping grid of equal cells with no media
  queries — children flow into as many columns as fit at a configurable
  minimum item width, then grow to share the leftover space. This is the
  "RAM" pattern (Repeat, Auto, Minmax). Pure CSS Grid; zero JavaScript.

    - `min`  — per-item minimum width (any CSS length). Default "16rem".
    - `gap`  — a gap-* class. Default "gap-4".
    - `fill` — true → auto-fill (keep empty tracks); false → auto-fit
               (collapse them so real items stretch). Default false.
    - `tag`  — div | ul | ol | section. Default "div".

  ## Examples

      <.auto_grid>
        <div :for={item <- @items}>…</.auto_grid>

      <.auto_grid min="20rem" gap="gap-6" fill tag="ul">
        <li :for={item <- @items}>…</li>
      </.auto_grid>
  """

  use Phoenix.Component

  attr :min, :string, default: "16rem"
  attr :gap, :string, default: "gap-4"
  attr :fill, :boolean, default: false
  attr :tag, :string, default: "div", values: ~w(div ul ol section)
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(aria-label aria-labelledby)
  slot :inner_block, required: true

  def auto_grid(assigns) do
    tracks =
      if assigns.fill do
        "repeat(auto-fill,minmax(min(var(--auto-grid-min,16rem),100%),1fr))"
      else
        "repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))"
      end

    assigns = assign(assigns, :tracks, tracks)

    ~H"""
    <.dynamic_tag
      tag_name={@tag}
      data-slot="auto-grid"
      data-fill={if @fill, do: "true"}
      style={"--auto-grid-min:#{@min}"}
      class={["grid", "[grid-template-columns:#{@tracks}]", @gap, @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.dynamic_tag>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/auto-grid.html
<div data-slot="auto-grid"
     style="--auto-grid-min:16rem"
     class="grid [grid-template-columns:repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-4">
  <div>…</div>
</div>
View source
snippets/auto-grid.html
<!--
  shadcn-htmx — raw HTML Auto Grid snippet.
  Mirrors registry/ui/auto-grid.tsx.

  A responsive, intrinsically-wrapping grid of equal cells with NO media
  queries (the RAM pattern: repeat, auto-fit/auto-fill, minmax). Pure CSS;
  no script. Relies only on theme tokens.

  Tune density with the --auto-grid-min custom property in the inline style.
  The grid-template-columns utility reads it:
    repeat(auto-fit,  minmax(min(var(--auto-grid-min), 100%), 1fr))  → collapse empty tracks
    repeat(auto-fill, minmax(min(var(--auto-grid-min), 100%), 1fr))  → keep empty tracks
-->

<!-- Default: 16rem minimum per item, gap-4, auto-fit (items stretch) -->
<div data-slot="auto-grid"
     style="--auto-grid-min:16rem"
     class="grid [grid-template-columns:repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-4">
  <div class="rounded-lg border bg-card p-4 text-card-foreground">Item 1</div>
  <div class="rounded-lg border bg-card p-4 text-card-foreground">Item 2</div>
  <div class="rounded-lg border bg-card p-4 text-card-foreground">Item 3</div>
  <div class="rounded-lg border bg-card p-4 text-card-foreground">Item 4</div>
</div>

<!-- As a list of cards: <ul> root, <li> children, wider min, larger gap -->
<ul data-slot="auto-grid"
    style="--auto-grid-min:20rem"
    class="grid [grid-template-columns:repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-6">
  <li class="rounded-lg border bg-card p-4 text-card-foreground">Card A</li>
  <li class="rounded-lg border bg-card p-4 text-card-foreground">Card B</li>
  <li class="rounded-lg border bg-card p-4 text-card-foreground">Card C</li>
</ul>

<!-- auto-fill: empty trailing tracks are kept so a half-empty row stays
     aligned to the column rhythm instead of stretching -->
<div data-slot="auto-grid" data-fill="true"
     style="--auto-grid-min:12rem"
     class="grid [grid-template-columns:repeat(auto-fill,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-4">
  <div class="rounded-lg border bg-card p-4 text-card-foreground">One</div>
  <div class="rounded-lg border bg-card p-4 text-card-foreground">Two</div>
</div>

Examples

Card grid — no media queries

Children flow into as many columns as fit at the min item width, then stretch to fill the row. Resize the window and watch the column count change with zero breakpoints.

The whole layout is one line of CSS: grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)). web.dev calls this the RAM pattern — Repeat, Auto-fit, Minmax. Each track is at least 16rem and at most 1fr, so on a narrow screen items take the full width and as the container grows they snap onto the same row. We wrap the lower bound in min(16rem, 100%) so a single item can never overflow a container narrower than the minimum.

One
Two
Three
Four
Five
Six
<AutoGrid>
  <div>One</div>
  <div>Two</div>
  <div>Three</div>
  <div>Four</div>
  <div>Five</div>
  <div>Six</div>
</AutoGrid>
{% call auto_grid() %}
  <div>One</div>
  <div>Two</div>
  <div>Three</div>
{% endcall %}
{{template "auto-grid" (dict "Body" (htmlSafe $cards))}}
<.auto_grid>
  <div :for={item <- @items}>{item}</div>
</.auto_grid>
<div data-slot="auto-grid" style="--auto-grid-min:16rem" class="grid [grid-template-columns:repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-4 w-full" data-test="basic">
  <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">One</div>
  <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">Two</div>
  <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">Three</div>
  <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">Four</div>
  <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">Five</div>
  <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">Six</div>
</div>

auto-fill vs auto-fit

With only two items: auto-fit (default) collapses empty tracks so the two cells stretch across the row; auto-fill keeps the empty tracks so the cells stay at their minimum width, aligned to the column rhythm.

The only difference is what happens to empty tracks. Per the web.dev "Learn CSS — Grid" course: auto-fit collapses unused tracks to 0 so the filled tracks grow to consume the space; auto-fill leaves the empty tracks at their reserved width. Reach for fill when a half-empty last row should keep the same item size as the full rows above it.

auto-fit (default) — items stretch

A
B

auto-fill — empty tracks kept

A
B
// auto-fit (default): the two items stretch to fill the row
<AutoGrid min="10rem">
  <div>A</div>
  <div>B</div>
</AutoGrid>

// auto-fill: empty tracks are reserved, items stay at min width
<AutoGrid fill min="10rem">
  <div>A</div>
  <div>B</div>
</AutoGrid>
{% call auto_grid(min="10rem") %}…{% endcall %}
{% call auto_grid(min="10rem", fill=true) %}…{% endcall %}
{{template "auto-grid" (dict "Min" "10rem" "Body" (htmlSafe $b))}}
{{template "auto-grid" (dict "Min" "10rem" "Fill" true "Body" (htmlSafe $b))}}
<.auto_grid min="10rem"></.auto_grid>
<.auto_grid min="10rem" fill></.auto_grid>
<div class="w-full space-y-4">
  <div class="space-y-1.5">
    <p class="text-xs font-medium text-muted-foreground">auto-fit (default) — items stretch</p>
    <div data-slot="auto-grid" style="--auto-grid-min:10rem" class="grid [grid-template-columns:repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-4" data-test="fit">
      <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">A</div>
      <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">B</div>
    </div>
  </div>
  <div class="space-y-1.5">
    <p class="text-xs font-medium text-muted-foreground">auto-fill — empty tracks kept</p>
    <div data-slot="auto-grid" data-fill="true" style="--auto-grid-min:10rem" class="grid [grid-template-columns:repeat(auto-fill,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-4" data-test="fill">
      <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">A</div>
      <div class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">B</div>
    </div>
  </div>
</div>

Density — the min width drives the column count

A smaller min item width packs more columns into the same container; a larger one yields fewer, wider columns. Set it with the min prop (any CSS length).

There are no per-breakpoint column counts to maintain — you describe the smallest acceptable item and the browser derives the columns. Use a list element (as="ul") when the cells are a genuine list so assistive tech announces the count.

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
<AutoGrid as="ul" min="7rem" gap={3} aria-label="Swatches">
  <li>1</li>
  <li>2</li>
  <li>3</li>
  {/* … */}
</AutoGrid>
{% call auto_grid(min="7rem", gap="gap-3", tag="ul", aria_label="Swatches") %}
  <li>1</li>
{% endcall %}
{{template "auto-grid" (dict "Min" "7rem" "Gap" "gap-3" "Tag" "ul" "AriaLabel" "Swatches" "Body" (htmlSafe $items))}}
<.auto_grid tag="ul" min="7rem" gap="gap-3" aria-label="Swatches">
  <li :for={n <- 1..8}>{n}</li>
</.auto_grid>
<ul data-slot="auto-grid" style="--auto-grid-min:7rem" aria-label="Swatches" class="grid [grid-template-columns:repeat(auto-fit,minmax(min(var(--auto-grid-min,16rem),100%),1fr))] gap-3 w-full" data-test="density">
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">1</li>
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">2</li>
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">3</li>
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">4</li>
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">5</li>
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">6</li>
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">7</li>
  <li class="flex min-h-20 items-center justify-center rounded-lg border bg-card p-4 text-sm font-medium text-card-foreground">8</li>
</ul>

API Reference

<AutoGrid>

PropTypeDefaultDescription
minstring"16rem"
Minimum per-item width (any CSS length, e.g. "16rem", "200px", "20ch"). Drives how many columns fit. Published as the --auto-grid-min custom property and read inside minmax().MDNminmax()
gapnumber|string4
Gap between cells. A number maps to gap-<n> (0,1,2,3,4,5,6,8,10,12); a string is appended verbatim as a class.
fillbooleanfalse
Use auto-fill (keep empty trailing tracks) instead of auto-fit (collapse them so real items stretch to fill the row).MDNrepeat()
as"div"|"ul"|"ol"|"section""div"
Semantic element / role for the grid container. Use ul/ol when the cells are a genuine list (children should be <li>).
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference