Blocks

Feature Grid

A four-state feature section of equal-weight icon cards, with optional real "learn more" links and no cap on how many render.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowRight, Sparkles } from "lucide-react"
import { cn } from "@/lib/utils"
import type { FeatureGridData, FeatureGridItem } from "./feature-grid.contract"

type IconComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>

/**
 * Column count → static Tailwind classes. Must be literal strings: Tailwind
 * scans source text, so `lg:grid-cols-${n}` would compile to nothing.
 * Unknown values fall back to 3 rather than rendering an unstyled column.
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/feature-grid.json

Prompt

Build a React + TypeScript + Tailwind "FeatureGrid" block (lucide-react
ArrowRight + a fallback glyph) with zod.

Contract
- A zod schema is the single source of truth and stays JSON-serialisable,
  because this copy usually arrives from a CMS:
  { status: "loading" | "empty" | "error" | "ready"; eyebrow?, heading?,
    description?; items: { id, title, description, icon?, link?: { label,
    href } }[] }.
- Component props = z.infer of the schema plus
  icons?: Record<string, ComponentType<SVGProps<SVGSVGElement>>>,
  columns?: 1 | 2 | 3 | 4 (default 3), onRetry?: () => void, className.
- `icon` is a KEY, never a React element — the data stays serialisable and
  the consumer's icons map decides what gets bundled. An unknown or absent
  key renders one neutral fallback glyph so the card anatomy is unchanged
  instead of collapsing to a ragged card.

Behavior
- Four first-class branches: loading (heading skeleton + one skeleton row of
  `columns` cards mirroring the real anatomy), empty ("No features to show"
  panel), error (message + "Try again" only when onRetry exists), ready
  (optional eyebrow/heading/description header + the card grid).
- NO ITEM CAP. items.map renders every entry; the grid wraps to as many rows
  as needed. Do not introduce a MAX_FEATURES constant — pass 7 items and 7
  cards must appear. If a project genuinely needs a limit, expose it as a
  prop and render a visible "+N more" affordance; a silent slice is a bug.
- NO FIXED CARD HEIGHT and no overflow clipping: a 400-character description
  grows its card, and grid stretch keeps the row even. Truncating copy the
  data contains is the same lie as capping the item count.
- `link` is optional and drives the only click affordance in the card: with
  it, a real <a href> from the contract (the host supplies real
  destinations — never "#"); without it, the card is plain text with no
  cursor-pointer and no hover treatment anywhere, including the icon tile.
- columns is clamped against the lookup table before use, and the same
  clamped number drives both the grid class and the skeleton count, so 0 /
  NaN / 9 can't produce an unstyled grid or an absurd skeleton row.

Rendering & styling
- Semantic tokens only: bg-card cards on border hairlines, icon tile
  bg-primary/10 text-primary, text-muted-foreground for supporting copy,
  text-primary for the link. No hardcoded colours, no fixed radii scale.
- Column classes are literal strings in a lookup table (grid-cols-1 /
  sm:grid-cols-2 / lg:grid-cols-3 …) because Tailwind scans source text and
  a template-built class would compile to nothing.
- Accessibility: the section is labelled by its heading via
  aria-labelledby + useId (and unlabelled when there is no heading, rather
  than pointing at a missing id); cards are a role="list" of <li>; the icon
  tile is aria-hidden decoration; links carry
  focus-visible:ring-2 ring-ring. Card titles are h3 under the section h2.
- cn() merges className; the root is a full-width flex column so the block
  drops into any page container.

Customization levers
- Density: columns 1–4 picks the widest-breakpoint track count; drop the
  sm: step for a phone-first single column, or add xl:grid-cols-N to the
  lookup table for very wide marketing pages.
- Header: eyebrow / heading / description are each optional and the header
  disappears entirely when all three are absent — useful when the section
  already sits under a page title. Swap text-center for text-start for a
  left-aligned marketing layout.
- Icon treatment: the tile is one span — make it a plain icon (drop the
  bg-primary/10 chip), a ring, or a square by changing that element only.
  Supply your own icons map to use a different icon set entirely.
- Link style: swap the inline text link for a Button-styled anchor, or move
  it out of the card into a single section-level CTA.
- Emphasis: this block deliberately treats every item equally; if one item
  must dominate, reach for a bento/tiling layout instead of special-casing
  a card here.

Concepts

  • Uncapped rendering — the grid maps over every item and wraps; a hidden MAX constant would silently drop content the data promised, so the count is a layout concern (columns), never a data one.
  • Intrinsic card height — no max-h and no clipping, so a long description grows its card instead of disappearing behind an ellipsis; grid stretch keeps the row edges level anyway.
  • Icon by key — the contract carries a string, the consumer carries the map. Data stays JSON-serialisable for a CMS, only the icons you map get bundled, and an unmapped key degrades to one neutral glyph rather than a hole.
  • Affordance follows data — the "learn more" anchor exists only when the item ships a link, and it is the sole clickable thing in the card; nothing else gets a pointer cursor or hover lift it can't honour.
  • Equal weight by design — every card is the same size, which is what makes a feature list scannable; mixed-weight mosaics are a different component (Bento Grid), not a variant of this one.
  • Contract-driven four states — marketing copy comes from a CMS that can be slow, unpublished or down, so status makes those paths first-class instead of an unhandled spinner.

On This Page