Outline button, from the Building blocks group of the nertia component library.
npx shadcn@latest add https://ner.sh/r/block-button.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-buttonnpm create nersh@latest my-site -- --site https://nertia.ai/components/building-blocks/element-block-button-outline/site.json scaffolds a project from it.{
"slug": "element-block-button-outline",
"mode": "builder",
"status": "draft",
"skinId": "zinc",
"composition": {
"id": "element-block-button-outline",
"sections": [
{
"id": "block-button",
"instanceId": "stage-block-button"
}
]
},
"copy": {
"stage-block-button.label": "Button",
"stage-block-button.href": "#",
"stage-block-button.variant": "outline",
"stage-block-button.size": "1",
"stage-block-button.tone": "",
"stage-block-button.width": "",
"stage-block-button.icon": "",
"stage-block-button.iconOnly": ""
}
}
import type { Section } from '../types';
import { Component } from './Component';
import { schema } from './schema';
import { meta } from './meta';
import { writeCopy } from './writeCopy';
export const blockButton: Section = { meta, schema, Component, writeCopy };
import type { SectionProps } from '../types';
import { Editable } from '@/components/site/EditableText';
import { compositionEditPath } from '@/lib/composition/compositionEditableCopy';
import { Button, Icon } from '@/components/foundation';
import type { ButtonProps, IconProps } from '@/lib/foundation/contract';
type ButtonVariant = NonNullable<ButtonProps['variant']>;
const BUTTON_VARIANTS: readonly ButtonVariant[] = ['solid', 'soft', 'outline', 'ghost'];
/** The icon slot's closed set, each drawn from the site icon library under the same name. */
const BUTTON_ICONS: readonly IconProps['name'][] = [
'instagram',
'tiktok',
'youtube',
'x',
'linkedin',
'github',
'mail',
'link',
'download',
];
/** Anything the owner types that is not a slot variant falls back to the primary CTA look. */
function resolveVariant(value: string | undefined): ButtonVariant {
const key = (value ?? '').trim().toLowerCase() as ButtonVariant;
return BUTTON_VARIANTS.includes(key) ? key : 'solid';
}
function resolveIcon(value: string | undefined): IconProps['name'] | null {
const key = (value ?? '').trim().toLowerCase() as IconProps['name'];
return BUTTON_ICONS.includes(key) ? key : null;
}
export function Component({ copy, instanceId, overrides }: SectionProps) {
const edit = (slotKey: string) => compositionEditPath(instanceId, slotKey);
const label = copy.label || 'Button';
const href = copy.href || '#';
const variant = resolveVariant(copy.variant);
const sizeRaw = (copy.size ?? '').trim();
const size = sizeRaw === '1' ? 1 : sizeRaw === '3' ? 3 : 2;
const tone = (copy.tone ?? '').trim().toLowerCase() === 'neutral' ? 'neutral' : 'accent';
// `width: 'full'` fills the slot the button sits in (a pricing card's CTA). The fill is
// skin CSS keyed on `data-width` (siteTokens), not a utility: `.site-btn-*` sets its own
// unlayered `width: fit-content`, which a layered `w-full` cannot beat.
const full = (copy.width ?? '').trim().toLowerCase() === 'full';
const icon = resolveIcon(copy.icon);
// An icon-only button still carries its label, for screen readers: a row of network marks.
const iconOnly = icon !== null && (copy.iconOnly ?? '').trim().toLowerCase() === 'true';
const labelNode = <Editable path={edit('label')!}>{label}</Editable>;
return (
<div
data-section="block-button"
data-width={full ? 'full' : undefined}
data-font-body={overrides?.['--font-body'] ? '' : undefined}
className="site-block-button"
>
<Button
href={href}
variant={variant}
size={size}
tone={tone}
download={icon === 'download' || undefined}
className={icon ? 'gap-2' : undefined}
data-icon={icon ?? undefined}
>
{icon ? <Icon name={icon} className="size-[1.125em] shrink-0" /> : null}
{iconOnly ? <span className="sr-only">{labelNode}</span> : labelNode}
</Button>
</div>
);
}
import type { SectionCopySchemaField } from '../types';
export const schema: SectionCopySchemaField[] = [
{ key: 'label', label: 'Label', type: 'text', placeholder: 'Get started', maxLength: 40 },
{ key: 'href', label: 'Link', type: 'link', placeholder: 'https://' },
{
key: 'variant',
label: 'Variant',
type: 'text',
placeholder: 'solid',
advanced: true,
options: [
{ value: 'solid', label: 'Solid' },
{ value: 'soft', label: 'Soft' },
{ value: 'outline', label: 'Outline' },
{ value: 'ghost', label: 'Ghost' },
],
},
{
key: 'size',
label: 'Size',
type: 'text',
placeholder: '2',
maxLength: 1,
advanced: true,
options: [
{ value: '1', label: 'Small' },
{ value: '', label: 'Medium' },
{ value: '3', label: 'Large' },
],
},
{
key: 'tone',
label: 'Tone',
type: 'text',
placeholder: 'accent',
maxLength: 7,
advanced: true,
options: [
{ value: '', label: 'Accent' },
{ value: 'neutral', label: 'Neutral' },
],
},
{
key: 'width',
label: 'Width',
type: 'text',
placeholder: '',
maxLength: 4,
advanced: true,
helpText: 'Full stretches the button to fill the width of its container. Fit sizes it to the label.',
options: [
{ value: '', label: 'Fit' },
{ value: 'full', label: 'Full' },
],
},
{
key: 'icon',
label: 'Icon',
type: 'text',
placeholder: '',
maxLength: 9,
advanced: true,
helpText: 'Shows a mark before the label, drawn in the site icon style. Download also saves the linked file instead of opening it. None shows the label alone.',
options: [
{ value: '', label: 'None' },
{ value: 'instagram', label: 'Instagram' },
{ value: 'tiktok', label: 'TikTok' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'x', label: 'X' },
{ value: 'linkedin', label: 'LinkedIn' },
{ value: 'github', label: 'GitHub' },
{ value: 'mail', label: 'Email' },
{ value: 'link', label: 'Link' },
{ value: 'download', label: 'Download' },
],
},
{
key: 'iconOnly',
label: 'Icon only',
type: 'text',
placeholder: '',
maxLength: 4,
advanced: true,
helpText: 'On shows the icon alone and keeps the label for screen readers. Off shows both. Needs an icon.',
options: [
{ value: '', label: 'Off' },
{ value: 'true', label: 'On' },
],
},
];
import type { SectionMeta } from '../types';
export const meta: SectionMeta = {
id: 'block-button',
displayName: 'Button',
family: 'component',
tags: ['component', 'button', 'cta'],
};
import type { BrandContext } from '@/lib/brandContext';
export function writeCopy(_ctx: BrandContext) {
return { label: 'Button', href: '#' };
}