Careers
Careers Application
A role's title, summary and lists beside an application form with sending, success and error 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
/*
* Careers Application — a role's title and summary, its lists (responsibilities, requirements, benefits) and an
* application form beside them.
* Source: marcframe/nitip → components/sections/CareerDetailSection.tsx (+ design-system PageTitle, HighlightList,
* Form, FormField, SubmitButton).
*
* Layout kept from Framer: title, divider and summary (up to 36rem); then from 1024px the lists take 450px at the left
* (a line above each) and the form half the row (up to 800px) at the right; below 1024px they stack. Form: a heading,
* name / email / phone in two columns from 768px with the position across both (prefilled with the role), then the
* "Social media links" and "Additional information" groups, then a full-width submit.
* Behavior kept: while sending the button shows a spinner and is disabled; on success it says "Thank you" and the
* fields reset (the position back to the role); on failure it says "Something went wrong". The result is also
* announced to screen readers. Added: typing again puts the button back to "Submit".
* Sending is yours: pass `onSubmit` (a server action or a fetch to your form service). Without it the form only
* pretends to send, so the demo works.
*/
export type ApplicationField = {
name: string;
label: string;
type?: "text" | "email" | "tel" | "url";
required?: boolean;
autoComplete?: string;
};
export type CareersApplicationProps = {
/** Heading level of the role title: h1 when the block opens the page. */
as?: "h1" | "h2";
title?: string;
summary?: string;
lists?: { title: string; items: string[] }[];
formTitle?: string;
/** Called with the form's data; throw (or reject) to show the error state. */
onSubmit?: (data: FormData) => Promise<void> | void;
labels?: {
position: string;
social: string;
additional: string;
message: string;
submit: string;
sending: string;
success: string;
error: string;
};
className?: string;
};
const details: ApplicationField[] = [
{ 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" },
];
const social: ApplicationField[] = [
{ name: "linkedin", label: "LinkedIn URL", type: "url", required: true, autoComplete: "url" },
{ name: "otherSocial", label: "Other social media", type: "url" },
{ name: "website", label: "Website or portfolio", type: "url" },
];
const defaults = {
title: "Senior content strategist",
summary:
"Lead content plans for a small group of clients — from pillars and calendars to briefs for writers and designers — and own the monthly review with each of them.",
lists: [
{
title: "Key responsibilities",
items: [
"Plan and run content calendars for three to four clients",
"Brief and review work from writers and designers",
"Report on results every month and propose the next tests",
"Keep each client's voice consistent across channels",
],
},
{
title: "Requirements",
items: [
"5+ years in content, social or editorial roles",
"A portfolio of campaigns with measurable results",
"Clear, confident writing and presenting",
"Comfortable with analytics and simple spreadsheets",
],
},
{
title: "Perks and benefits",
items: ["Hybrid work, two office days a week", "Learning budget of $1,500 a year", "25 days of paid holiday", "Top-of-the-line laptop and tools"],
},
],
formTitle: "Submit your application",
labels: {
position: "Job position",
social: "Social media links",
additional: "Additional information",
message: "Message",
submit: "Submit",
sending: "Sending…",
success: "Thank you",
error: "Something went wrong",
},
};
type Status = "idle" | "pending" | "success" | "error";
export function CareersApplication({
as: Heading = "h2",
title = defaults.title,
summary = defaults.summary,
lists = defaults.lists,
formTitle = defaults.formTitle,
onSubmit,
labels = defaults.labels,
className,
}: CareersApplicationProps) {
const [status, setStatus] = React.useState<Status>("idle");
const id = React.useId();
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setStatus("pending");
try {
if (onSubmit) await onSubmit(new FormData(form));
else await new Promise((resolve) => setTimeout(resolve, 900));
form.reset();
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)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)">
<div className="flex flex-col gap-8">
<Heading className="font-heading text-h1 font-semibold tracking-tight text-balance">{title}</Heading>
<hr className="border-border" />
{summary && <p className="max-w-xl text-lead text-pretty text-muted-foreground">{summary}</p>}
</div>
<div className="flex flex-col gap-12 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-col lg:w-112.5 lg:shrink-0">
{lists.map((list) => (
<div key={list.title} className="flex flex-col gap-5 border-t border-border py-5">
<h3 className="font-heading text-h3 font-semibold tracking-tight">{list.title}</h3>
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
{list.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
))}
</div>
<form
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">
<h3 className="max-w-md font-heading text-h3 font-semibold tracking-tight text-balance">{formTitle}</h3>
<div className="grid w-full gap-5 md:grid-cols-2">
{details.map((field) => (
<Field key={field.name} id={id} field={field} />
))}
<div className="flex flex-col gap-2 md:col-span-2">
<Label htmlFor={`${id}-position`}>{labels.position}</Label>
<Input id={`${id}-position`} name="position" defaultValue={title} className="h-10" />
</div>
</div>
<div role="group" aria-labelledby={`${id}-social`} className="flex w-full flex-col gap-5">
<p id={`${id}-social`} className="text-lead font-medium">
{labels.social}
</p>
{social.map((field) => (
<Field key={field.name} id={id} field={field} />
))}
</div>
<div role="group" aria-labelledby={`${id}-additional`} className="flex w-full flex-col gap-5">
<p id={`${id}-additional`} className="text-lead font-medium">
{labels.additional}
</p>
<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>
<p role="status" className="sr-only">
{status === "success" ? labels.success : status === "error" ? labels.error : ""}
</p>
</form>
</div>
</div>
</section>
);
}
function Field({ id, field }: { id: string; field: ApplicationField }) {
const fieldId = `${id}-${field.name}`;
return (
<div className="flex flex-col gap-2">
<Label htmlFor={fieldId}>
{field.label}
{field.required && (
<span aria-hidden className="text-muted-foreground">
*
</span>
)}
</Label>
<Input
id={fieldId}
name={field.name}
type={field.type ?? "text"}
required={field.required}
autoComplete={field.autoComplete}
className="h-10"
/>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/careers-application.json1. Add the shadcn/ui primitives it uses.
npx shadcn@latest add button input label textarea2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/careers-application.tsx."use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; /* * Careers Application — a role's title and summary, its lists (responsibilities, requirements, benefits) and an * application form beside them. * Source: marcframe/nitip → components/sections/CareerDetailSection.tsx (+ design-system PageTitle, HighlightList, * Form, FormField, SubmitButton). * * Layout kept from Framer: title, divider and summary (up to 36rem); then from 1024px the lists take 450px at the left * (a line above each) and the form half the row (up to 800px) at the right; below 1024px they stack. Form: a heading, * name / email / phone in two columns from 768px with the position across both (prefilled with the role), then the * "Social media links" and "Additional information" groups, then a full-width submit. * Behavior kept: while sending the button shows a spinner and is disabled; on success it says "Thank you" and the * fields reset (the position back to the role); on failure it says "Something went wrong". The result is also * announced to screen readers. Added: typing again puts the button back to "Submit". * Sending is yours: pass `onSubmit` (a server action or a fetch to your form service). Without it the form only * pretends to send, so the demo works. */ export type ApplicationField = { name: string; label: string; type?: "text" | "email" | "tel" | "url"; required?: boolean; autoComplete?: string; }; export type CareersApplicationProps = { /** Heading level of the role title: h1 when the block opens the page. */ as?: "h1" | "h2"; title?: string; summary?: string; lists?: { title: string; items: string[] }[]; formTitle?: string; /** Called with the form's data; throw (or reject) to show the error state. */ onSubmit?: (data: FormData) => Promise<void> | void; labels?: { position: string; social: string; additional: string; message: string; submit: string; sending: string; success: string; error: string; }; className?: string; }; const details: ApplicationField[] = [ { 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" }, ]; const social: ApplicationField[] = [ { name: "linkedin", label: "LinkedIn URL", type: "url", required: true, autoComplete: "url" }, { name: "otherSocial", label: "Other social media", type: "url" }, { name: "website", label: "Website or portfolio", type: "url" }, ]; const defaults = { title: "Senior content strategist", summary: "Lead content plans for a small group of clients — from pillars and calendars to briefs for writers and designers — and own the monthly review with each of them.", lists: [ { title: "Key responsibilities", items: [ "Plan and run content calendars for three to four clients", "Brief and review work from writers and designers", "Report on results every month and propose the next tests", "Keep each client's voice consistent across channels", ], }, { title: "Requirements", items: [ "5+ years in content, social or editorial roles", "A portfolio of campaigns with measurable results", "Clear, confident writing and presenting", "Comfortable with analytics and simple spreadsheets", ], }, { title: "Perks and benefits", items: ["Hybrid work, two office days a week", "Learning budget of $1,500 a year", "25 days of paid holiday", "Top-of-the-line laptop and tools"], }, ], formTitle: "Submit your application", labels: { position: "Job position", social: "Social media links", additional: "Additional information", message: "Message", submit: "Submit", sending: "Sending…", success: "Thank you", error: "Something went wrong", }, }; type Status = "idle" | "pending" | "success" | "error"; export function CareersApplication({ as: Heading = "h2", title = defaults.title, summary = defaults.summary, lists = defaults.lists, formTitle = defaults.formTitle, onSubmit, labels = defaults.labels, className, }: CareersApplicationProps) { const [status, setStatus] = React.useState<Status>("idle"); const id = React.useId(); async function submit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); const form = event.currentTarget; setStatus("pending"); try { if (onSubmit) await onSubmit(new FormData(form)); else await new Promise((resolve) => setTimeout(resolve, 900)); form.reset(); 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)}> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)"> <div className="flex flex-col gap-8"> <Heading className="font-heading text-h1 font-semibold tracking-tight text-balance">{title}</Heading> <hr className="border-border" /> {summary && <p className="max-w-xl text-lead text-pretty text-muted-foreground">{summary}</p>} </div> <div className="flex flex-col gap-12 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col lg:w-112.5 lg:shrink-0"> {lists.map((list) => ( <div key={list.title} className="flex flex-col gap-5 border-t border-border py-5"> <h3 className="font-heading text-h3 font-semibold tracking-tight">{list.title}</h3> <ul className="list-disc space-y-1 pl-5 text-muted-foreground"> {list.items.map((item) => ( <li key={item}>{item}</li> ))} </ul> </div> ))} </div> <form 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"> <h3 className="max-w-md font-heading text-h3 font-semibold tracking-tight text-balance">{formTitle}</h3> <div className="grid w-full gap-5 md:grid-cols-2"> {details.map((field) => ( <Field key={field.name} id={id} field={field} /> ))} <div className="flex flex-col gap-2 md:col-span-2"> <Label htmlFor={`${id}-position`}>{labels.position}</Label> <Input id={`${id}-position`} name="position" defaultValue={title} className="h-10" /> </div> </div> <div role="group" aria-labelledby={`${id}-social`} className="flex w-full flex-col gap-5"> <p id={`${id}-social`} className="text-lead font-medium"> {labels.social} </p> {social.map((field) => ( <Field key={field.name} id={id} field={field} /> ))} </div> <div role="group" aria-labelledby={`${id}-additional`} className="flex w-full flex-col gap-5"> <p id={`${id}-additional`} className="text-lead font-medium"> {labels.additional} </p> <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> <p role="status" className="sr-only"> {status === "success" ? labels.success : status === "error" ? labels.error : ""} </p> </form> </div> </div> </section> ); } function Field({ id, field }: { id: string; field: ApplicationField }) { const fieldId = `${id}-${field.name}`; return ( <div className="flex flex-col gap-2"> <Label htmlFor={fieldId}> {field.label} {field.required && ( <span aria-hidden className="text-muted-foreground"> * </span> )} </Label> <Input id={fieldId} name={field.name} type={field.type ?? "text"} required={field.required} autoComplete={field.autoComplete} className="h-10" /> </div> ); }
Usage
import { CareersApplication } from "@/components/careers-application";
<CareersApplication
as="h1"
title="Paid media specialist"
summary="…"
onSubmit={async (data) => {
const res = await fetch("/api/apply", { method: "POST", body: data });
if (!res.ok) throw new Error("Failed");
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "h1" | "h2" | "h2" | Heading level of the role title — h1 when the block opens the page. |
| title / summary | string | — | The role, and the text under the divider. The position field is prefilled with the title. |
| lists | { title, items[] }[] | — | Responsibilities, requirements, benefits — h3 + bulleted list each. |
| formTitle | string | Submit your application | Heading of the form (h3). |
| onSubmit | (data: FormData) => Promise<void> | — | Sends the form; throw to show the error state. Without it the form only pretends to send. |
| labels | { position, social, additional, message, submit, sending, success, error } | — | Field-group titles and button states. |
| className | string | — | Classes for the <section>. |
- From 1024px the lists take 450px at the left and the form half the row (up to 800px) at the right; below that they stack.
- Fields: first and last name, email (required), phone, the position (prefilled), LinkedIn (required), other social, website and a message — shadcn input, textarea and label.
- While sending the button shows a spinner and is disabled; success says Thank you and resets the fields; 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 CareersApplication (Next.js, shadcn input / textarea / label / button). - <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12. - Title: an h2 (or h1) font-heading text-h1 semibold, an <hr>, a text-lead muted summary max-w-xl. - Below: stacked; from 1024px a row, justify-between: the lists (450px) — each with border-t, py-5, an h3 (font-heading text-h3) and a muted bulleted list — and the form (half the row, max 800px): an h3 heading; a 2-column grid from 768px of First name*, Last name*, Email*, Phone, then Job position across both columns (prefilled with the role); a "Social media links" group (role="group", labelled): LinkedIn URL*, Other social media, Website; an "Additional information" group: a 200px message textarea; a full-width submit button (h-10). - Submit: preventDefault, 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 the result; typing resets the button to "Submit". - Only shadcn variables and block tokens.











