Contact
Contact Split
Every way to reach you beside an enquiry form with services checkboxes and sending states.
Layout and motion from the Nitip template — see it in context"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { UnderlineLink } from "@/components/ui/underline-link";
import { cn } from "@/lib/utils";
/*
* Contact Split — every way to reach you (email and phone, location, opening hours, social links) beside an
* enquiry form, divided by a line.
* Source: marcframe/nitip → components/sections/ContactSection.tsx (details and form; the page title above it is
* hero-title) + design-system Form, FormField, CheckboxGroup.
*
* Layout kept from Framer: below 1024px the details, a line and the form stack; from 1024px the details fill the row,
* a vertical line sits 96px from each side, and the form takes half the row (up to 800px). Each details group has a
* line on top; rows put the label at the left and the value at the right. The form: a heading, name / email / phone in
* two columns from 768px, the services as checkboxes, a message, a full-width submit.
* Behavior kept: links underline on hover; "Get directions" is a text link whose underline grows and arrow turns
* (underline-link); the submit shows a spinner while sending, "Thank you" and resets on success, "Something went wrong"
* on failure, announced to screen readers. Added: typing again puts the button back to "Submit". The form remounts to
* clear itself, since form.reset() doesn't clear Base UI checkboxes.
* Sending is yours: pass `onSubmit`. Without it the form only pretends to send, so the demo works.
* The section heading is for screen readers; the details groups and the form heading are h3.
*/
export type ContactSplitProps = {
/** Section heading, visually hidden (the page title above says it). */
title?: string;
email?: string;
phone?: string;
address?: string;
/** Map link for "Get directions"; opens in a new tab. */
directions?: string;
hours?: { days: string; time: string }[];
socials?: { label: string; href: string }[];
formTitle?: string;
/** Checkbox options; the checked ones are sent as `services`. */
services?: string[];
/** Called with the form's data; throw (or reject) to show the error state. */
onSubmit?: (data: FormData) => Promise<void> | void;
labels?: {
contact: string;
email: string;
phone: string;
location: string;
directions: string;
hours: string;
social: string;
services: string;
message: string;
submit: string;
sending: string;
success: string;
error: string;
};
className?: string;
};
const defaults = {
title: "Contact details and enquiry form",
email: "hello@northstar.example",
phone: "+1 (555) 014 2290",
address: "Northstar Studio, 480 Market Street, Suite 12, San Francisco, CA 94104",
directions: "https://maps.google.com/?q=480+Market+Street+San+Francisco",
hours: [
{ days: "Monday – Friday", time: "9:00 AM – 6:00 PM" },
{ days: "Saturday", time: "10:00 AM – 2:00 PM" },
{ days: "Sunday", time: "Closed" },
],
socials: [
{ label: "Instagram", href: "https://instagram.com" },
{ label: "LinkedIn", href: "https://linkedin.com" },
{ label: "YouTube", href: "https://youtube.com" },
{ label: "X", href: "https://x.com" },
],
formTitle: "Tell us about your project and we'll reply within a day.",
services: ["Social media", "SEO", "Paid advertising", "Content", "Brand strategy", "Web design"],
labels: {
contact: "Contact us",
email: "Email",
phone: "Phone",
location: "Our location",
directions: "Get directions",
hours: "Business hours",
social: "Connect with us",
services: "Services",
message: "Message",
submit: "Submit",
sending: "Sending…",
success: "Thank you",
error: "Something went wrong",
},
};
const fields = [
{ name: "firstName", label: "First name", required: true, autoComplete: "given-name" },
{ name: "lastName", label: "Last name", required: true, autoComplete: "family-name" },
{ name: "email", label: "Email", type: "email", required: true, autoComplete: "email" },
{ name: "phone", label: "Phone number", type: "tel", autoComplete: "tel" },
];
type Status = "idle" | "pending" | "success" | "error";
const textLink = "transition-colors hover:text-foreground/80 hover:underline hover:underline-offset-4";
export function ContactSplit({
title = defaults.title,
email = defaults.email,
phone = defaults.phone,
address = defaults.address,
directions = defaults.directions,
hours = defaults.hours,
socials = defaults.socials,
formTitle = defaults.formTitle,
services = defaults.services,
onSubmit,
labels = defaults.labels,
className,
}: ContactSplitProps) {
const [status, setStatus] = React.useState<Status>("idle");
/* Remounting the form clears every field — form.reset() leaves Base UI checkboxes checked. */
const [formKey, setFormKey] = React.useState(0);
const id = React.useId();
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setStatus("pending");
try {
if (onSubmit) await onSubmit(data);
else await new Promise((resolve) => setTimeout(resolve, 900));
setFormKey((key) => key + 1);
setStatus("success");
} catch {
setStatus("error");
}
}
const buttonLabel =
status === "pending" ? labels.sending : status === "success" ? labels.success : status === "error" ? labels.error : labels.submit;
return (
<section className={cn("py-(--section-y)", className)}>
<h2 className="sr-only">{title}</h2>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter) lg:flex-row lg:items-start lg:gap-24">
<div className="flex min-w-0 flex-col lg:flex-1">
<Group title={labels.contact}>
<Row label={labels.email}>
<a href={`mailto:${email}`} className={textLink}>
{email}
</a>
</Row>
<Row label={labels.phone}>
<a href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={textLink}>
{phone}
</a>
</Row>
</Group>
<Group title={labels.location}>
<div className="flex items-end justify-between gap-5">
<p className="max-w-md flex-1">{address}</p>
{directions && (
<UnderlineLink href={directions} className="shrink-0">
{labels.directions}
</UnderlineLink>
)}
</div>
</Group>
<Group title={labels.hours}>
{hours.map((row) => (
<Row key={row.days} label={row.days}>
{row.time}
</Row>
))}
</Group>
<Group title={labels.social} className="items-start">
{socials.map((social) => (
<a key={social.href} href={social.href} target="_blank" rel="noopener noreferrer" className={textLink}>
{social.label}
</a>
))}
</Group>
</div>
<hr className="border-border lg:h-auto lg:self-stretch lg:border-t-0 lg:border-l" />
<form
key={formKey}
onSubmit={submit}
onInput={() => (status === "success" || status === "error") && setStatus("idle")}
className="flex w-full flex-col items-start gap-8 lg:w-1/2 lg:max-w-200 lg:shrink-0"
>
<h3 className="max-w-md font-heading text-h3 font-semibold tracking-tight text-balance">{formTitle}</h3>
<div className="flex w-full flex-col gap-5">
<div className="grid gap-5 md:grid-cols-2">
{fields.map((field) => (
<div key={field.name} className="flex flex-col gap-2">
<Label htmlFor={`${id}-${field.name}`}>
{field.label}
{field.required && (
<span aria-hidden className="text-muted-foreground">
*
</span>
)}
</Label>
<Input
id={`${id}-${field.name}`}
name={field.name}
type={field.type ?? "text"}
required={field.required}
autoComplete={field.autoComplete}
className="h-10"
/>
</div>
))}
</div>
{services.length > 0 && (
<div role="group" aria-labelledby={`${id}-services`} className="flex flex-col gap-3">
<p id={`${id}-services`} className="text-sm font-medium">
{labels.services}
</p>
<div className="grid gap-3 sm:grid-cols-2">
{services.map((service) => (
<Label key={service} className="font-normal">
<Checkbox name="services" value={service} />
{service}
</Label>
))}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-message`}>{labels.message}</Label>
<Textarea id={`${id}-message`} name="message" className="min-h-50" />
</div>
</div>
<Button
type="submit"
size="lg"
disabled={status === "pending"}
className={cn("h-10 w-full", status === "error" && "bg-destructive/10 text-destructive hover:bg-destructive/20")}
>
{status === "pending" && (
<svg viewBox="0 0 16 16" className="size-4 animate-spin motion-reduce:animate-none" fill="none" aria-hidden>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
{buttonLabel}
</Button>
</form>
<p role="status" className="sr-only">
{status === "success" ? labels.success : status === "error" ? labels.error : ""}
</p>
</div>
</section>
);
}
/* A details group: its title, then rows 20px apart, a line on top. */
function Group({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) {
return (
<div className={cn("flex flex-col gap-5 border-t border-border py-5", className)}>
<h3 className="font-heading text-h3 font-semibold tracking-tight">{title}</h3>
{children}
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-5">
<p className="text-muted-foreground">{label}</p>
<p className="text-right">{children}</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/contact-split.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/underline-link.json2. Add the shadcn/ui primitives it uses.
npx shadcn@latest add button checkbox input label textarea3. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json4. Copy the block into
components/contact-split.tsx."use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { UnderlineLink } from "@/components/ui/underline-link"; import { cn } from "@/lib/utils"; /* * Contact Split — every way to reach you (email and phone, location, opening hours, social links) beside an * enquiry form, divided by a line. * Source: marcframe/nitip → components/sections/ContactSection.tsx (details and form; the page title above it is * hero-title) + design-system Form, FormField, CheckboxGroup. * * Layout kept from Framer: below 1024px the details, a line and the form stack; from 1024px the details fill the row, * a vertical line sits 96px from each side, and the form takes half the row (up to 800px). Each details group has a * line on top; rows put the label at the left and the value at the right. The form: a heading, name / email / phone in * two columns from 768px, the services as checkboxes, a message, a full-width submit. * Behavior kept: links underline on hover; "Get directions" is a text link whose underline grows and arrow turns * (underline-link); the submit shows a spinner while sending, "Thank you" and resets on success, "Something went wrong" * on failure, announced to screen readers. Added: typing again puts the button back to "Submit". The form remounts to * clear itself, since form.reset() doesn't clear Base UI checkboxes. * Sending is yours: pass `onSubmit`. Without it the form only pretends to send, so the demo works. * The section heading is for screen readers; the details groups and the form heading are h3. */ export type ContactSplitProps = { /** Section heading, visually hidden (the page title above says it). */ title?: string; email?: string; phone?: string; address?: string; /** Map link for "Get directions"; opens in a new tab. */ directions?: string; hours?: { days: string; time: string }[]; socials?: { label: string; href: string }[]; formTitle?: string; /** Checkbox options; the checked ones are sent as `services`. */ services?: string[]; /** Called with the form's data; throw (or reject) to show the error state. */ onSubmit?: (data: FormData) => Promise<void> | void; labels?: { contact: string; email: string; phone: string; location: string; directions: string; hours: string; social: string; services: string; message: string; submit: string; sending: string; success: string; error: string; }; className?: string; }; const defaults = { title: "Contact details and enquiry form", email: "hello@northstar.example", phone: "+1 (555) 014 2290", address: "Northstar Studio, 480 Market Street, Suite 12, San Francisco, CA 94104", directions: "https://maps.google.com/?q=480+Market+Street+San+Francisco", hours: [ { days: "Monday – Friday", time: "9:00 AM – 6:00 PM" }, { days: "Saturday", time: "10:00 AM – 2:00 PM" }, { days: "Sunday", time: "Closed" }, ], socials: [ { label: "Instagram", href: "https://instagram.com" }, { label: "LinkedIn", href: "https://linkedin.com" }, { label: "YouTube", href: "https://youtube.com" }, { label: "X", href: "https://x.com" }, ], formTitle: "Tell us about your project and we'll reply within a day.", services: ["Social media", "SEO", "Paid advertising", "Content", "Brand strategy", "Web design"], labels: { contact: "Contact us", email: "Email", phone: "Phone", location: "Our location", directions: "Get directions", hours: "Business hours", social: "Connect with us", services: "Services", message: "Message", submit: "Submit", sending: "Sending…", success: "Thank you", error: "Something went wrong", }, }; const fields = [ { name: "firstName", label: "First name", required: true, autoComplete: "given-name" }, { name: "lastName", label: "Last name", required: true, autoComplete: "family-name" }, { name: "email", label: "Email", type: "email", required: true, autoComplete: "email" }, { name: "phone", label: "Phone number", type: "tel", autoComplete: "tel" }, ]; type Status = "idle" | "pending" | "success" | "error"; const textLink = "transition-colors hover:text-foreground/80 hover:underline hover:underline-offset-4"; export function ContactSplit({ title = defaults.title, email = defaults.email, phone = defaults.phone, address = defaults.address, directions = defaults.directions, hours = defaults.hours, socials = defaults.socials, formTitle = defaults.formTitle, services = defaults.services, onSubmit, labels = defaults.labels, className, }: ContactSplitProps) { const [status, setStatus] = React.useState<Status>("idle"); /* Remounting the form clears every field — form.reset() leaves Base UI checkboxes checked. */ const [formKey, setFormKey] = React.useState(0); const id = React.useId(); async function submit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); const data = new FormData(event.currentTarget); setStatus("pending"); try { if (onSubmit) await onSubmit(data); else await new Promise((resolve) => setTimeout(resolve, 900)); setFormKey((key) => key + 1); setStatus("success"); } catch { setStatus("error"); } } const buttonLabel = status === "pending" ? labels.sending : status === "success" ? labels.success : status === "error" ? labels.error : labels.submit; return ( <section className={cn("py-(--section-y)", className)}> <h2 className="sr-only">{title}</h2> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter) lg:flex-row lg:items-start lg:gap-24"> <div className="flex min-w-0 flex-col lg:flex-1"> <Group title={labels.contact}> <Row label={labels.email}> <a href={`mailto:${email}`} className={textLink}> {email} </a> </Row> <Row label={labels.phone}> <a href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={textLink}> {phone} </a> </Row> </Group> <Group title={labels.location}> <div className="flex items-end justify-between gap-5"> <p className="max-w-md flex-1">{address}</p> {directions && ( <UnderlineLink href={directions} className="shrink-0"> {labels.directions} </UnderlineLink> )} </div> </Group> <Group title={labels.hours}> {hours.map((row) => ( <Row key={row.days} label={row.days}> {row.time} </Row> ))} </Group> <Group title={labels.social} className="items-start"> {socials.map((social) => ( <a key={social.href} href={social.href} target="_blank" rel="noopener noreferrer" className={textLink}> {social.label} </a> ))} </Group> </div> <hr className="border-border lg:h-auto lg:self-stretch lg:border-t-0 lg:border-l" /> <form key={formKey} onSubmit={submit} onInput={() => (status === "success" || status === "error") && setStatus("idle")} className="flex w-full flex-col items-start gap-8 lg:w-1/2 lg:max-w-200 lg:shrink-0" > <h3 className="max-w-md font-heading text-h3 font-semibold tracking-tight text-balance">{formTitle}</h3> <div className="flex w-full flex-col gap-5"> <div className="grid gap-5 md:grid-cols-2"> {fields.map((field) => ( <div key={field.name} className="flex flex-col gap-2"> <Label htmlFor={`${id}-${field.name}`}> {field.label} {field.required && ( <span aria-hidden className="text-muted-foreground"> * </span> )} </Label> <Input id={`${id}-${field.name}`} name={field.name} type={field.type ?? "text"} required={field.required} autoComplete={field.autoComplete} className="h-10" /> </div> ))} </div> {services.length > 0 && ( <div role="group" aria-labelledby={`${id}-services`} className="flex flex-col gap-3"> <p id={`${id}-services`} className="text-sm font-medium"> {labels.services} </p> <div className="grid gap-3 sm:grid-cols-2"> {services.map((service) => ( <Label key={service} className="font-normal"> <Checkbox name="services" value={service} /> {service} </Label> ))} </div> </div> )} <div className="flex flex-col gap-2"> <Label htmlFor={`${id}-message`}>{labels.message}</Label> <Textarea id={`${id}-message`} name="message" className="min-h-50" /> </div> </div> <Button type="submit" size="lg" disabled={status === "pending"} className={cn("h-10 w-full", status === "error" && "bg-destructive/10 text-destructive hover:bg-destructive/20")} > {status === "pending" && ( <svg viewBox="0 0 16 16" className="size-4 animate-spin motion-reduce:animate-none" fill="none" aria-hidden> <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" /> <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg> )} {buttonLabel} </Button> </form> <p role="status" className="sr-only"> {status === "success" ? labels.success : status === "error" ? labels.error : ""} </p> </div> </section> ); } /* A details group: its title, then rows 20px apart, a line on top. */ function Group({ title, className, children }: { title: string; className?: string; children: React.ReactNode }) { return ( <div className={cn("flex flex-col gap-5 border-t border-border py-5", className)}> <h3 className="font-heading text-h3 font-semibold tracking-tight">{title}</h3> {children} </div> ); } function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( <div className="flex items-center justify-between gap-5"> <p className="text-muted-foreground">{label}</p> <p className="text-right">{children}</p> </div> ); }
Usage
import { ContactSplit } from "@/components/contact-split";
import { HeroTitle } from "@/components/hero-title";
<HeroTitle title="Get in touch" breadcrumbs={[{ label: "Home", href: "/" }, { label: "Contact" }]} />
<ContactSplit
email="hello@example.com"
onSubmit={async (data) => {
const res = await fetch("/api/contact", { method: "POST", body: data });
if (!res.ok) throw new Error("Failed");
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | Section heading for screen readers (visually hidden). |
| email / phone | string | — | Shown as mailto and tel links. |
| address / directions | string | — | Address and a map link (Get directions, new tab). |
| hours | { days, time }[] | — | Rows under Business hours. |
| socials | { label, href }[] | — | Links under Connect with us (new tab). |
| formTitle | string | — | Heading of the form (h3). |
| services | string[] | 6 services | Checkboxes; the checked ones are sent as services. [] hides them. |
| onSubmit | (data: FormData) => Promise<void> | — | Sends the form; throw to show the error state. Without it the form only pretends to send. |
| labels | object | — | Every heading, label and button state. |
| className | string | — | Classes for the <section>. |
- Below 1024px details, a line and the form stack; from 1024px the details fill the row, a vertical line sits 96px from each side and the form takes half the row (up to 800px).
- Fields: first and last name and email (required), phone, the services, a message — shadcn input, checkbox, textarea, label and button.
- Sending shows a spinner and disables the button; success says Thank you and resets; failure says Something went wrong. Both are announced; typing again resets the button.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called ContactSplit (Next.js, shadcn input / checkbox / textarea / label / button). - <section className="py-(--section-y)"> with a visually hidden h2; container mx-auto max-w-(--container) px-(--gutter); stacked, a row from 1024px (items-start, gap-24) with an <hr> that becomes a vertical line (border-l, self-stretch) between the two sides. - Details (flex-1): groups with border-t and py-5, each an h3 (font-heading text-h3 semibold) and rows 20px apart — "Contact us" (Email / Phone rows: muted label left, link right), "Our location" (address and a "Get directions" text link whose underline grows), "Business hours" (days / time rows), "Connect with us" (links, new tab). - Form (half the row from 1024px, max 800px): an h3 heading; a 2-column grid from 768px of First name*, Last name*, Email*, Phone; a labelled group of service checkboxes (2 columns from 640px, each wrapped in its label, name="services"); a 200px message; a full-width submit. - Submit: pending → spinner + "Sending…" and disabled; success → "Thank you" and form.reset(); error → "Something went wrong" on a light destructive fill; a role="status" sr-only line announces it; typing resets the button. - Only shadcn variables and block tokens.











