Release Timeline
A dated changelog on a vertical rail with added/changed/fixed tags and machine-readable <time> values.
Openings grouped by department with location, type and salary on every row — candidates self-select by craft first, and a flat list makes them scan for it.
components/job-listing-board.tsxnpx hoverlab add job-listing-board
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
We’re hiring across three teams. Every role lists the real salary range — no “competitive”.
Nothing that fits? We read every speculative application — jobs@hoverlab.dev.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <JobListingBoard> — openings grouped by department, not one long list.
*
* A flat ranked list of twelve roles makes every candidate read all
* twelve. Grouping by department works because candidates self-select by
* craft first — a designer scans straight past Engineering to the Design
* header and reads three rows instead of twelve. The count in each group
* header does the same job at a glance ("Engineering (4)") before a
* single row is read.
*
* Each row is one whole <a>, not a title-link inside a div — the entire
* row is the target, which is what every candidate's thumb assumes. The
* closing mailto line matters too: the best applicant is often the one
* your listed roles didn't predict, and a board with no speculative
* route silently turns them away.
*/
import * as React from 'react'
import { ChevronRight, MapPin } from 'lucide-react'
export interface JobOpening {
title: string
location: string
remote?: boolean
type: string
salary: string
href?: string
}
export interface JobDepartment {
name: string
openings: JobOpening[]
}
export interface JobListingBoardProps {
heading?: string
intro?: string
departments?: JobDepartment[]
speculativeEmail?: string
className?: string
}
const DEFAULT_DEPARTMENTS: JobDepartment[] = [
{
name: 'Engineering',
openings: [
{
title: 'Senior Frontend Engineer',
location: 'Berlin',
remote: true,
type: 'Full-time',
salary: '€95k–€120k',
href: '#',
},
{
title: 'Infrastructure Engineer, Sync',
location: 'Amsterdam',
remote: true,
type: 'Full-time',
salary: '€100k–€130k',
href: '#',
},
{
title: 'Engineering Intern (Winter 2026)',
location: 'Berlin',
type: 'Internship, 6 months',
salary: '€2.8k/month',
href: '#',
},
],
},
{
name: 'Design',
openings: [
{
title: 'Product Designer, Growth',
location: 'London',
remote: true,
type: 'Full-time',
salary: '£75k–£95k',
href: '#',
},
{
title: 'Brand Designer',
location: 'London',
type: 'Full-time',
salary: '£60k–£78k',
href: '#',
},
],
},
{
name: 'Go-to-market',
openings: [
{
title: 'Developer Marketing Lead',
location: 'New York',
remote: true,
type: 'Full-time',
salary: '$130k–$160k',
href: '#',
},
{
title: 'Solutions Engineer, EMEA',
location: 'Dublin',
remote: true,
type: 'Full-time',
salary: '€85k–€105k',
href: '#',
},
],
},
]
export function JobListingBoard({
heading = 'Open roles',
intro = 'We’re hiring across three teams. Every role lists the real salary range — no “competitive”.',
departments = DEFAULT_DEPARTMENTS,
speculativeEmail = 'jobs@hoverlab.dev',
className = '',
}: JobListingBoardProps) {
return (
<section className={`mx-auto w-full max-w-3xl px-6 py-16 ${className}`}>
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">{heading}</h2>
<p className="mt-2 text-sm text-muted-foreground sm:text-base">{intro}</p>
<div className="mt-10 space-y-10">
{departments.map((department) => (
<div key={department.name}>
<h3 className="flex items-baseline gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{department.name}
<span className="text-xs font-medium normal-case tracking-normal text-muted-foreground/85">
{department.openings.length}{' '}
{department.openings.length === 1 ? 'opening' : 'openings'}
</span>
</h3>
<ul className="mt-3 divide-y divide-border/60 overflow-hidden rounded-2xl border border-border/60 bg-card/60">
{department.openings.map((job) => (
<li key={job.title}>
<a
href={job.href ?? '#'}
className="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-card-foreground">{job.title}</p>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<MapPin aria-hidden className="h-3.5 w-3.5" />
{job.location}
</span>
{job.remote ? (
<span className="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">
Remote OK
</span>
) : null}
<span>{job.type}</span>
<span className="tabular-nums">{job.salary}</span>
</p>
</div>
<ChevronRight
aria-hidden
className="h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180"
/>
</a>
</li>
))}
</ul>
</div>
))}
</div>
<p className="mt-10 text-sm text-muted-foreground">
Nothing that fits? We read every speculative application —{' '}
<a
href={`mailto:${speculativeEmail}`}
className="font-semibold text-primary underline-offset-4 transition-colors hover:underline"
>
{speculativeEmail}
</a>
.
</p>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/job-listing-board.tsx and import it where you need the section:
import { JobListingBoard } from '@/components/job-listing-board'2 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
heading | string | 'Open roles' |
intro | string | — |
departments | JobDepartment[] | DEFAULT_DEPARTMENTS |
speculativeEmail | string | 'jobs@hoverlab.dev' |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
<!--
Careers Openings Board — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block has no interactive behaviour, so nothing is missing.
-->
<section class="mx-auto w-full max-w-3xl px-6 py-16 ">
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">Open roles</h2>
<p class="mt-2 text-sm text-muted-foreground sm:text-base">We’re hiring across three teams. Every role lists the real salary range — no “competitive”.</p>
<div class="mt-10 space-y-10">
<div>
<h3 class="flex items-baseline gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Engineering<span class="text-xs font-medium normal-case tracking-normal text-muted-foreground/85">3 openings</span></h3>
<ul class="mt-3 divide-y divide-border/60 overflow-hidden rounded-2xl border border-border/60 bg-card/60">
<li>
<a href="#" class="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-card-foreground">Senior Frontend Engineer</p>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>Berlin</span>
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">Remote OK</span>
<span>Full-time</span>
<span class="tabular-nums">€95k–€120k</span>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</li>
<li>
<a href="#" class="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-card-foreground">Infrastructure Engineer, Sync</p>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>Amsterdam</span>
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">Remote OK</span>
<span>Full-time</span>
<span class="tabular-nums">€100k–€130k</span>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</li>
<li>
<a href="#" class="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-card-foreground">Engineering Intern (Winter 2026)</p>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>Berlin</span>
<span>Internship, 6 months</span>
<span class="tabular-nums">€2.8k/month</span>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</li>
</ul>
</div>
<div>
<h3 class="flex items-baseline gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Design<span class="text-xs font-medium normal-case tracking-normal text-muted-foreground/85">2 openings</span></h3>
<ul class="mt-3 divide-y divide-border/60 overflow-hidden rounded-2xl border border-border/60 bg-card/60">
<li>
<a href="#" class="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-card-foreground">Product Designer, Growth</p>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>London</span>
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">Remote OK</span>
<span>Full-time</span>
<span class="tabular-nums">£75k–£95k</span>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</li>
<li>
<a href="#" class="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-card-foreground">Brand Designer</p>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>London</span>
<span>Full-time</span>
<span class="tabular-nums">£60k–£78k</span>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</li>
</ul>
</div>
<div>
<h3 class="flex items-baseline gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Go-to-market<span class="text-xs font-medium normal-case tracking-normal text-muted-foreground/85">2 openings</span></h3>
<ul class="mt-3 divide-y divide-border/60 overflow-hidden rounded-2xl border border-border/60 bg-card/60">
<li>
<a href="#" class="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-card-foreground">Developer Marketing Lead</p>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>New York</span>
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">Remote OK</span>
<span>Full-time</span>
<span class="tabular-nums">$130k–$160k</span>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</li>
<li>
<a href="#" class="group flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/40">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-card-foreground">Solutions Engineer, EMEA</p>
<p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin h-3.5 w-3.5" aria-hidden="true"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path><circle cx="12" cy="10" r="3"></circle></svg>Dublin</span>
<span class="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">Remote OK</span>
<span>Full-time</span>
<span class="tabular-nums">€85k–€105k</span>
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground rtl:rotate-180" aria-hidden="true">
<path d="m9 18 6-6-6-6"></path>
</svg>
</a>
</li>
</ul>
</div>
</div>
<p class="mt-10 text-sm text-muted-foreground">Nothing that fits? We read every speculative application — <a href="mailto:jobs@hoverlab.dev" class="font-semibold text-primary underline-offset-4 transition-colors hover:underline">jobs@hoverlab.dev</a>.</p>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Content & BlogCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A dated changelog on a vertical rail with added/changed/fixed tags and machine-readable <time> values.
Shipped / in progress / planned in three columns, with status carried by icon as well as colour.
A featured post at full width above a card grid, with drawn covers built from tokens — a blog index with no image pipeline and no broken-thumbnail state.
A grid of case-study cards that lead with the metric rather than the company name, each one linking out to the full write-up — the block a customer-stories section or a home page needed and only had a full case-study page for.
Kicker, headline, standfirst, byline and share row, with the opening prose and a pull quote included — the type scale of a post, decided once instead of per-article.
An about-page team section on initials avatars rather than photos, so shipping it never waits on headshots and there is no broken-image state to design.
Sidebar, article and on-this-page rail — each column answers a different question, and they collapse on mobile in the order that keeps the article alive.
Method badge, path, parameter table and a request/response pair kept side by side — an example request whose response is a scroll away gets pasted blind.
One install command behind npm/pnpm/yarn/bun tabs with a working copy button — every reader uses exactly one package manager, so showing all four wastes three lines.
A CV as a document rather than a timeline of cards — one column, dates on the end edge, and a print layer that puts it on paper without a separate PDF to keep in step.
The watch screen with no playback in it: a ratio-locked stage, a control bar drawn down to the buffered layer, and an up-next rail. Drop a video element in and wire your own player.