Form, from the Building blocks group of the nertia component library.
npx shadcn@latest add https://ner.sh/r/block-form.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-formnpm create nersh@latest my-site -- --site https://nertia.ai/components/building-blocks/element-block-form/site.json scaffolds a project from it.{
"slug": "element-block-form",
"mode": "builder",
"status": "draft",
"skinId": "zinc",
"composition": {
"id": "element-block-form",
"sections": [
{
"id": "block-form",
"instanceId": "stage-block-form"
}
]
},
"copy": {
"stage-block-form.submitLabel": "Send",
"stage-block-form.email": "hello@yourcompany.com",
"stage-block-form.successMessage": "Thanks. We will be in touch soon."
}
}
import type { Section } from '../types';
import { Component } from './Component';
import { schema } from './schema';
import { meta } from './meta';
import { writeCopy } from './writeCopy';
export const blockForm: Section = { meta, schema, Component, writeCopy };
'use client';
import { useState, type FormEvent } from 'react';
import { Editable } from '@/components/site/EditableText';
import { compositionEditPath } from '@/lib/composition/compositionEditableCopy';
import type { SectionProps } from '../types';
import { Button, Form, Input } from '@/components/foundation';
import { LayoutDropHint } from '@/components/site/LayoutDropHint';
import {
buildContactPayloadFromFormData,
postSiteContact,
} from '@/lib/siteContactSubmit';
type State = 'idle' | 'submitting' | 'success' | 'error';
export function Component({
copy,
siteSlug,
instanceId,
slotContent,
builderInsert,
}: SectionProps) {
const edit = (slotKey: string) => compositionEditPath(instanceId, slotKey);
const submitLabel = copy.submitLabel || 'Send';
const successMessage = copy.successMessage ?? '';
const [state, setState] = useState<State>('idle');
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (state === 'submitting') return;
if (!siteSlug) {
setError('Form submissions need a published site slug.');
setState('error');
return;
}
const formData = new FormData(e.currentTarget);
const payload = buildContactPayloadFromFormData(formData, {
instanceId,
});
if (!payload.name && !payload.email && !payload.phone && !payload.message) {
setError('Please fill in at least one field.');
setState('error');
return;
}
setState('submitting');
setError(null);
try {
await postSiteContact(siteSlug, payload);
setState('success');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setState('error');
}
}
if (state === 'success') {
return (
<div data-section="block-form">
<p className="site-type-body mt-2 text-center text-[var(--muted-fg)]">
<Editable path={edit('successMessage')!}>
{successMessage.trim() || "Thanks. We will be in touch soon."}
</Editable>
</p>
</div>
);
}
return (
<div data-section="block-form">
<span className="sr-only">
<Editable path={edit('email')!}>{copy.email ?? ''}</Editable>
</span>
<span className="sr-only">
<Editable path={edit('successMessage')!}>{successMessage}</Editable>
</span>
<Form onSubmit={handleSubmit}>
{slotContent ?? (
<LayoutDropHint
label="Add Input, Textarea…"
className="min-h-[3rem] py-2"
action={
builderInsert
? {
label: 'Open Components',
onClick: () => builderInsert.openTab('components'),
}
: undefined
}
/>
)}
{/* Spam honeypot: absolutely positioned, so it is not a flex item and adds no gap. */}
<Input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden
className="absolute left-[-9999px] h-0 w-0 opacity-0"
defaultValue=""
/>
{/* `site-lead-form-error` / `site-lead-form-actions` are the scoped CSS hooks
emitted by buildSiteLeadFormCss; the class map itself is not a block dependency. */}
{error ? <p className="site-lead-form-error">{error}</p> : null}
<div className="site-lead-form-actions">
<Button type="submit" variant="solid" disabled={state === 'submitting'}>
{state === 'submitting' ? 'Sending…' : (
<Editable path={edit('submitLabel')!}>{submitLabel}</Editable>
)}
</Button>
</div>
</Form>
</div>
);
}
import type { SectionCopySchemaField } from '../types';
export const schema: SectionCopySchemaField[] = [
{
key: 'submitLabel',
label: 'Submit button',
type: 'text',
placeholder: 'Send',
group: 'Form',
},
{
key: 'email',
label: 'Notification email',
type: 'text',
placeholder: 'owner@yourbusiness.com',
group: 'Form',
},
{
key: 'successMessage',
label: 'Success message',
type: 'textarea',
placeholder: "Thanks. We will be in touch soon.",
group: 'Form',
},
];
import type { SectionMeta } from '../types';
export const meta: SectionMeta = {
id: 'block-form',
displayName: 'Form',
family: 'component',
tags: ['component', 'atom', 'form', 'submit'],
bucket: 'atom',
};
import type { BrandContext } from '@/lib/brandContext';
export function writeCopy(_ctx: BrandContext) {
return {
submitLabel: 'Send',
email: '',
successMessage: "Thanks. We will be in touch soon.",
};
}