GrCard

Пакет: @feugene/granularityядроГруппа: Данные

Берут, когда контенту нужна поверхность.

Когда брать

  • контенту нужна поверхность — рамка, фон, радиус и тень одним компонентом вместо своих классов;
  • у блока есть заголовокtitle даёт настоящий h2h6, по которому отчёт из шести карточек обходится структурой;
  • карточка кликабельна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 печатается тегом h2h6 (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

PropTypeпо умолчаниюОписание
variantGrCardVariant | undefinedundefined`elevated` — рамка, фон и тень; `outlined` — без тени; `ghost` — без рамки.
titlestring | undefinedundefinedЗаголовок карточки — настоящий `h2`…`h6`, а не жирная строка: отчёт из шести карточек иначе не обойти по структуре, после `h1` страницы в нём нет ни одного заголовка.
asstring | Component | undefinedundefinedСвой корневой тег (`RouterLink`, `Link` от Inertia). Сильнее `href`.
hrefstring | undefinedundefinedКарточка-ссылка.
paddingGrCardPadding | undefinedundefinedВнутренние отступы. По умолчанию `none`: карточка — поверхность, а её содержимое (`GrCollapse`, `GrList`) само знает про свои отступы.
clickableboolean | undefinedfalseКарточка-кнопка: интерактивна вся поверхность.
hoverableboolean | undefinedfalseПодсветка при наведении без интерактивности.
bodyClassstring | undefinedundefinedКлассы обёртки тела — она появляется вместе с секциями.
descriptionstring | undefinedundefinedПояснение под заголовком.
headingLevel2 | 3 | 4 | 5 | 6 | undefinedundefinedУровень заголовка под структуру страницы. Не задан — из `GrConfigProvider`, иначе `3`.

Slots

SlotTypeОписание
defaultanyСодержимое карточки.
headeranyШапка вместо пропов `title` и `description`.
actionsanyДействия справа в собственной шапке карточки: «⋯», «Обновить», переключатель периода. Включают шапку так же, как `title`. Со слотом `#header` не сочетается: тот заменяет шапку целиком, и всё, что в ней есть, рисует потребитель.
footeranyПодвал: действия, сводка, пагинация.

Events

EventTypeОписание
click[event: MouseEvent]

Примеры 6

Действия рядом с заголовком карточки

Слот #actions рисует действия справа в собственной шапке карточки: заголовок остаётся настоящим h2…h6 со своим уровнем и отступами. До него карточке с кнопкой приходилось забирать #header целиком и переписывать заголовок руками.

Продажи за неделю

Обновлено 5 минут назад

Последнее действие:

Header Actions
<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 делает интерактивной всю поверхность.

elevated
Рамка, фон и тень — вид по умолчанию.
outlined
Без тени: плотные сетки не рябят.
ghost
Без рамки: карточка внутри карточки.
Открыто:

Variants
<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.

Basic card
Карточка задаёт поверхность и отступы сама — потребителю остаётся содержимое.

Basic Surface
<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-блоков.

Error budget
98.4%
Healthy
Pending reviews
7
Attention
Blocked pipelines
2
Escalate

Kpi Grid
<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 печатается настоящим h2h6 (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
Слот занят — `title` и `description` не рендерятся вовсе.

Section Heading
<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
Шапка и подвал — слоты карточки: разделители и отступы она расставляет сама.

Action Panel
<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>

Документация компонентаВсе компоненты