Careers
Careers Accordion
Open roles as rows that open to a summary, a link and the requirements, with Load more.
Layout and motion from the Nitip template — see it in context"use client";
import * as React from "react";
import { AccordionList } from "@/components/ui/accordion-list";
import { SpringButton } from "@/components/ui/spring-button";
import { UnderlineLink } from "@/components/ui/underline-link";
import { cn } from "@/lib/utils";
/*
* Careers Accordion — open roles as rows that open to a summary, a link to the role and its requirements, with an
* optional "Load more" and a link to every position.
* Source: marcframe/nitip → components/sections/TeamSection.tsx (open positions), CareerCatalogSection.tsx and
* MoreOpeningsSection.tsx (+ design-system CareerList).
*
* Layout kept from Framer: a title row (title, and the last update at the right from 768px), the rows, then the
* actions. An open row shows "About the role", the summary and a "More details" link; the requirements take a 256px
* (1024px: 352px) column at the right from 768px.
* Motion kept (accordion-list): rows open on their own, the first starts open, closed rows sit at 50% until hovered,
* height opens with a spring (bounce 0, 0.4s). The dot in front of a title fills when its row opens (400ms).
* Changed: with `pageSize`, rows past the current page are added when loaded instead of sitting hidden in the HTML.
*/
export type CareerRole = { title: string; summary: string; requirements: string[]; href: string };
export type CareersAccordionProps = {
title?: string;
/** Short line at the right of the title from 768px. */
updated?: string;
roles?: CareerRole[];
/** Rows per page with a "Load more" button; leave it out to show every role. */
pageSize?: number;
action?: { label: string; href: string };
labels?: { summary: string; requirements: string; details: string; loadMore: string };
className?: string;
};
const defaults = {
title: "Open positions",
updated: "Updated May 2026",
action: { label: "See all positions", href: "/careers" },
labels: { summary: "About the role", requirements: "Requirements", details: "More details", loadMore: "Load more" },
roles: [
{
title: "Senior content strategist",
summary:
"Lead content plans for three to four clients, from pillars and calendars to briefs for writers and designers. You'll own the monthly review with each client.",
requirements: ["5+ years in content or social", "A portfolio of campaigns with results", "Clear, confident writing", "Comfortable presenting to clients"],
href: "/careers/senior-content-strategist",
},
{
title: "Paid media specialist",
summary:
"Plan, launch and optimise search and social campaigns across Google, Meta and LinkedIn, and turn the numbers into decisions clients understand.",
requirements: ["3+ years running paid campaigns", "Hands-on with Google Ads and Meta", "Strong with spreadsheets", "Curious about creative testing"],
href: "/careers/paid-media-specialist",
},
{
title: "UX/UI designer",
summary:
"Design landing pages, ads and small sites that convert, working closely with strategists and developers from the first sketch to launch.",
requirements: ["3+ years in product or web design", "Fluent in Figma", "Understands responsive layouts", "Portfolio with shipped work"],
href: "/careers/ux-ui-designer",
},
{
title: "Data analyst",
summary:
"Build the dashboards and reports that tell our clients what is working, and help the team plan the next test.",
requirements: ["2+ years in analytics", "SQL and a BI tool", "Experience with GA4", "Explains data in plain words"],
href: "/careers/data-analyst",
},
] as CareerRole[],
};
export function CareersAccordion({
title = defaults.title,
updated = defaults.updated,
roles = defaults.roles,
pageSize,
action = defaults.action,
labels = defaults.labels,
className,
}: CareersAccordionProps) {
const [open, setOpen] = React.useState<string[]>(roles.slice(0, 1).map((role) => role.href));
const [count, setCount] = React.useState(pageSize ?? roles.length);
const listId = React.useId();
const visible = roles.slice(0, count);
return (
<section className={cn("py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col items-start gap-8 px-(--gutter)">
<div className="flex w-full flex-col gap-2.5 md:flex-row md:items-center md:justify-between">
<h2 className="font-heading text-h3 font-semibold tracking-tight">{title}</h2>
{updated && <p className="text-muted-foreground">{updated}</p>}
</div>
<div id={listId} className="w-full border-t border-border">
<AccordionList
type="multiple"
value={open}
onValueChange={setOpen}
items={visible.map((role) => ({
value: role.href,
marker: (
<span
aria-hidden
className={cn(
"size-4 rounded-full border border-foreground transition-colors duration-400 motion-reduce:transition-none",
open.includes(role.href) ? "bg-foreground" : "bg-transparent",
)}
/>
),
title: <span className="font-heading text-h3 font-semibold tracking-tight">{role.title}</span>,
content: (
<div className="flex flex-col gap-8 pt-4 pb-4 text-base text-foreground md:flex-row md:gap-12 lg:gap-24">
<div className="flex min-w-0 flex-1 flex-col items-start gap-8 md:gap-10">
<div className="flex w-full flex-col gap-2.5">
<p className="font-medium">{labels.summary}</p>
<hr className="border-border" />
<p className="text-muted-foreground">{role.summary}</p>
</div>
<UnderlineLink href={role.href}>
{labels.details}
<span className="sr-only">: {role.title}</span>
</UnderlineLink>
</div>
<div className="flex shrink-0 flex-col gap-2.5 md:w-64 lg:w-88">
<p className="font-medium">{labels.requirements}</p>
<hr className="border-border" />
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
{role.requirements.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</div>
),
}))}
/>
</div>
{count < roles.length && (
<SpringButton
type="button"
variant="outline"
arrow={false}
aria-controls={listId}
onClick={() => setCount((n) => n + (pageSize ?? roles.length))}
>
{labels.loadMore}
</SpringButton>
)}
{action && <SpringButton href={action.href}>{action.label}</SpringButton>}
</div>
</section>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/careers-accordion.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/accordion-list.json https://framebrick.co/r/spring-button.json https://framebrick.co/r/underline-link.json2. 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-accordion.tsx."use client"; import * as React from "react"; import { AccordionList } from "@/components/ui/accordion-list"; import { SpringButton } from "@/components/ui/spring-button"; import { UnderlineLink } from "@/components/ui/underline-link"; import { cn } from "@/lib/utils"; /* * Careers Accordion — open roles as rows that open to a summary, a link to the role and its requirements, with an * optional "Load more" and a link to every position. * Source: marcframe/nitip → components/sections/TeamSection.tsx (open positions), CareerCatalogSection.tsx and * MoreOpeningsSection.tsx (+ design-system CareerList). * * Layout kept from Framer: a title row (title, and the last update at the right from 768px), the rows, then the * actions. An open row shows "About the role", the summary and a "More details" link; the requirements take a 256px * (1024px: 352px) column at the right from 768px. * Motion kept (accordion-list): rows open on their own, the first starts open, closed rows sit at 50% until hovered, * height opens with a spring (bounce 0, 0.4s). The dot in front of a title fills when its row opens (400ms). * Changed: with `pageSize`, rows past the current page are added when loaded instead of sitting hidden in the HTML. */ export type CareerRole = { title: string; summary: string; requirements: string[]; href: string }; export type CareersAccordionProps = { title?: string; /** Short line at the right of the title from 768px. */ updated?: string; roles?: CareerRole[]; /** Rows per page with a "Load more" button; leave it out to show every role. */ pageSize?: number; action?: { label: string; href: string }; labels?: { summary: string; requirements: string; details: string; loadMore: string }; className?: string; }; const defaults = { title: "Open positions", updated: "Updated May 2026", action: { label: "See all positions", href: "/careers" }, labels: { summary: "About the role", requirements: "Requirements", details: "More details", loadMore: "Load more" }, roles: [ { title: "Senior content strategist", summary: "Lead content plans for three to four clients, from pillars and calendars to briefs for writers and designers. You'll own the monthly review with each client.", requirements: ["5+ years in content or social", "A portfolio of campaigns with results", "Clear, confident writing", "Comfortable presenting to clients"], href: "/careers/senior-content-strategist", }, { title: "Paid media specialist", summary: "Plan, launch and optimise search and social campaigns across Google, Meta and LinkedIn, and turn the numbers into decisions clients understand.", requirements: ["3+ years running paid campaigns", "Hands-on with Google Ads and Meta", "Strong with spreadsheets", "Curious about creative testing"], href: "/careers/paid-media-specialist", }, { title: "UX/UI designer", summary: "Design landing pages, ads and small sites that convert, working closely with strategists and developers from the first sketch to launch.", requirements: ["3+ years in product or web design", "Fluent in Figma", "Understands responsive layouts", "Portfolio with shipped work"], href: "/careers/ux-ui-designer", }, { title: "Data analyst", summary: "Build the dashboards and reports that tell our clients what is working, and help the team plan the next test.", requirements: ["2+ years in analytics", "SQL and a BI tool", "Experience with GA4", "Explains data in plain words"], href: "/careers/data-analyst", }, ] as CareerRole[], }; export function CareersAccordion({ title = defaults.title, updated = defaults.updated, roles = defaults.roles, pageSize, action = defaults.action, labels = defaults.labels, className, }: CareersAccordionProps) { const [open, setOpen] = React.useState<string[]>(roles.slice(0, 1).map((role) => role.href)); const [count, setCount] = React.useState(pageSize ?? roles.length); const listId = React.useId(); const visible = roles.slice(0, count); return ( <section className={cn("py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col items-start gap-8 px-(--gutter)"> <div className="flex w-full flex-col gap-2.5 md:flex-row md:items-center md:justify-between"> <h2 className="font-heading text-h3 font-semibold tracking-tight">{title}</h2> {updated && <p className="text-muted-foreground">{updated}</p>} </div> <div id={listId} className="w-full border-t border-border"> <AccordionList type="multiple" value={open} onValueChange={setOpen} items={visible.map((role) => ({ value: role.href, marker: ( <span aria-hidden className={cn( "size-4 rounded-full border border-foreground transition-colors duration-400 motion-reduce:transition-none", open.includes(role.href) ? "bg-foreground" : "bg-transparent", )} /> ), title: <span className="font-heading text-h3 font-semibold tracking-tight">{role.title}</span>, content: ( <div className="flex flex-col gap-8 pt-4 pb-4 text-base text-foreground md:flex-row md:gap-12 lg:gap-24"> <div className="flex min-w-0 flex-1 flex-col items-start gap-8 md:gap-10"> <div className="flex w-full flex-col gap-2.5"> <p className="font-medium">{labels.summary}</p> <hr className="border-border" /> <p className="text-muted-foreground">{role.summary}</p> </div> <UnderlineLink href={role.href}> {labels.details} <span className="sr-only">: {role.title}</span> </UnderlineLink> </div> <div className="flex shrink-0 flex-col gap-2.5 md:w-64 lg:w-88"> <p className="font-medium">{labels.requirements}</p> <hr className="border-border" /> <ul className="list-disc space-y-1 pl-5 text-muted-foreground"> {role.requirements.map((item) => ( <li key={item}>{item}</li> ))} </ul> </div> </div> ), }))} /> </div> {count < roles.length && ( <SpringButton type="button" variant="outline" arrow={false} aria-controls={listId} onClick={() => setCount((n) => n + (pageSize ?? roles.length))} > {labels.loadMore} </SpringButton> )} {action && <SpringButton href={action.href}>{action.label}</SpringButton>} </div> </section> ); }
Usage
import { CareersAccordion } from "@/components/careers-accordion";
<CareersAccordion
roles={[
{ title: "Paid media specialist", summary: "…", requirements: ["3+ years running paid campaigns"], href: "/careers/paid-media" },
]}
pageSize={4}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title / updated | string | — | Section heading (h2, set small) and a short line at the right from 768px. |
| roles | { title, summary, requirements[], href }[] | 4 roles | One row each; titles are h3. |
| pageSize | number | — | Rows per page with a Load more button; leave it out to show every role. |
| action | { label, href } | See all positions | Button under the list; pass undefined on the careers page. |
| labels | { summary, requirements, details, loadMore } | — | Text inside an open row and on the Load more button. |
| className | string | — | Classes for the <section>. |
- Rows open on their own, the first starts open, closed rows sit at 50% until hovered; the dot before a title fills when its row opens (from 640px).
- An open row shows About the role, the summary and a More details link; the requirements take a 256px (1024px: 352px) column at the right from 768px.
- With pageSize, rows past the page are added when loaded (Nitip keeps them hidden in the HTML).
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS + Motion block called CareersAccordion (Next.js). - <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column items-start gap-8. - Title row: a small h2 (font-heading text-h3 semibold) and a muted "Updated …" at the right from 768px. - Rows (border-t on the list, border-b between rows): a button in an h3 with a 16px round dot (border-foreground, filled bg-foreground over 400ms when open, shown from 640px), the title (font-heading text-h3 semibold) and a plus that turns 45°; several rows can be open, the first starts open, closed rows at 50% until hover or focus; panel height 0 → auto with a spring (bounce 0, 0.4s). - Panel: "About the role" over an <hr> and the muted summary, then a "More details" text link (underline grows on hover); at the right from 768px a 256px (lg 352px) "Requirements" column with a bulleted list. - Optional paging: show pageSize rows and an outline "Load more" button that adds pageSize more; then a shadcn-style button with an arrow to all positions. - Only shadcn variables and block tokens.











