List, from the Building blocks group of the nertia component library.
npx shadcn@latest add https://ner.sh/r/block-list.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-listnpm create nersh@latest my-site -- --site https://nertia.ai/components/building-blocks/element-block-list/site.json scaffolds a project from it.{
"slug": "element-block-list",
"mode": "builder",
"status": "draft",
"skinId": "zinc",
"composition": {
"id": "element-block-list",
"sections": [
{
"id": "block-list",
"instanceId": "stage-block-list"
}
]
},
"copy": {
"stage-block-list.items": "First item\nSecond item\nThird item",
"stage-block-list.marker": "check",
"stage-block-list.size": "",
"stage-block-list.markerImage": ""
}
}
import type { Section } from '../types';
import { Component } from './Component';
import { schema } from './schema';
import { meta } from './meta';
import { writeCopy } from './writeCopy';
export const blockList: Section = { meta, schema, Component, writeCopy };
import type { SectionProps } from '../types';
import { Icon, Text } from '@/components/foundation';
import { cn } from '@/lib/utils';
/**
* Inlined rather than imported from `@/lib/isImageSrc`: a registry leaf has to close
* inside the boundary, since `@nersh/render` consumers get this file without the app
* around it. Same rule as that helper, kept deliberately in step with it.
*/
function isImageSource(value: string | undefined): boolean {
const v = value?.trim();
if (!v) return false;
return /^data:image\//i.test(v) || /^https?:\/\//i.test(v) || v.startsWith('/');
}
/**
* `items` is edited in the panel, not on the page. One `data-edit-path` over the whole
* newline-separated slot would be written back through `innerText`, which collapses the
* newlines and folds the list into a single row on the first inline edit. Per-row indexed
* edit paths are the plan 2 shape.
*
* `markerImage` carries a site's own uploaded glyph in place of the check, which is what
* the retiring `pricing` monolith did with its `checkIcon` slot. It follows that slot's
* rule exactly: only a real image source is drawn, and anything else (an emoji, a word)
* falls back to the check rather than reaching `<img src>`.
*
* `size: 'sm'` is the compact feature list a pricing card carries: rows on the site's
* small type step with tighter spacing, so short bullets sit on one line in a narrow
* card. Closed set, anything else is the default scale.
*/
export function Component({ copy, overrides }: SectionProps) {
const items = (copy.items ?? '')
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
const marker = copy.marker === 'dot' || copy.marker === 'none' ? copy.marker : 'check';
const dotted = marker === 'dot';
const markerImage = marker === 'check' && isImageSource(copy.markerImage) ? copy.markerImage!.trim() : '';
const small = (copy.size ?? '').trim().toLowerCase() === 'sm';
const markerClassName = cn('shrink-0', small ? 'mt-0.5 size-4' : 'mt-1 size-4');
return (
<ul
data-section="block-list"
data-size={small ? 'sm' : undefined}
data-font-body={overrides?.['--font-body'] ? '' : undefined}
// A dot row has to stay `display: list-item` to draw its `::marker`, so the dotted
// list keeps normal flow and spaces its rows with margins instead of a flex gap.
// `text-left` is explicit, not the default: a centred parent card (block-card's
// `align` slot) inherits `text-align: center`, and the monolith it reproduces keeps
// its feature list left-aligned even when the rest of the card centres.
className={cn(
'site-list text-left',
dotted ? cn('list-disc pl-5', small ? 'space-y-1.5' : 'space-y-2') : cn('flex flex-col', small ? 'gap-1.5' : 'gap-2'),
)}
>
{items.map((item, i) => (
<li key={i} className={marker === 'check' ? cn('flex items-start', small ? 'gap-2.5' : 'gap-2') : undefined}>
{markerImage ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={markerImage}
alt=""
aria-hidden
// `site-inline-icon` is what tells card photo sizing to skip this image.
className={cn('site-inline-icon', markerClassName)}
/>
) : marker === 'check' ? (
<Icon name="check" className={cn(markerClassName, 'text-primary')} />
) : null}
<Text as="span" size={small ? 'sm' : undefined}>
{item}
</Text>
</li>
))}
</ul>
);
}
import type { SectionCopySchemaField } from '../types';
export const schema: SectionCopySchemaField[] = [
{
key: 'items',
label: 'Items (one per line)',
type: 'textarea',
placeholder: 'One line per item',
},
{
key: 'marker',
label: 'Marker',
type: 'text',
placeholder: 'check',
advanced: true,
options: [
{ value: 'check', label: 'Check' },
{ value: 'dot', label: 'Dot' },
{ value: 'none', label: 'None' },
],
},
{
key: 'size',
label: 'Size',
type: 'text',
placeholder: '',
maxLength: 2,
advanced: true,
helpText: 'Small uses smaller text and tighter row spacing, like a pricing list. Default uses the normal list size.',
options: [
{ value: '', label: 'Default' },
{ value: 'sm', label: 'Small', budgets: { items: { maxLineLength: 32 } } },
],
},
{
key: 'markerImage',
label: 'Marker image (optional)',
type: 'image',
advanced: true,
},
];
import type { SectionMeta } from '../types';
export const meta: SectionMeta = {
id: 'block-list',
displayName: 'List',
family: 'component',
tags: ['list', 'checklist'],
bucket: 'atom',
};
import type { BrandContext } from '@/lib/brandContext';
export function writeCopy(_ctx: BrandContext) {
return { items: 'First item\nSecond item\nThird item', marker: 'check' };
}