블록
기능 그리드
제목과 짧은 항목들을 카드로 늘어놓는 영역이에요. 번호를 매겨 단계로도 쓸 수 있어요.
- #arc
- #block
- #feature
- #grid
- #services
- #steps
- #marketing
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/feature-grid.json파일
components/arc/blocks/feature-grid/feature-grid.module.csscomponents/arc/blocks/feature-grid/feature-grid.tsx
함께 오는 항목
feature-grid.module.cssfeature-grid.module.cssfeature-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.tsxfeature-grid.tsxfeature-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;