# Contact Sales Form

> Contact sales form in one narrow column: first and last name, optional company, email, country select with phone, message and a privacy consent checkbox. Validated with react-hook-form and zod.

A shadcn/ui block from the [@01orbital](https://01orbital.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/01orbital/contact-form-01).

- Page, with a live preview: https://shadcn.exchange/r/01orbital/contact-form-01
- Screenshot: https://shadcn.exchange/shot/01orbital/contact-form-01/light.png
- Registry item: https://01orbital.dev/r/contact-form-01.json
- Categories: contact
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/01orbital/contact-form-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@hookform/resolvers`, `react-hook-form`, `zod`
- Registry items: `@01orbital/button`, `@01orbital/checkbox`, `@01orbital/field`, `@01orbital/heading`, `@01orbital/input`, `@01orbital/native-select`, `@01orbital/section`, `@01orbital/textarea`

## Files

### `blocks/contact/contact-form-01/index.tsx`

```tsx
"use client"

import { useId } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"

import { Heading } from "@/components/editorial/heading"
import {
  Section,
  SectionContent,
  SectionDescription,
  SectionHeader,
  SectionTitleGroup,
} from "@/components/editorial/section"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  NativeSelect,
  NativeSelectOption,
} from "@/components/ui/native-select"
import { Textarea } from "@/components/ui/textarea"

// The copy: change text here, not in the markup.
const COPY = {
  title: "Contact sales",
  body: "Tell us about the site you are building and a person on the team replies within one working day.",
  firstName: "First name",
  lastName: "Last name",
  company: "Company",
  email: "Email",
  country: "Country",
  phone: "Phone number",
  message: "Message",
  optional: "Optional",
  consent: {
    before: "By selecting this, you agree to our ",
    link: "privacy policy",
    after: ".",
  },
  submit: "Let’s talk",
  errors: {
    firstName: "Enter your first name.",
    lastName: "Enter your last name.",
    email: "Enter a valid email address.",
    message: "Tell us a little about the project.",
    consent: "Agree to the privacy policy to continue.",
  },
} as const

// value is the ISO 3166 country code the form submits with the phone.
const COUNTRIES = [
  { value: "US", label: "United States (+1)" },
  { value: "CA", label: "Canada (+1)" },
  { value: "GB", label: "United Kingdom (+44)" },
  { value: "DE", label: "Germany (+49)" },
  { value: "FR", label: "France (+33)" },
  { value: "AU", label: "Australia (+61)" },
] as const

// What the form validates and submits. Messages come from COPY.errors.
const schema = z.object({
  firstName: z.string().trim().min(1, COPY.errors.firstName),
  lastName: z.string().trim().min(1, COPY.errors.lastName),
  company: z.string().trim(),
  email: z.email(COPY.errors.email),
  country: z.enum(COUNTRIES.map((country) => country.value)),
  phone: z.string().trim(),
  message: z.string().trim().min(1, COPY.errors.message),
  consent: z.boolean().refine((agreed) => agreed, COPY.errors.consent),
})

type ContactValues = z.infer<typeof schema>

