GrCard
Берут, когда контенту нужна поверхность.
Когда брать
- контенту нужна поверхность — рамка, фон, радиус и тень одним компонентом вместо своих классов;
- у блока есть заголовок —
titleдаёт настоящийh2…h6, по которому отчёт из шести карточек обходится структурой; - карточка кликабельна —
clickable/hrefдают роль и клавиатуру целиком, а не@clickнаdiv; - карточек много — общий вид держится
variant, а не копией классов в каждом месте; - отступы задаются раскладкой —
paddingвключаяnoneдля таблицы или картинки край в край.
Когда взять другое
| Нужно | Берите |
|---|---|
| Нужна структура шапки, тела и подвала окна | GrDialog |
| Карточка — виджет на сетке дашборда | GrDashboardItem |
| Карточка объясняет пустоту | GrEmptyState |
| Внутри список строк | GrList |
| Нужен только разделитель | GrDivider |
<GrCard> без пропов рендерит один <div> со слотом: ни обёрток, ни
отступов. Это позволяет ставить его под любую раскладку, не борясь с чужими
полями.
Дефолт неизменен
<GrCard> без пропов рендерит один <div> со слотом внутри и ровно теми же
классами, что и раньше: ни обёрток, ни отступов. Это не осторожность ради
осторожности — на GrCard стоят GrCollapse и GrList, и сдвинутый дефолт
поехал бы у них. Отсюда padding="none" по умолчанию.
Отступы
<GrCard padding="md">
…
</GrCard>
none (по умолчанию), sm (12px), md (16px), lg (24px). Пока секций нет,
отступ живёт на самой поверхности — лишней обёртки не появляется.
Варианты
| Значение | Что даёт |
|---|---|
elevated (по умолчанию) | рамка + фон + тень |
outlined | рамка + фон, без тени |
ghost | только фон — карточка внутри карточки не рисует вторую рамку |
padding и variant читаются из GrConfigProvider:
<GrConfigProvider :component-defaults="{ GrCard: { padding: 'md', variant: 'outlined' } }">Секции
<GrCard padding="md">
<template #header>
<h3>Релиз 0.15</h3>
</template>
Тело карточки.
<template #footer>
<GrButton size="sm">Опубликовать</GrButton>
</template>
</GrCard>
Шапка отбивается border-b, подвал — border-t. Как только появляется хотя бы
одна секция (или bodyClass), отступ переезжает с поверхности на секции —
иначе он сложился бы вдвое.
Карточка отбивает то, что рисует сама. Шапка из title — её собственная, и
отступ ей полагается независимо от padding, включая дефолтный none: иначе
заголовок и разделитель под ним оказываются прижаты к рамке. Это и есть типовая
карточка отчёта — заголовок с отступом, таблица край в край:
<GrCard title="Документы" variant="outlined">
<GrDataTable :rows="rows" :columns="columns" />
</GrCard>
Слоты #header и #footer наполняет потребитель — их отступы идут из padding
и остаются его заботой. Правило то же, что и у приоритета: #header сильнее
пропов и заменяет шапку целиком, а вместе с ней и ответственность за отступы.
Иначе потребитель, у которого внутри слота свои отступы, получил бы двойные.
bodyClass кладёт классы на обёртку тела — там же, где обычно нужен
grid gap-*.
Заголовок — настоящий, а не жирная строка
<GrCard title="Выручка за месяц" description="Без учёта возвратов" padding="md">
<RevenueChart />
</GrCard>
title печатается тегом h2…h6 (headingLevel, по умолчанию h3), а не
<span class="font-semibold">. Разница не косметическая: страница из шести
карточек, где после h1 нет ни одного заголовка, обходится скринридером
только подряд — прыгать между блоками нечем.
#header сильнее пропов: слот занят — title и description не
рендерятся вовсе. Нестандартная шапка не обязана объяснять, почему она не
title.
Действие рядом с заголовком — слот #actions, и шапка при этом остаётся
своей: заголовок печатается тем же h2…h6, отступы берутся из шкалы карточки.
<GrCard title="Продажи за неделю" :heading-level="3">
<template #actions>
<GrButton size="xs" variant="ghost">Обновить</GrButton>
</template>
</GrCard>
Слот появился не ради удобства: третьего случая не было — либо заголовок из
пропов, либо #header целиком, — и любая кнопка в шапке заставляла забрать
слот и переписать заголовок вручную. К этому обходу независимо пришли
GrDashboardItem в кольце и обёртки потребителей, и все теряли одно и то же:
настоящий уровень заголовка и шкалу отступов.
С #header слот не сочетается: тот заменяет шапку целиком, включая место, куда
действия встали бы. GrDashboardItem по-прежнему пользуется #header — у него
левее заголовка живёт ручка перетаскивания, а её #actions не выражает.
Карточка-ссылка берёт доступное имя из заголовка (aria-labelledby), а не из
всего содержимого подряд.
У кликабельной карточки заголовка не будет. Корень там — <button>, а его
контент-модель это phrasing content: <h3> внутри невалиден. title
печатается <span>, и в dev компонент об этом предупреждает. Нужен и
заголовок, и переход — hoverable плюс ссылка в самом заголовке; это тот же
приём, что и с действиями внутри (ниже).
Карточка-ссылка и карточка-кнопка
<GrCard href="/reports/42" padding="md">
…
</GrCard>
<GrCard clickable padding="md" @click="open">
…
</GrCard>
<GrCard :as="RouterLink" :to="{ name: 'report' }" padding="md">
…
</GrCard>
Порядок выбора тега — as → <a href> → <button clickable> → <div>, как у
GrLink и GrListItem. Интерактивная карточка получает кольцо фокуса и
подсветку при наведении.
Ограничение: внутрь кликабельной карточки нельзя класть кнопки и ссылки —
интерактив внутри интерактива ломает и клавиатуру, и скринридер (axe:
nested-interactive). Карточке с действиями внутри нужен hoverable плюс
ссылка в заголовке — визуально это то же самое, семантически — правильно.
hoverable подсвечивает карточку, не делая её интерактивной.
Playground 6
Загружается…
<GrCard />Установка
npm i @feugene/granularityИмпорт
import { GrCard } from '@feugene/granularity/components/GrCard'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
variant | GrCardVariant | undefined | undefined | `elevated` — рамка, фон и тень; `outlined` — без тени; `ghost` — без рамки. |
title | string | undefined | undefined | Заголовок карточки — настоящий `h2`…`h6`, а не жирная строка: отчёт из шести карточек иначе не обойти по структуре, после `h1` страницы в нём нет ни одного заголовка. |
as | string | Component | undefined | undefined | Свой корневой тег (`RouterLink`, `Link` от Inertia). Сильнее `href`. |
href | string | undefined | undefined | Карточка-ссылка. |
padding | GrCardPadding | undefined | undefined | Внутренние отступы. По умолчанию `none`: карточка — поверхность, а её содержимое (`GrCollapse`, `GrList`) само знает про свои отступы. |
clickable | boolean | undefined | false | Карточка-кнопка: интерактивна вся поверхность. |
hoverable | boolean | undefined | false | Подсветка при наведении без интерактивности. |
bodyClass | string | undefined | undefined | Классы обёртки тела — она появляется вместе с секциями. |
description | string | undefined | undefined | Пояснение под заголовком. |
headingLevel | 2 | 3 | 4 | 5 | 6 | undefined | undefined | Уровень заголовка под структуру страницы. Не задан — из `GrConfigProvider`, иначе `3`. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Содержимое карточки. |
header | any | Шапка вместо пропов `title` и `description`. |
actions | any | Действия справа в собственной шапке карточки: «⋯», «Обновить», переключатель периода. Включают шапку так же, как `title`. Со слотом `#header` не сочетается: тот заменяет шапку целиком, и всё, что в ней есть, рисует потребитель. |
footer | any | Подвал: действия, сводка, пагинация. |
Events
| Event | Type | Описание |
|---|---|---|
click | [event: MouseEvent] | — |
Примеры 6
Действия рядом с заголовком карточки
Слот #actions рисует действия справа в собственной шапке карточки: заголовок остаётся настоящим h2…h6 со своим уровнем и отступами. До него карточке с кнопкой приходилось забирать #header целиком и переписывать заголовок руками.
Продажи за неделю
Обновлено 5 минут назад
Последнее действие: —
<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrCard, GrDropdownMenu, GrDropdownMenuItem } from '@feugene/granularity'
const lastAction = ref('—')
</script>
<template>
<!--
Заголовок остаётся заголовком карточки — настоящим `h3` со своим уровнем и
отступами, — а кнопка живёт в `#actions` справа от него. До этого слота
карточке с действием приходилось забирать `#header` целиком и переписывать
заголовок вручную.
-->
<GrCard title="Продажи за неделю" description="Обновлено 5 минут назад" :heading-level="3" padding="md">
<template #actions>
<GrButton size="xs" variant="ghost" @click="lastAction = 'Обновить'">
Обновить
</GrButton>
<GrDropdownMenu placement="bottom-end">
<template #trigger="{ triggerProps }">
<GrButton v-bind="triggerProps" size="xs" variant="ghost" square aria-label="Ещё">
⋯
</GrButton>
</template>
<GrDropdownMenuItem @click="lastAction = 'Экспорт'">
Экспорт в CSV
</GrDropdownMenuItem>
<GrDropdownMenuItem @click="lastAction = 'Настройки'">
Настроить период
</GrDropdownMenuItem>
</GrDropdownMenu>
</template>
<p class="text-sm text-[var(--gr-muted-fg)]">
Последнее действие: <strong>{{ lastAction }}</strong>
</p>
</GrCard>
</template>Варианты поверхности и карточка-кнопка
elevated / outlined / ghost и полиморфный корень: clickable делает интерактивной всю поверхность.
<script setup lang="ts">
import { ref } from 'vue'
import { GrCard } from '@feugene/granularity'
const opened = ref('—')
</script>
<template>
<div class="grid gap-4">
<div class="grid gap-3 md:grid-cols-3">
<GrCard padding="md" body-class="grid gap-1">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
elevated
</div>
<div class="text-sm text-[var(--gr-muted-fg)]">
Рамка, фон и тень — вид по умолчанию.
</div>
</GrCard>
<GrCard padding="md" variant="outlined" body-class="grid gap-1">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
outlined
</div>
<div class="text-sm text-[var(--gr-muted-fg)]">
Без тени: плотные сетки не рябят.
</div>
</GrCard>
<GrCard padding="md" variant="ghost" body-class="grid gap-1">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
ghost
</div>
<div class="text-sm text-[var(--gr-muted-fg)]">
Без рамки: карточка внутри карточки.
</div>
</GrCard>
</div>
<!-- Внутри кликабельной карточки не должно быть кнопок и ссылок:
интерактив внутри интерактива ломает и клавиатуру, и скринридер. -->
<GrCard clickable padding="md" body-class="grid gap-1" @click="opened = 'Отчёт за июль'">
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Отчёт за июль
</div>
<div class="text-sm text-[var(--gr-muted-fg)]">
Вся поверхность кликабельна и достижима `Tab`.
</div>
</GrCard>
<div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
Открыто: <span class="font-semibold text-[var(--gr-fg)]">{{ opened }}</span>
</div>
</div>
</template>Базовая поверхность с отступами от раскладки
Показываем главный contract GrCard: компонент отвечает за surface/border, а внутренние spacing/layout decisions остаются у страницы через class.
<script setup lang="ts">
import { GrButton, GrCard } from '@feugene/granularity'
</script>
<template>
<!-- Отступ и раскладка тела — пропами карточки, а не `class` снаружи. -->
<GrCard padding="md" body-class="grid gap-4">
<div>
<div class="text-sm font-semibold text-[var(--gr-fg)]">
Basic card
</div>
<div class="mt-1 text-sm text-[var(--gr-muted-fg)]">
Карточка задаёт поверхность и отступы сама — потребителю остаётся содержимое.
</div>
</div>
<div class="flex flex-wrap gap-2">
<GrButton size="sm">
Primary action
</GrButton>
<GrButton size="sm" variant="outline">
Secondary
</GrButton>
</div>
</GrCard>
</template>Карточки как плитки показателей
Один из самых частых use-case — KPI/stat tiles, где GrCard даёт единый surface для компактных dashboard-блоков.
<script setup lang="ts">
import { GrBadge, GrCard } from '@feugene/granularity'
const metrics = [
{ id: 'budget', label: 'Error budget', value: '98.4%', tone: 'success', badge: 'Healthy' },
{ id: 'reviews', label: 'Pending reviews', value: '7', tone: 'warning', badge: 'Attention' },
{ id: 'pipelines', label: 'Blocked pipelines', value: '2', tone: 'danger', badge: 'Escalate' },
] as const
</script>
<template>
<div class="grid gap-4 md:grid-cols-3">
<GrCard
v-for="metric in metrics"
:key="metric.id"
padding="sm"
variant="outlined"
body-class="grid gap-2"
>
<div class="text-sm text-[var(--gr-muted-fg)]">
{{ metric.label }}
</div>
<div class="text-2xl font-semibold text-[var(--gr-fg)]">
{{ metric.value }}
</div>
<GrBadge size="sm" :tone="metric.tone" radius="semi">
{{ metric.badge }}
</GrBadge>
</GrCard>
</div>
</template>Заголовок раздела и описание
title печатается настоящим h2…h6 (headingLevel), а не жирной строкой: отчёт из нескольких карточек иначе нечем обойти по структуре. Слот #header остаётся сильнее пропов.
Revenue by channel
August 2026, excluding refunds
- Gross
- $26,100
- Refunds
- $1,355
- Net
- $24,745
Documents
- Gross
- $26,100
- Refunds
- $1,355
- Net
- $24,745
Custom header
updated 2 min ago<script setup lang="ts">
import { GrCard, GrDescriptionList } from '@feugene/granularity'
const items = [
{ label: 'Gross', value: '$26,100' },
{ label: 'Refunds', value: '$1,355' },
{ label: 'Net', value: '$24,745' },
]
</script>
<template>
<div class="grid gap-4">
<!--
`title` печатается настоящим `h2`…`h6`, а не жирной строкой: отчёт из
нескольких карточек иначе нечем обойти по структуре — после `h1` страницы
заголовков в нём нет.
-->
<GrCard
title="Revenue by channel"
description="August 2026, excluding refunds"
:heading-level="3"
variant="outlined"
padding="md"
>
<GrDescriptionList :items="items" label-width="7rem" />
</GrCard>
<!--
Типовая карточка отчёта: `padding` оставлен дефолтным, чтобы содержимое
шло край в край, — и шапка всё равно с отступом. Она принадлежит самой
карточке, а не содержимому, и прижатой к рамке быть не должна.
-->
<GrCard title="Documents" variant="outlined">
<GrDescriptionList :items="items" layout="flow" class="px-4 py-3" />
</GrCard>
<!-- `#header` сильнее пропов: нестандартная шапка остаётся за потребителем. -->
<GrCard variant="outlined" padding="md">
<template #header>
<div class="flex items-center justify-between gap-2">
<h3 class="m-0 text-[length:var(--gr-text-sm)] font-600">
Custom header
</h3>
<span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">updated 2 min ago</span>
</div>
</template>
Слот занят — `title` и `description` не рендерятся вовсе.
</GrCard>
</div>
</template>У кликабельной карточки заголовка не будет: корень там <button>, а его контент-модель это phrasing content. Нужен и заголовок, и переход — hoverable плюс ссылка в самом заголовке.
Панель действий с бейджами и группой CTA
Документируем composed pattern, где карточка работает контейнером для actions, helper badges и explanatory copy.
Release checklist
2 blockers<script setup lang="ts">
import { GrBadge, GrButton, GrCard } from '@feugene/granularity'
</script>
<template>
<GrCard padding="md" body-class="grid gap-2">
<template #header>
<div class="flex flex-wrap items-center gap-2">
<h3 class="m-0 text-sm font-semibold text-[var(--gr-fg)]">
Release checklist
</h3>
<GrBadge size="sm" tone="info" radius="semi">
2 blockers
</GrBadge>
</div>
</template>
<div class="text-sm text-[var(--gr-muted-fg)]">
Шапка и подвал — слоты карточки: разделители и отступы она расставляет сама.
</div>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<GrButton size="sm" variant="outline">
Open runbook
</GrButton>
<GrButton size="sm">
Resolve blockers
</GrButton>
</div>
</template>
</GrCard>
</template>