Card, from the Building blocks group of the nertia component library.
npx shadcn@latest add https://ner.sh/r/block-card.jsonThe recipe carries this block's layout and variant; the install brings the component.
{
"registries": {
"@nertia": "https://ner.sh/r/{name}.json"
}
}npx shadcn@latest add @nertia/block-cardnpm create nersh@latest my-site -- --site https://nertia.ai/components/building-blocks/element-block-card/site.json scaffolds a project from it.{
"slug": "element-block-card",
"mode": "builder",
"status": "draft",
"skinId": "zinc",
"composition": {
"id": "element-block-card",
"sections": [
{
"id": "block-card",
"instanceId": "stage-block-card"
}
]
},
"copy": {
"stage-block-card.title": "Card title",
"stage-block-card.body": "Add a short description.",
"stage-block-card.href": "#",
"stage-block-card.emphasis": "",
"stage-block-card.density": "",
"stage-block-card.align": "",
"stage-block-card.icon": "✦"
}
}
import type { Section } from '../types';
import { Component } from './Component';
import { schema } from './schema';
import { meta } from './meta';
import { writeCopy } from './writeCopy';
export const blockCard: Section = { meta, schema, Component, writeCopy };
import type { SectionProps } from '../types';
import { compositionEditPath } from '@/lib/composition/compositionEditableCopy';
import { Editable } from '@/components/site/EditableText';
import { Card, CardBody, CardHeader, Heading, Text } from '@/components/foundation';
import { isImageSrc } from '@/lib/isImageSrc';
import { cn } from '@/lib/utils';
/**
* A nested block that asks for the card's edge (`data-placement="edge"`, block-badge's
* `placement`) is pinned centred on the card's top border and taken out of the body's
* flow, so it costs the card no height and no gap. The pin lands on the renderer's
* per-child wrapper, not the leaf: an in-flow 0-height wrapper would still take a gap
* and push the featured tier's content down. The outer `.site-block-card` is the
* positioned box, so the pin measures from the card's own top edge.
*/
const EDGE_PIN = cn(
'[&>*:has([data-placement=edge])]:absolute [&>*:has([data-placement=edge])]:left-1/2 [&>*:has([data-placement=edge])]:top-0',
'[&>*:has([data-placement=edge])]:z-10 [&>*:has([data-placement=edge])]:-translate-x-1/2 [&>*:has([data-placement=edge])]:-translate-y-1/2',
);
/**
* A closing button is the card's action, so it sits a step further from the content
* above it than the body's own rhythm. Pinned to the bottom by `mt-auto`, it would
* otherwise touch a feature list whenever the cards in a row run the same height.
*/
const CLOSING_ACTION_SPACE = '[&>*:last-child:has(>[data-section=block-button])]:pt-3';
/**
* A linked card that holds nested blocks cannot be one anchor: a nested block-button is an
* anchor too, and anchors do not nest. So the card stays a box and its link is a sibling
* stretched over it, the way product-grid's action cards keep their buttons out of the
* card link. Clicks on the card's text land on the stretched link; a nested block that holds
* a control is lifted above it so the control keeps its own clicks. The edge pin's `absolute`
* outranks this `relative` on specificity. The link is the card's last child on purpose: a
* child slot ahead of the others would shift the React id of every form field in every card.
*/
const LIFT_CONTROLS = cn(
'[&>*:has(:is(a,button,input,select,textarea,summary,iframe,video,audio,[tabindex]))]:relative',
'[&>*:has(:is(a,button,input,select,textarea,summary,iframe,video,audio,[tabindex]))]:z-[1]',
);
export function Component({ copy, instanceId, slotContent }: SectionProps) {
const edit = (slotKey: string) => compositionEditPath(instanceId, slotKey);
const title = copy.title ?? '';
const compact = (copy.density ?? '').trim().toLowerCase() === 'compact';
const body = copy.body ?? '';
const icon = (copy.icon ?? '').trim();
// The one card emphasis a pattern can ask for: lift the card off the band.
const elevated = (copy.emphasis ?? '').trim().toLowerCase() === 'true';
// A curated centring option: blank/'start' keeps today's left-aligned card content.
const centered = (copy.align ?? '').trim().toLowerCase() === 'center';
// A card with nothing in it still names itself, so it is findable in the builder.
// A card that frames nested blocks hands the header over to them when the title is blank.
const showHeader = title.trim().length > 0 || !slotContent;
// The whole card is one link. Without nested blocks the Card slot renders an anchor, which
// takes the skin's hover lift; with them the card is an interactive box under a stretched link.
const href = (copy.href ?? '').trim();
const stretched = Boolean(href && slotContent);
// The stretched link is named by what the card says. Ids come from the instance, not
// `useId`: a hook here would shift every generated id below the card on every site.
const labelId = stretched && instanceId ? `block-card-${instanceId}` : null;
const headerId = labelId ? `${labelId}-header` : undefined;
const bodyId = labelId ? `${labelId}-body` : undefined;
const blocksId = labelId ? `${labelId}-blocks` : undefined;
const linkLabel = labelId
? [showHeader && headerId, body && bodyId, blocksId].filter(Boolean).join(' ')
: undefined;
return (
<div data-section="block-card" className="site-block-card relative flex flex-1 flex-col">
<Card
elevated={elevated}
align={centered ? 'center' : undefined}
href={href && !stretched ? href : undefined}
interactive={stretched}
className={cn('flex-1', href && 'cursor-pointer')}
>
{icon ? (
<div
data-block-card-icon=""
className="mb-3 flex h-16 w-16 items-center justify-center rounded-[var(--radius)] bg-muted text-4xl leading-none text-primary"
>
{isImageSrc(icon) ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={icon} alt="" className="h-10 w-10 object-contain" />
) : (
<span aria-hidden="true">{icon}</span>
)}
</div>
) : null}
{showHeader ? (
<CardHeader id={headerId}>
<Heading level={compact ? 'heading' : 'title'} dataEditPath={edit('title')}>
{title || 'Card title'}
</Heading>
</CardHeader>
) : null}
{body ? (
<CardBody id={bodyId}>
<Text
size={compact ? 'sm' : 'md'}
className={compact ? 'mt-1 text-[var(--muted-foreground)]' : 'mt-2 text-[var(--foreground)]'}
>
<Editable path={edit('body')!}>{body}</Editable>
</Text>
</CardBody>
) : null}
{/* `w-full` is load-bearing: `.site-card` is `align-items: var(--site-align-items)`,
which most skins centre, so a body left to itself shrinks to its widest child and
a form in a card rendered about a third of the card wide. That same `w-full`
defeats the outer `data-align="center"` rule for this body's own children (they'd
still stretch), so a centred card repeats the centring here as plain utilities:
no `.site-card-body` rule exists to lose a specificity fight against. */}
{slotContent ? (
<CardBody
id={blocksId}
className={cn(
'flex w-full flex-1 flex-col gap-3 [&>*:last-child]:mt-auto',
EDGE_PIN,
CLOSING_ACTION_SPACE,
stretched && LIFT_CONTROLS,
centered && 'items-center text-center',
)}
>
{slotContent}
</CardBody>
) : null}
{stretched ? (
<a
href={href}
aria-labelledby={linkLabel}
aria-label={linkLabel ? undefined : [title, body].filter(Boolean).join(' ') || undefined}
data-stretched-link=""
className="absolute inset-0 rounded-[inherit] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent)]"
/>
) : null}
</Card>
</div>
);
}
import type { SectionCopySchemaField } from '../types';
export const schema: SectionCopySchemaField[] = [
{ key: 'title', label: 'Title', type: 'text', placeholder: 'Card title' },
{ key: 'body', label: 'Body', type: 'textarea', placeholder: 'Short description' },
{
key: 'href',
label: 'Link',
type: 'link',
placeholder: 'https://',
helpText: 'Makes the whole card one link. Blank leaves it unlinked.',
},
{
key: 'emphasis',
label: 'Emphasised',
type: 'text',
placeholder: 'true',
advanced: true,
options: [
{ value: '', label: 'Off' },
{ value: 'true', label: 'On' },
],
},
{
key: 'density',
label: 'Density',
type: 'text',
placeholder: 'default',
maxLength: 8,
advanced: true,
helpText: 'Compact shows a smaller, bolder title and smaller muted body text, the size used on product cards. Default uses the normal card text size.',
options: [
{ value: '', label: 'Default' },
{ value: 'compact', label: 'Compact' },
],
},
{
key: 'align',
label: 'Align',
type: 'text',
placeholder: 'start',
maxLength: 8,
advanced: true,
helpText: 'Center centers the title, body and other content in the card. A list inside still keeps its own rows left-aligned. Start keeps everything left-aligned.',
options: [
{ value: '', label: 'Start' },
{ value: 'center', label: 'Center' },
],
},
{
key: 'icon',
label: 'Icon',
type: 'text',
placeholder: '🏠 or https://example.com/icon.svg',
helpText: 'An emoji or an image URL. Blank shows no mark.',
advanced: true,
},
];
import type { SectionMeta } from '../types';
export const meta: SectionMeta = {
id: 'block-card',
displayName: 'Card',
family: 'component',
tags: ['component', 'card'],
};
import type { BrandContext } from '@/lib/brandContext';
export function writeCopy(_ctx: BrandContext) {
return { title: 'Card title', body: 'Add a short description.' };
}