export default function ContactForm01() {
  const id = useId()
  const form = useForm<ContactValues>({
    resolver: zodResolver(schema),
    defaultValues: {
      firstName: "",
      lastName: "",
      company: "",
      email: "",
      country: "US",
      phone: "",
      message: "",
      consent: false,
    },
  })

  // Your send action goes here; until then the form only validates.
  function onSubmit(values: ContactValues) {
    void values
  }

  return (
    <Section>
      <SectionContent width="sm" size="lg">
        {/* The header shares the form's column and its left edge. */}
        <SectionHeader className="mx-auto max-w-xl">
          <SectionTitleGroup>
            <Heading>{COPY.title}</Heading>
          </SectionTitleGroup>
          <SectionDescription>{COPY.body}</SectionDescription>
        </SectionHeader>
        <form
          onSubmit={form.handleSubmit(onSubmit)}
          noValidate
          className="mx-auto w-full max-w-xl"
        >
          <FieldGroup>
            <div className="grid gap-4 @2xl/section:grid-cols-2">
              <Controller
                name="firstName"
                control={form.control}
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor={`${id}-first`}>
                      {COPY.firstName}
                    </FieldLabel>
                    <Input
                      {...field}
                      id={`${id}-first`}
                      autoComplete="given-name"
                      required
                      aria-invalid={fieldState.invalid}
                    />
                    <FieldError errors={[fieldState.error]} />
                  </Field>
                )}
              />
              <Controller
                name="lastName"
                control={form.control}
                render={({ field, fieldState }) => (
                  <Field data-invalid={fieldState.invalid}>
                    <FieldLabel htmlFor={`${id}-last`}>
                      {COPY.lastName}
                    </FieldLabel>
                    <Input
                      {...field}
                      id={`${id}-last`}
                      autoComplete="family-name"
                      required
                      aria-invalid={fieldState.invalid}
                    />
                    <FieldError errors={[fieldState.error]} />
                  </Field>
                )}
              />
            </div>
            <Controller
              name="company"
              control={form.control}
              render={({ field }) => (
                <Field>
                  <FieldLabel htmlFor={`${id}-company`}>
                    {COPY.company}
                  </FieldLabel>
                  <Input
                    {...field}
                    id={`${id}-company`}
                    autoComplete="organization"
                    aria-describedby={`${id}-company-note`}
                  />
                  <FieldDescription id={`${id}-company-note`}>
                    {COPY.optional}
                  </FieldDescription>
                </Field>
              )}
            />
            <Controller
              name="email"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor={`${id}-email`}>{COPY.email}</FieldLabel>
                  <Input
                    {...field}
                    id={`${id}-email`}
                    type="email"
                    autoComplete="email"
                    required
                    aria-invalid={fieldState.invalid}
                  />
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <div className="grid gap-4 @2xl/section:grid-cols-[auto_1fr]">
              <Controller
                name="country"
                control={form.control}
                render={({ field }) => (
                  <Field>
                    <FieldLabel htmlFor={`${id}-country`}>
                      {COPY.country}
                    </FieldLabel>
                    <NativeSelect
                      {...field}
                      id={`${id}-country`}
                      autoComplete="country"
                      className="w-full"
                    >
                      {COUNTRIES.map((country) => (
                        <NativeSelectOption
                          key={country.value}
                          value={country.value}
                        >
                          {country.label}
                        </NativeSelectOption>
                      ))}
                    </NativeSelect>
                  </Field>
                )}
              />
              <Controller
                name="phone"
                control={form.control}
                render={({ field }) => (
                  <Field>
                    <FieldLabel htmlFor={`${id}-phone`}>
                      {COPY.phone}
                    </FieldLabel>
                    <Input
                      {...field}
                      id={`${id}-phone`}
                      type="tel"
                      autoComplete="tel-national"
                      placeholder="555-123-4567"
                      aria-describedby={`${id}-phone-note`}
                    />
                    <FieldDescription id={`${id}-phone-note`}>
                      {COPY.optional}
                    </FieldDescription>
                  </Field>
                )}
              />
            </div>
            <Controller
              name="message"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor={`${id}-message`}>
                    {COPY.message}
                  </FieldLabel>
                  <Textarea
                    {...field}
                    id={`${id}-message`}
                    rows={4}
                    required
                    aria-invalid={fieldState.invalid}
                  />
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <Controller
              name="consent"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <Field orientation="horizontal">
                    <Checkbox
                      ref={field.ref}
                      id={`${id}-consent`}
                      name={field.name}
                      checked={field.value}
                      onCheckedChange={(checked) =>
                        field.onChange(checked === true)
                      }
                      onBlur={field.onBlur}
                      required
                      aria-invalid={fieldState.invalid}
                    />
                    <FieldLabel
                      htmlFor={`${id}-consent`}
                      className="font-normal"
                    >
                      <span>
                        {COPY.consent.before}
                        <a
                          href="#"
                          className="font-semibold underline underline-offset-4"
                        >
                          {COPY.consent.link}
                        </a>
                        {COPY.consent.after}
                      </span>
                    </FieldLabel>
                  </Field>
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <Field>
              <Button type="submit" size="lg">
                {COPY.submit}
              </Button>
            </Field>
          </FieldGroup>
        </form>
      </SectionContent>
    </Section>
  )
}
```
