Skip to content

Partnership enquiry

Multi-select partnership types as selectable cards, plus a substantive proposal field.

Businessstarterpartnershipbusiness-developmentmulti-select

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.

All forms