Skip to content
txbrendan/ui

Contact Page

beta

A whole contact page, built from our website components: a floating navbar, a page title over the ways to reach you, a form in one soft card, a FAQ, and a footer.

Built from Button, Field Frame, Input, Textarea, Select, and Glide Highlight.

Preview

Talk to us. A person reads every message.

Ask about plans, get help with your account, or tell us what to build next.

Send a message

Topic

We answer within one working day.

Before you write.

A form whose send fails, with a hint and a phone field

Book a call

We call between 9 and 5.

We call within two working days.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the template. It also adds every component it uses. Its own files go in your components folder, and they are yours to edit.

    npx shadcn@latest add @txbrendan/contact-page

Use it for

  • The contact page of a product site. Render it as the whole page, from your app's page file.
  • Write every word in the props: one object for the navbar, the header, the ways to reach you, the form, the FAQ, and the footer.
  • Keep the form short: a name, an email, a topic, and a message. Every field you add loses people.
  • Send the message in `form.onSubmit`, like a fetch to your own route. Throw when it fails, and the form says so and keeps what they wrote.
  • From a server page, pass a Server Action as `form.onSubmit`, since a server page cannot pass a plain function. Or render the page from a client file.
  • Use `channels` for the places people can go instead, like a sales email, support, or the docs. Give each one a `label`, like the address, which reads as the link.
  • Use the FAQ for what people ask before they write, like when you answer.
  • Pass your router's link once in `renderLink`. The navbar, the ways to reach you, and the footer all use it.
  • Set `embedded` when you show the page inside another page, like a gallery, so it has no second main.

Avoid it for

  • A form with no `onSubmit`. It shows the sent note, but the message goes nowhere.
  • A promise you can't keep in `note`, like an answer within an hour.
  • Asking for a phone number when email will do.

Props

  • navbar

    The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem.

    NavbarProps

  • header

    The headline at the top, left-aligned over the ways to reach you. It renders as the page's one h1.

    HeadlineProps

  • channels

    Ways to reach you besides the form, one row each, with a highlight that glides from row to row. Each title must be unique, since it is the key.

    { title: string; description?: ReactNode; href: string; label?: string }[]

  • channelsLabel

    Names the ways to reach you for screen readers.

    string · default "Other ways to reach us"

  • form

    Required. The form's `fields` (each with a unique `name`, a `label`, and a `type` of text, email, tel, textarea, or select), its `title`, `submitLabel`, `note`, `onSubmit`, the `sent` note, and the `failed` line.

    ContactFormProps

  • faq

    Questions people ask before they write, under the form. Its id defaults to "faq".

    { id?: string; headline?: HeadlineProps; faq: FaqProps }

  • footer

    The Footer's props.

    FooterProps

  • renderLink

    Draws every link in the navbar, the ways to reach you, and the footer, unless a part sets its own.

    (props: NavbarLinkProps) => ReactNode · default a plain <a>

  • embedded

    Renders the content in a div, not a main, for a preview inside another page.

    boolean · default false

  • className

    Extra classes on the root, which holds the container query.

    string

Accessibility

  • The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.
  • The header renders the page's one h1. The form's title and the FAQ's headline are h2s.
  • Every field has a visible label, and its hint is linked to it, so screen readers read it.
  • Send checks every field, gives each one that is wrong a red edge, and moves focus to the first. One line under the button says what to fix, and each wrong field is linked to it. It is under the button, so it never pushes the button. Typing in a field clears its edge.
  • While it sends, the button is busy and takes no second press. A failure, or no answer within 20 seconds, is announced under the button, and the form keeps what was typed.
  • Once sent, focus moves to the sent note, which is announced and scrolls clear of the floating navbar. For a moment after, a second tap goes nowhere, so a double tap on Send cannot land on what slid under it. Send another clears every field and moves focus to the first.
  • The form and the sent note slide and fade in place of each other. With reduced motion they only fade, and with motion off they swap at once.
  • Each part keeps its own accessibility, listed on its page.

Examples

A short contact page

<ContactPage
  navbar={{ brand: "Workspace", links: [{ href: "/contact", label: "Contact", current: true }] }}
  header={{ title: "Talk to us.", muted: "We answer every message." }}
  channels={[{ title: "Support", description: "For help with your account.", href: "mailto:help@example.com", label: "help@example.com" }]}
  form={{
    fields: [
      { name: "name", label: "Name", required: true, autoComplete: "name", half: true },
      { name: "email", label: "Email", type: "email", required: true, autoComplete: "email", half: true },
      { name: "message", label: "Message", type: "textarea", required: true },
    ],
    note: "We answer within one working day.",
    onSubmit: async (values) => {
      const response = await fetch("/api/contact", { method: "POST", body: JSON.stringify(values) });
      if (!response.ok) throw new Error("Not sent");
    },
  }}
  footer={{
    brand: "Workspace",
    columns: [{ title: "Company", links: [{ href: "/contact", label: "Contact" }] }],
    legal: "© 2026 Workspace",
  }}
/>