Skip to content
Block

Feature grid

A titled grid of short items as cards. Number them to show steps.

  • #arc
  • #block
  • #feature
  • #grid
  • #services
  • #steps
  • #marketing

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/feature-grid.json

feature-grid.module.csscss
.root { container: feature-grid / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); word-break: keep-all; overflow-wrap: anywhere; }
.root *, .root *::before, .root *::after { box-sizing: border-box; }

.inner { width: 100%; max-width: 72rem; margin: 0 auto; padding: var(--space-16) var(--space-4); }
@container feature-grid (min-width: 640px) { .inner { padding-inline: var(--space-6); } }
@container feature-grid (min-width: 768px) { .inner { padding-block: var(--space-20); } }

.header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-8); }
.title { margin: 0; font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1.2; text-wrap: balance; }
.description { max-width: 40rem; margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-base); line-height: var(--leading-body); text-wrap: pretty; }
@container feature-grid (min-width: 768px) { .title { font-size: var(--text-3xl); } }

.action { display: inline-flex; align-items: center; gap: var(--space-1); border-radius: var(--radius-control); color: var(--accent); font-size: var(--text-sm); font-weight: 500; text-decoration: none; -webkit-tap-highlight-color: transparent; transition: opacity var(--duration-fast) var(--ease-standard); }
.action:active { opacity: .7; }
@media (hover: hover) and (pointer: fine) { .action:hover { text-decoration: underline; text-underline-offset: 4px; } }

.grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); margin: 0; padding: 0; list-style: none; }
@container feature-grid (min-width: 560px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@container feature-grid (min-width: 960px) {
  .grid[data-columns="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .grid[data-columns="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.item { display: flex; min-width: 0; flex-direction: column; gap: var(--space-3); }
.itemTitle { margin: 0; font-size: var(--text-lg); font-weight: 500; line-height: 1.3; text-wrap: balance; }
.itemText { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }

.mark { display: inline-grid; width: var(--space-8); height: var(--space-8); flex: none; place-items: center; border-radius: var(--radius-pill); background: var(--accent-subtle); color: var(--accent); font-size: var(--text-sm); font-weight: 500; font-variant-numeric: tabular-nums; }
.mark[data-kind="icon"] { border-radius: var(--radius-control); }

/* Cards: each item in a bordered surface. */
.root[data-variant="cards"] .item { border: 1px solid var(--border); border-radius: var(--radius-panel); padding: var(--space-6); background: var(--surface); box-shadow: var(--shadow-resting); }

/* Rules: a line above each item, quieter for steps. */
.root[data-variant="rules"] .item { gap: var(--space-2); border-top: 2px solid var(--accent); padding-top: var(--space-4); }
.root[data-variant="rules"] .itemTitle { font-size: var(--text-base); }
.root[data-variant="rules"] .mark { margin-bottom: var(--space-1); }

@media (prefers-reduced-motion: reduce) { .action { transition-duration: 0ms; } }
feature-grid.tsxtsx
import { forwardRef, useId } from "react";
import type { ReactNode } from "react";
import { UiIcon } from "../../theme/ui-icon";
import styles from "./feature-grid.module.css";

export interface FeatureItem {
  title: string;
  description?: string;
  /** A mark for the item. It takes the place of the number when `numbered` is on. */
  icon?: ReactNode;
}

export interface FeatureGridProps {
  title?: string;
  description?: string;
  /** A link beside the title, for "see everything" or "learn more". */
  action?: { label: string; href: string };
  items?: FeatureItem[];
  /** Numbers the items 1, 2, 3 and reads them as an ordered list. Use it for steps, not for a menu of services. */
  numbered?: boolean;
  /** Columns on a wide container. It folds to two and then one as the container narrows. Defaults to 3. */
  columns?: 2 | 3 | 4;
  /** `cards` sets each item in a bordered card; `rules` ties each to a line above it, quieter for steps. */
  variant?: "cards" | "rules";
  /** The heading level of the title. The items sit one level below it. Defaults to 2. */
  headingLevel?: 2 | 3;
  className?: string;
}

export const exampleFeatures: FeatureItem[] = [
  { title: "웹앱 개발", description: "새 서비스, 관리자 도구, 사내 시스템을 웹으로 만들어요." },
  { title: "디자인 시스템", description: "흩어진 화면을 같은 부품과 같은 규칙으로 묶어요." },
  { title: "모바일 앱", description: "하나의 코드로 iOS와 안드로이드에 함께 내놓아요." },
  { title: "운영과 유지보수", description: "출시한 뒤에도 고치고 다듬으며 곁에서 지켜봐요." },
  { title: "자동화", description: "손으로 반복하던 일을 도구가 대신하게 만들어요." },
  { title: "기술 자문", description: "만들기 전에 방향과 비용을 함께 따져 봐요." },
];

/**
 * A titled grid of short items: what a team does, why it is worth choosing, the steps of a process.
 *
 * It is a section named by its title and the items are a real list (ordered when numbered), so a screen reader hears
 * "list, 6 items" instead of six unrelated boxes. The items are plain text on purpose. If an item needs its own link,
 * use LinkCard; if it needs a price and a list, use PlanComparison.
 */
export const FeatureGrid = forwardRef<HTMLElement, FeatureGridProps>(function FeatureGrid({
  title = "이런 일을 해요",
  description = "작은 도구부터 출시할 앱까지, 필요한 만큼 맡아서 만들어요.",
  action,
  items = exampleFeatures,
  numbered = false,
  columns = 3,
  variant = "cards",
  headingLevel = 2,
  className,
}, ref) {
  const id = useId();
  const Heading = `h${headingLevel}` as "h2" | "h3";
  const ItemHeading = `h${headingLevel + 1}` as "h3" | "h4";
  const List = numbered ? "ol" : "ul";
  return <section ref={ref} className={[styles.root, className].filter(Boolean).join(" ")} aria-labelledby={title ? `${id}-title` : undefined} data-variant={variant}>
    <div className={styles.inner}>
      {(title || description || action) && <header className={styles.header}>
        <div className={styles.heading}>
          {title && <Heading id={`${id}-title`} className={styles.title}>{title}</Heading>}
          {description && <p className={styles.description}>{description}</p>}
        </div>
        {action && <a className={styles.action} href={action.href}>
          {action.label}
          <UiIcon iconRole="arrow-right" size={16} strokeWidth={1.75} aria-hidden="true" />
        </a>}
      </header>}
      <List className={styles.grid} data-columns={columns} data-count={items.length}>
        {items.map((item, index) => <li key={`${item.title}-${index}`} className={styles.item}>
          {(numbered || item.icon) && <span className={styles.mark} data-kind={item.icon ? "icon" : "number"} aria-hidden="true">
            {item.icon ?? index + 1}
          </span>}
          <ItemHeading className={styles.itemTitle}>{item.title}</ItemHeading>
          {item.description && <p className={styles.itemText}>{item.description}</p>}
        </li>)}
      </List>
    </div>
  </section>;
});

FeatureGrid.displayName = "FeatureGrid";

/** Preview: the grid with its sample services, numbered. */
export function FeatureGridBlock() {
  return <FeatureGrid numbered action={{ label: "서비스 자세히 보기", href: "#services" }} />;
}

export default FeatureGridBlock;