You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When you pass `href`, `Card` automatically wraps in `BaseLink` and adds a `group/link` class so descendants can react to card-level hover/focus. You don't need to nest your own `<a>` for "whole card clickable" cards.
54
56
57
+
**Two hover signals that can stack -- keep their meanings distinct:**
58
+
59
+
-**`hoverLift`** (the card raises on hover) means the card **carries an action** -- a CTA button or a text link. Add it to *any* card that holds an action, whether or not the card is also a link.
60
+
- An **outline ring** (or, on a `ghost` card, a **`bg-background-highlight` fill**) means the **whole card is the click target**. An `href``Card` renders this automatically (see Interaction props). It's *additive* to `hoverLift`, not an alternative.
61
+
62
+
**The number of actions decides the shape. This is the canonical rule -- follow it for every new/refactored card:**
63
+
64
+
-**One CTA -> the whole card is the link, and it carries an action.** Put `href` on the `Card` (automatic outline/fill), render the footer button as a **`CardButtonFake`** (not a `ButtonLink`/`Button` -- a real interactive `<a>`/`<button>` nested inside the card's own anchor is invalid HTML with broken keyboard/hit-target behavior; `CardButtonFake` is a presentational `<div>` mirror of `Button` via `asChild` that inherits the card's `group/link` hover), **and** add `hoverLift` so the card advertises the action it carries. (See the `InteractionPatterns` story.)
65
+
-**Two or more CTAs -> the card is NOT a link, but carries actions.** Multiple independent actions can't collapse into one card-level link, so drop `href`, use real `ButtonLink`s (each its own target), and add `hoverLift`. Reach for `CardFooter buttons="compact"` when they should sit inline.
66
+
-**No button, action is an inline link in the copy -> non-link card that carries an action.** Keep the card a plain `<div>` (no `href`), use an `InlineLink` in the copy, and add `hoverLift`. No outline ring, which would falsely imply the entire card is a single link.
67
+
68
+
(A **pure navigation card** -- an `href` card with no interior button or link, e.g. a media/thumbnail tile -- is the one case that skips `hoverLift`: there's no interior action to advertise, so the automatic outline/fill alone carries the "whole card is clickable" signal.)
69
+
70
+
**Rule: a link card needs interior padding, and its banner belongs in a `CardHeader`.** Because an `href` card renders a variant-aware hover state (fill or ring) tight to its outer edge, its contents must sit inset from that edge. In practice:
71
+
72
+
- If a link card contains a `CardBanner`, **wrap it in a `CardHeader` by default** so the `--card-pad` inset keeps the banner clear of the hover treatment and its corners concentric with the card. Don't drop a bare `CardBanner` straight into an `href``Card`.
73
+
- Avoid `size="xs"` (which zeroes `--card-pad`) on a link card unless the layout supplies padding another way.
74
+
75
+
The sanctioned exceptions are cards that arrange the banner differently and **bake the padding into the banner itself** rather than relying on a `CardHeader` -- e.g. `PathwayCard` (`src/components/cards/pathway-card.tsx`), whose banner sits beside the text in a container-query row layout and carries its own `p-*`. If you're building a new bespoke link-card arrangement, follow that model: inset the banner somehow, don't leave it flush.
76
+
55
77
## Step 2: Pick the `variant`
56
78
57
79
| Variant | When to use |
58
80
|---|---|
59
81
|`base` (default) | Standard card on the default page background. Gives `bg-background-highlight` (grey). |
60
82
|`nested`| When the Card sits inside a section that already has a non-default background. Gives `bg-background` (white in light, black in dark) so it visually pops out from the colored container. |
61
-
|`ghost`| No background. The Card behaves like an outlined container. `--banner-radius` is automatically widened (since the banner's edge IS the card's edge), so any `CardBanner` matches the outer corner radius. |
83
+
|`ghost`| No background -- a transparent, outlined-style container. `--banner-radius` is automatically widened so an edge-to-edge `CardBanner` matches the outer corner radius. As a **link** (`href`), a ghost card fills with `bg-background-highlight` on hover instead of showing an outline ring (see Interaction props). |
62
84
|`header-bar`| "Top bar" appearance: only the `CardHeader` region gets the highlight background, the rest is bordered, and the header gets row layout (icon + text) with a bottom border. The variant bakes in all of this — just drop a `CardHeader` inside, no extra props. |
63
85
64
86
## Step 3: Pick the `size`
@@ -75,18 +97,23 @@ When you pass `href`, `Card` automatically wraps in `BaseLink` and adds a `group
75
97
|`sm`| 10px | 10px | Compact list cards |
76
98
|`xs`| 0 | 4px | No padding; use when the banner image needs to extend to all edges of the Card |
Two independent booleans layer edge/interaction treatment on top of `variant`/`size` -- they're additive, so combine freely:
81
103
82
104
| Prop | Effect | When |
83
105
|---|---|---|
84
106
|`border`| Static hairline edge (`ring ring-border`). It's a `ring`, so it never shifts layout. | A resting outline on `nested`/`ghost` cards that need definition against their background. |
85
-
|`hoverLift`|`hover:shadow-md` plus a subtle `hover:scale`. | Cards that hold their own actions instead of being a link themselves: multiple action buttons, or text-link actions in the body/footer. The lift signals "interactive content here" without implying the whole card is clickable. |
107
+
|`hoverLift`|`hover:shadow-md` plus a subtle `hover:scale`. | Any card that carries an action: a single CTA (alongside the automatic `href` outline/fill), multiple action buttons, or a text link in the body. The lift signals "interactive content here." Skip it only on a pure navigation card (an `href` tile with no interior button/link), where the outline/fill alone carries the signal. |
108
+
109
+
**There is no `hoverOutline` prop anymore.** Every Card with an `href` gets a hover affordance automatically, chosen by `variant` -- you don't set it:
110
+
111
+
-**`ghost` link cards fill on hover** with `bg-background-highlight` and show *no* outline. A ghost card is transparent at rest, so the fill is the affordance; an offset ring would clip against a flush banner and read as noise. This is the treatment for the site's media/link cards (video, hackathon, story, and latest-article grids).
112
+
-**`base`, `nested`, and `header-bar` link cards keep the outline ring** (`ring-transparent` at rest -> `ring-primary-hover` on hover). They already sit on a fill, so a hover fill would be invisible; the ring is what reads.
86
113
87
-
**There is no `hoverOutline` prop anymore.** Every Card with an `href` gets the hover ring automatically (transparent at rest, `ring-primary-hover` on hover) -- it's baked into the link render path, not a variant you set. It also can't be applied to non-link cards by hand; if a card isn't a link, use `hoverLift` (or nothing).
114
+
It's implemented with `compoundVariants` keyed on an internal `interactive` flag (set from `href`, omitted from the public `CardProps`) -- not a variant you pass, and not a class you can hand-apply to a non-link card. The outline/fill is additive to `hoverLift`: a single-CTA link card gets both (ring/fill from `href`, lift from `hoverLift`); a non-link card that carries an action gets `hoverLift` alone.
88
115
89
-
`border` composes with the automatic ring: on an `href` Card, the `ring-border` shows at rest and `hover:ring-primary-hover` takes over on hover, reading as a resting border that brightens to primary. These props replaced the old `hoverEffect="lift"` prop.
116
+
`border` composes with the ring on non-ghost link cards: the `ring-border` shows at rest and `hover:ring-primary-hover` takes over, reading as a resting border that brightens to primary. On a `ghost` link card there is no hover ring, so a `border` simply stays as the resting outline while the highlight fills behind it. These props replaced the old `hoverEffect="lift"` prop.
90
117
91
118
## Step 4: Border Radius "Just Works"
92
119
@@ -130,17 +157,39 @@ When *should* you use `className`? Things that are genuinely outside the Card's
- Defaults to `padding-top: 0` (mirrors `CardHeader`'s `padding-bottom: 0`); the gap between Content and Footer comes from Content's bottom-pad. The parent restores the top-pad automatically for `variant="header-bar"`.
141
-
-`buttons="full"` (default): buttonsand ButtonLinks stretch to full width and center their text. Use this when you want CTAs to span the card.
142
-
-`buttons="compact"`: buttons size to fit their content. Use for trailing-link style or when the button shouldn't dominate.
173
+
-`buttons="full"` (default): buttons, `ButtonLink`s, and `CardButtonFake`s stretch to full width and center their text. Use this when you want CTAs to span the card.
174
+
-`buttons="compact"`: buttons size to fit their content. Use for trailing-link style, multi-button footers, or when the button shouldn't dominate.
143
175
-`buttons="inherit"`: opt out of both — buttons render with their own intrinsic width.
176
+
- The layout variants target both real buttons (`[button]`) and `CardButtonFake` (`[data-label=button-link]`), so they apply whichever you use. Which one to use is the single-vs-multiple-CTA rule under [`Card` with `href`](#card-with-href): one CTA -> `CardButtonFake` in an `href` card; two+ -> real `ButtonLink`s in a non-link card.
177
+
178
+
### `CardButtonFake`
179
+
180
+
```tsx
181
+
<Cardhref="/proof-of-stake"hoverLift>
182
+
...
183
+
<CardFooter>
184
+
<CardButtonFake>Learn more</CardButtonFake>
185
+
</CardFooter>
186
+
</Card>
187
+
```
188
+
189
+
- The **only** correct way to render a footer CTA inside an `href``Card`. It's a presentational `<div>` mirror of `Button` (rendered via `Button asChild`), so it reuses every Button style but is **not** an interactive element -- no nested `<a>`/`<button>` inside the card's anchor.
190
+
- Accepts Button's `variant`, `size`, and `isSecondary`. It carries `data-label="button-link"`, so `CardFooter`'s `buttons` layout variants size it exactly like a real button.
191
+
- Button's `hover-link` styling fires from the card's `group/link`, so hovering anywhere on the card animates the fake button identically to a real hover -- you don't (and can't) hand-wire hover styles onto it.
192
+
- Never use it on a non-link card (there's nothing to click) and never use a real `Button`/`ButtonLink` as the sole CTA of an `href` card (invalid nested anchor). See the single-vs-multiple-CTA rule above.
144
193
145
194
### `CardBanner`
146
195
@@ -158,8 +207,8 @@ When *should* you use `className`? Things that are genuinely outside the Card's
158
207
-`fit="contain"` with a *single*`<Image>` child triggers an auto-blurred-backdrop effect: the same image is cloned, scaled, blurred, and placed behind to fill any letterboxing. If you pass two children, you lose this magic and need to provide your own backdrop.
159
208
-`zoom`: `true` (default) propagates the parent `group/link` hover/focus into an image scale-up; pass `zoom={false}` when the art shouldn't move.
160
209
- Placement:
161
-
- Inside `CardHeader` (most common): the banner respects `--card-pad` and gets`--banner-radius`-rounded corners.
162
-
- As a direct child of `Card` (no `CardHeader` wrapper): the banner extends to the card's edges (no padding). Pair with `Card size="xs"`for an edge-to-edge image card, or use `variant="ghost"` for an outlined edge-to-edge look.
210
+
-**Inside `CardHeader` (the default -- prefer this):** the banner insets by `--card-pad` and its`--banner-radius` corners stay concentric with the card's outer radius. Required on **link** cards (`href`): the inset keeps the hover state clear of the image and the corner radii aligned.
211
+
- As a bare direct child of `Card` (no `CardHeader`): the banner extends flush to the card's edges. Only safe for a true edge-to-edge image, and pair it with `Card size="xs"`(`--card-pad: 0`) so the banner radius and the card's outer radius match. A bare banner on a padded size (`sm`/`md`/`base`) clips at `--banner-radius` while the card corner is `--card-pad + --banner-radius` -- a visible mismatch, and on a link card the hover treatment has no room to breathe. (See the bare-banner gotcha.)
163
212
164
213
### `CardEmoji`
165
214
@@ -253,10 +302,11 @@ import { Sparkles } from "lucide-react"
253
302
254
303
- [ ] Imports from `@/components/ui/card` (NOT `@/components/MarkdownCard` — that's the MDX shortcode wrapper)
255
304
- [ ] If linkable, uses `Card href="..."` (not a wrapping `<a>`)
305
+
- [ ] `hoverLift` on every card that carries an action (single CTA, multi-CTA, or text-link) -- skip it only on a pure navigation card with no interior button/link. Single CTA -> `href` + `CardButtonFake` + `hoverLift` (never a real `ButtonLink`/`Button` -- no nested anchor); two+ CTAs -> no `href`, real `ButtonLink`s + `hoverLift`; text-link-only -> no `href`, `InlineLink` + `hoverLift`
256
306
- [ ] No `className` on `Card`/parts that adjusts padding, background, spacing, border-radius, or text color — those go through variants
257
307
- [ ] Heading is `CardTitle`; uses `asChild` if `<h3>` would break heading outline
258
308
- [ ] Description is `CardParagraph`
259
-
- [ ] Image lives in a `CardBanner` (inside `CardHeader` for padded, or as a direct child of `Card` with `size="xs"` or `variant="ghost"` for edge-to-edge)
309
+
- [ ] Image lives in a `CardBanner`, wrapped in `CardHeader` (the default -- keeps banner radius concentric and gives link-hover room). Bare banner only for a true edge-to-edge image, paired with `size="xs"` so radii match
260
310
- [ ] If image needs containment, `fit="contain"` is used to get the auto-blur backdrop
261
311
- [ ] If you added a new variant case to `card.tsx`, story coverage is updated
Copy file name to clipboardExpand all lines: .claude/skills/design-system/references/components.md
+5-2Lines changed: 5 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -92,7 +92,8 @@ The canonical card primitive. **Driven by CSS variables set on `Card`** (`--card
92
92
93
93
**`Card` variants**:
94
94
95
-
-`variant`: `base` (default, `bg-background-highlight` grey) | `nested` (`bg-background`, use when inside a colored section) | `ghost` (no bg; auto-widens `--banner-radius` for edge-to-edge banners) | `header-bar` (highlight only on the header, bordered card, header laid out as an icon+text row with bottom border — all baked in, just drop a `CardHeader` inside).
95
+
-`variant`: `base` (default, `bg-background-highlight` grey) | `nested` (`bg-background`, use when inside a colored section) | `ghost` (no bg; auto-widens `--banner-radius`; as a link, fills with `bg-background-highlight` on hover instead of an outline ring) | `header-bar` (highlight only on the header, bordered card, header laid out as an icon+text row with bottom border — all baked in, just drop a `CardHeader` inside).
96
+
-**Link hover is variant-aware** (auto, from `href`): `ghost` link cards fill with `bg-background-highlight` and drop the outline; `base`/`nested`/`header-bar` link cards keep the `ring-primary-hover` outline. Driven by an internal `interactive` compound variant, not a prop.
96
97
-`size`: `lg | base (default) | md | sm | xs`. Controls `--card-pad` (between/around parts) and `--content-space` (within `CardContent`). `xs` = zero padding for edge-to-edge banner imagery.
97
98
-`href`: pass to wrap in `BaseLink` and get whole-card-clickable behavior with `group/link` propagation.
98
99
- Card is always vertical (`flex flex-col`); there is no `orientation` variant.
@@ -115,7 +116,7 @@ The canonical card primitive. **Driven by CSS variables set on `Card`** (`--card
115
116
-`size`: `full | lg | base (default) | sm | thumbnail-lg | thumbnail`. Use these instead of `className="h-..."` to stay on-rhythm. `thumbnail-lg` is a 128px square; `thumbnail` is 64px — both `shrink-0` for small logo/icon placements.
116
117
-`fit`: `cover (default) | contain`. With `fit="contain"` and a single `<Image>` child, the banner auto-clones the image as a blurred backdrop. Two children breaks the magic.
117
118
-`zoom`: `true (default) | false`. Controls hover zoom propagation from a parent `group/link`.
118
-
- Placement: inside `CardHeader`for padded; as a direct child of `Card`(pair with `Card size="xs"`or `variant="ghost"`) for edge-to-edge.
119
+
- Placement: inside `CardHeader`(default; keeps banner radius concentric and gives link-hover room). Bare direct child of `Card`only for a true edge-to-edge image, paired with `size="xs"`so radii match (a bare banner on a padded size / link card mismatches corner radius — see gotchas).
`"use client"`, wraps swiper.js. On the deprecation track. Migrate existing consumers to `EdgeScrollContainer`.
672
673
674
+
**i18n requirement:**`Swiper` binds `useTranslations("component-swiper")` for its a11y slide announcements. Every page that renders one must ship that namespace to the client — register the route in `src/lib/utils/translations.ts` (`EXACT_PATH_ADDITIONAL_NAMESPACES` / `PREFIX_PATH_ADDITIONAL_NAMESPACES`) or add `"component-swiper"` to the page's `pick()` for its `I18nProvider`. Missing it means screen readers announce raw key tails (`swiper-next-slide`). Currently registered: `/` (hand-pick), `/developers/` (scoped providers), `/start/`, `/10years/`, `/apps/`.
Copy file name to clipboardExpand all lines: .claude/skills/design-system/references/gotchas.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -38,6 +38,10 @@ In `Button.tsx` lines 67-69, there's an early-out: `["solid", "link"].includes(v
38
38
39
39
If you pass `<CardBanner fit="contain"><Image .../></CardBanner>` with **one** child, you get a blurred-bg + sharp-fg automatically (Card.tsx lines 95-119). Pass two children and you lose this magic.
40
40
41
+
### A bare `CardBanner` on a link card mismatches its corner radius
42
+
43
+
`Card`'s outer radius is `--card-pad + --banner-radius`, but a `CardBanner` rounds at only `--banner-radius`. Wrapping the banner in a `CardHeader` lets the `--card-pad` inset bridge the difference so the corners stay concentric. Drop the banner straight into `Card` (no header) on any padded size and the banner's squarer corners poke past the card outline; add an `href` and the variant-aware hover has no inset to breathe against. Only go bare with `size="xs"` (`--card-pad: 0`), where the two radii collapse to the same value. This bit the video / hackathon / story / latest card grids until their banners were wrapped in `CardHeader` (July 2026).
The whole-card-clickable pattern uses a `before:absolute` pseudo-element on `LinkOverlay`. Use `LinkBox` without `LinkOverlay` and the click-the-whole-card behavior breaks.
0 commit comments