Partnership enquiry
Multi-select partnership types as selectable cards, plus a substantive proposal field.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, required, url } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Partnership enquiry
*
* Partnership forms fail when they ask "tell us about your company" and
* nothing else. This one asks for the shape of the partnership up front, which
* is the only question that determines whether there is a conversation to have.
*/
const types = [
{
id: 'technology',
label: 'Technology integration',
description: 'Our products work better together.',
},
{ id: 'reseller', label: 'Reseller or agency', description: 'We implement for our own clients.' },
{ id: 'content', label: 'Content or education', description: 'Courses, books, workshops.' },
{ id: 'community', label: 'Community', description: 'Events, meetups, open source.' },
]
export default function PartnershipForm() {
const form = useDemoForm({
schema: {
company: { validators: [required('Company')] },
website: { validators: [required('Website'), url()] },
contact: { validators: [required('Contact name')] },
email: { validators: [required('Email'), email()] },
types: { initial: 'technology', validators: [required('Partnership type')] },
proposal: { validators: [required('Proposal'), minLength(40, 'Proposal')] },
},
})
const selected = new Set((form.values.types ?? '').split(',').filter(Boolean))
const toggle = (id: string, checked: boolean) => {
const next = new Set(selected)
if (checked) next.add(id)
else next.delete(id)
form.setValue('types', Array.from(next).join(','))
}
return (
<FormShell
title="Partner with us"
description="We review partnership enquiries fortnightly."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Submit proposal"
submittingLabel="Submitting"
onReset={form.reset}
successTitle="Proposal received"
width="lg"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="company" label="Company" required error={form.error('company')}>
{(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
</Field>
<Field name="website" label="Website" required error={form.error('website')}>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="example.com"
{...form.field('website')}
/>
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="contact" label="Contact name" required error={form.error('contact')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('contact')} />}
</Field>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('email')} />
)}
</Field>
</div>
<Fieldset
legend="Type of partnership"
name="types"
required
error={form.error('types')}
hint="Select every type that applies."
>
{types.map((type) => (
<Checkbox
key={type.id}
id={`partnership-${type.id}`}
name="types"
value={type.id}
appearance="card"
label={type.label}
description={type.description}
checked={selected.has(type.id)}
onChange={(event) => toggle(type.id, event.target.checked)}
/>
))}
</Fieldset>
<Field
name="proposal"
label="What do you have in mind?"
required
error={form.error('proposal')}
hint="What you would build or offer, and what you would need from us."
>
{(field) => <Textarea {...field} rows={6} {...form.field('proposal')} />}
</Field>
</FormShell>
)
}
components/blocks/forms/partnership.tsx
'use client'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, required, url } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Partnership enquiry
*
* Partnership forms fail when they ask "tell us about your company" and
* nothing else. This one asks for the shape of the partnership up front, which
* is the only question that determines whether there is a conversation to have.
*/
const types = [
{
id: 'technology',
label: 'Technology integration',
description: 'Our products work better together.',
},
{ id: 'reseller', label: 'Reseller or agency', description: 'We implement for our own clients.' },
{ id: 'content', label: 'Content or education', description: 'Courses, books, workshops.' },
{ id: 'community', label: 'Community', description: 'Events, meetups, open source.' },
]
export default function PartnershipForm() {
const form = useDemoForm({
schema: {
company: { validators: [required('Company')] },
website: { validators: [required('Website'), url()] },
contact: { validators: [required('Contact name')] },
email: { validators: [required('Email'), email()] },
types: { initial: 'technology', validators: [required('Partnership type')] },
proposal: { validators: [required('Proposal'), minLength(40, 'Proposal')] },
},
})
const selected = new Set((form.values.types ?? '').split(',').filter(Boolean))
const toggle = (id: string, checked: boolean) => {
const next = new Set(selected)
if (checked) next.add(id)
else next.delete(id)
form.setValue('types', Array.from(next).join(','))
}
return (
<FormShell
title="Partner with us"
description="We review partnership enquiries fortnightly."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Submit proposal"
submittingLabel="Submitting"
onReset={form.reset}
successTitle="Proposal received"
width="lg"
>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="company" label="Company" required error={form.error('company')}>
{(field) => <Input {...field} autoComplete="organization" {...form.field('company')} />}
</Field>
<Field name="website" label="Website" required error={form.error('website')}>
{(field) => (
<Input
{...field}
inputMode="url"
placeholder="example.com"
{...form.field('website')}
/>
)}
</Field>
</div>
<div className="grid gap-stack sm:grid-cols-2">
<Field name="contact" label="Contact name" required error={form.error('contact')}>
{(field) => <Input {...field} autoComplete="name" {...form.field('contact')} />}
</Field>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => (
<Input {...field} type="email" autoComplete="email" {...form.field('email')} />
)}
</Field>
</div>
<Fieldset
legend="Type of partnership"
name="types"
required
error={form.error('types')}
hint="Select every type that applies."
>
{types.map((type) => (
<Checkbox
key={type.id}
id={`partnership-${type.id}`}
name="types"
value={type.id}
appearance="card"
label={type.label}
description={type.description}
checked={selected.has(type.id)}
onChange={(event) => toggle(type.id, event.target.checked)}
/>
))}
</Fieldset>
<Field
name="proposal"
label="What do you have in mind?"
required
error={form.error('proposal')}
hint="What you would build or offer, and what you would need from us."
>
{(field) => <Textarea {...field} rows={6} {...form.field('proposal')} />}
</Field>
</FormShell>
)
}
components/ui/choice.tsx
import type { InputHTMLAttributes, ReactNode } from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Checkbox / Radio / Switch
*
* All three keep a real, focusable native input in the DOM and paint the
* visible control with a sibling element. That keeps `aria-checked`, form
* submission, `:checked`, `:disabled` and keyboard behaviour native, while
* still allowing a token-driven appearance.
*
* The native input is positioned over the visual control rather than hidden
* with `display:none`, so the tap target is the full 44px row on touch.
*/
const controlBox = cn(
'pointer-events-none flex shrink-0 items-center justify-center border transition-colors duration-150 ease-standard',
'border-line-strong bg-surface text-accent-ink',
'peer-hover:border-accent',
'peer-checked:border-accent peer-checked:bg-accent',
'peer-disabled:opacity-50',
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
'peer-aria-[invalid=true]:border-danger',
// The tick/dot is a *descendant* of this box, not a sibling of the input, so
// the peer variant has to reach through with a child selector.
'[&>*]:opacity-0 peer-checked:[&>*]:opacity-100',
)
const nativeInput = cn(
'peer absolute inset-0 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed',
)
export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
label: ReactNode
description?: ReactNode
/** `card` turns the whole row into a bordered, selectable surface. */
appearance?: 'inline' | 'card'
}
export function Checkbox({
label,
description,
appearance = 'inline',
className,
id,
indeterminate,
...props
}: ChoiceProps & { indeterminate?: boolean }) {
return (
<label
className={cn(
'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
appearance === 'card' &&
'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
appearance === 'inline' && 'py-1.5',
className,
)}
htmlFor={id}
>
<span className="relative flex size-4.5 shrink-0 items-center justify-center">
<input
type="checkbox"
id={id}
className={nativeInput}
aria-checked={indeterminate ? 'mixed' : undefined}
{...props}
/>
<span className={cn(controlBox, 'size-4.5 rounded-sm')} aria-hidden="true">
{indeterminate ? (
<Minus className="size-3" strokeWidth={3} />
) : (
<Check className="size-3" strokeWidth={3} />
)}
</span>
</span>
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
</label>
)
}
export function Radio({
label,
description,
appearance = 'inline',
className,
id,
...props
}: ChoiceProps) {
return (
<label
className={cn(
'group relative flex min-h-9 cursor-pointer items-start gap-2.5 text-sm',
appearance === 'card' &&
'min-h-11 rounded-md border border-line bg-surface p-3 has-[:checked]:border-accent has-[:checked]:bg-accent-soft has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
appearance === 'inline' && 'py-1.5',
className,
)}
htmlFor={id}
>
<span className="relative flex size-4.5 shrink-0 items-center justify-center">
<input type="radio" id={id} className={nativeInput} {...props} />
<span className={cn(controlBox, 'size-4.5 rounded-full')} aria-hidden="true">
<span className="size-1.5 rounded-full bg-current" />
</span>
</span>
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
</label>
)
}
export interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
label: ReactNode
description?: ReactNode
/** Places the switch on the trailing edge — the settings-row convention. */
align?: 'leading' | 'trailing'
}
export function Switch({
label,
description,
align = 'leading',
className,
id,
...props
}: SwitchProps) {
const control = (
<span className="relative inline-flex h-5 w-9 shrink-0 items-center">
<input type="checkbox" role="switch" id={id} className={nativeInput} {...props} />
<span
className={cn(
'pointer-events-none h-5 w-9 rounded-full border border-line-strong bg-surface-sunken transition-colors duration-150 ease-standard',
'peer-checked:border-accent peer-checked:bg-accent',
'peer-disabled:opacity-50',
'peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]',
)}
aria-hidden="true"
/>
<span
className={cn(
'pointer-events-none absolute left-0.5 size-4 rounded-full bg-surface shadow-sm transition-transform duration-150 ease-standard',
'border border-line peer-checked:translate-x-4 peer-checked:border-transparent',
)}
aria-hidden="true"
/>
</span>
)
return (
<label
className={cn(
'flex min-h-9 cursor-pointer items-start gap-3 py-1.5 text-sm has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60',
align === 'trailing' && 'justify-between',
className,
)}
htmlFor={id}
>
{align === 'leading' ? control : null}
<span className="min-w-0 flex-1 select-none">
<span className="block leading-snug font-medium text-ink">{label}</span>
{description ? (
<span className="mt-0.5 block text-xs leading-normal text-ink-muted">{description}</span>
) : null}
</span>
{align === 'trailing' ? control : null}
</label>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Partnership forms fail when they ask “tell us about your company” and nothing else. Asking for the shape of the partnership up front is the only question that determines whether there is a conversation to have.
- Card checkboxes earn their space here because each type needs a sentence of explanation.
- The proposal field has a real minimum length — a one-line proposal is not a proposal.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Card checkboxes
- Long-form proposal
- Validation on empty selection
Accessibility
- Group validation
- The empty-selection error is reported on the Fieldset, not on one arbitrary checkbox.
- Card targets
- Each card is a 44px-plus target on touch.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsSales enquiry
A qualification form that asks only what changes the conversation — and deliberately omits budget.
starter4 variantsVendor onboarding
A compliance form grouped into panels, with country-aware identifiers and document upload.
advanced4 variantsCheckbox
Multi-select control with inline and card appearances, plus a genuine indeterminate state for bulk selection.
starter5 variants