Job Listings
A four-state careers board — roles grouped by department, three filters that genuinely combine, a live result count, and salary ranges whose absence is spelled out instead of left blank.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/job-listings.jsonPrompt
Build a React + TypeScript + Tailwind "JobListings" careers block
(lucide-react glyphs) with zod.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; heading?; subheading?;
items: { id, title, department,
location: { mode: "remote" | "hybrid" | "onsite"; place? },
employment: "full-time" | "part-time" | "contract" | "internship",
salary?: { min, max, currency, period: "year"|"month"|"hour" },
href }[] }.
- Location is TWO fields, never one string: `mode` says how the role relates
to a place, `place` says which place. That is what lets "On-site · Berlin"
and "Hybrid · Berlin" land under the same location filter option while
remote stays an independent axis. A remote role with no `place` is
location-independent: it renders as plain "Remote" and is reached through
the remote filter or "All locations".
- `salary` is optional on purpose — many employers do not publish pay, while
several US states and the EU pay-transparency directive require a range.
Absence is a rendered state, not a blank cell (see Behavior).
- `href` is refined at parse time to reject dead anchors ("#" and friends),
so a decorative fake Apply link cannot enter the data in the first place.
- Component props = z.infer of the schema plus undisclosedSalaryLabel?:
string (default "Salary not disclosed"), locale?: string (default "en-US"),
onRetry?: () => void, className. Filter selection is internal state — the
host ships data, the component owns the filtering.
Behavior
- Four first-class branches: loading (filter bar + two group skeletons
mirroring the real row anatomy, plus an sr-only role=status "Loading open
roles"), empty ("No open roles right now" — the board itself is empty, no
filter bar), error (message + "Try again" only when onRetry exists), ready.
- Filter options are DERIVED from the items: department options are the
distinct departments, location options the distinct `place` values, both in
first-appearance order so the host controls priority by ordering the data.
A hardcoded option list eventually lies about the data. A control that
cannot change the result is not rendered at all: one department means no
department dropdown, zero `place` values mean no location dropdown, and the
matching dimension is forced back to "all" so nothing filters invisibly.
- The three filters INTERSECT: department AND location AND remote-only. An
unset dimension does not narrow. The remote toggle is an aria-pressed
button that keeps mode === "remote" (hybrid is a distinct answer to "where
do I work", so it is not silently folded in).
- Stale-selection guard: if the items prop changes and the selected
department/location no longer exists, that dimension falls back to "all"
during render — otherwise the block filters to zero forever on a value the
user can no longer see in the dropdown, with no way back.
- A result line reads the filtered array length: "N open roles" unfiltered,
"Showing M of N open roles" once any filter is on. It carries role=status
so the new count is announced.
- Two visually and verbally distinct zero states:
* status === "empty" -> "No open roles right now" (nothing to filter).
* ready + 0 matches -> "No roles match these filters", restating the
total that is still listed, plus a Clear filters button. The same
Clear filters control also sits in the filter bar whenever any filter
is active, so the user is never trapped in an empty result.
- Grouping: one <section aria-labelledby> per department (heading + role
count) wrapping a <ul> of rows; groups with no matches are not rendered.
The department shows once as the group heading instead of repeating on
every row.
- Rows: the job title is the only link in the row, and its ::after covers the
whole row so the entire row is clickable while the accessible name stays
exactly the job title. The trailing "Apply" pill is aria-hidden decoration
belonging to that same link, not a second link and not a dead button.
- Salary rendering: min/max are ordered defensively (a reversed pair still
reads low to high), min === max collapses to a single figure, the period
becomes / yr · / mo · / hr, and the currency symbol comes from the data so
one board can mix currencies. No salary -> undisclosedSalaryLabel in muted
italic, never an empty cell.
- No caps anywhere: every item in `items` renders, titles wrap (break-words,
min-w-0 on the text column) instead of being clipped by a fixed height.
- Heading levels shift with the data: with a section heading the groups are
h3 and job titles h4; without one the groups become h2 and titles h3, so
the outline never skips a level.
Rendering & styling
- Semantic tokens only: bg-card panels, border / divide-y separators,
text-muted-foreground for meta and the undisclosed-salary fallback, primary
reserved for the active remote toggle (border-primary bg-primary/10) and
the Apply pill on row hover/focus-within. Salary figures tabular-nums.
- Native <select> for the two dropdowns (appearance-none + a positioned
chevron with pointer-events-none) and sr-only <label>s; scheme-light /
dark:scheme-dark so the system-drawn option list follows the theme.
- Row hover uses bg-muted/50; transitions are transition-colors and
transition-[translate] (Tailwind v4 translate-* writes the CSS `translate`
property, so it must be named in the transition list), all disabled under
motion-reduce without losing any function.
- focus-visible:ring-2 ring-ring on every control and on the title link;
cn() merges className.
Customization levers
- Add a fourth dimension (employment type, seniority, team) by copying the
derive-options -> intersect-predicate -> reset-in-clearFilters trio; the
result count and both zero states pick it up with no further changes.
- Grouping key: swap `department` for location or employment in the bucket
builder to regroup the same data; render one flat <ul> by dropping the
bucketing and keeping the row component.
- Row density: the row is a flex column that becomes a row at sm; drop the
meta line, or move salary under the title, without touching filter logic.
- Copy: undisclosedSalaryLabel is a prop; the empty / no-match / error strings
are single literals meant to be swapped for your voice or an i18n lookup.
- Formatting: locale feeds Intl.NumberFormat — switch to style:"currency" with
an ISO code if you would rather pass "USD" than "$".
- Emphasis: the Apply pill inherits primary on hover; make it always-solid for
a louder board, or reduce it to the arrow glyph alone for a denser one.Concepts
- Intersecting filters — the three controls are ANDed into one predicate, so department + location + remote narrows the list the way a visitor expects, and a dimension left unset simply does not participate. Filter chrome that does not change the result is a dead affordance.
- Derived filter options — the dropdowns are built from the items themselves in first-appearance order, so a new department appears the moment it is in the data and a removed one disappears; a hardcoded option list eventually offers a filter that matches nothing.
- Remote as its own axis —
mode(remote / hybrid / on-site) is separate fromplace, which keeps "Hybrid · Berlin" and "On-site · Berlin" under one Berlin option and stops "Remote" from squatting in the city field. - No-match is not no-openings — an empty board and an over-narrow filter look identical if you reuse one panel; they get different copy, and only the filter case offers the Clear filters escape hatch.
- Undisclosed is a rendered state — a missing salary prints a configurable label rather than a blank column, which keeps the row scannable and makes pay-transparency gaps visible instead of invisible.
- Stretched link, single accessible name — one anchor per row with its ::after covering the row: the whole row is clickable while a screen reader still hears exactly one link, named after the job.
Roadmap Board
A public product roadmap in planned / in-progress / shipped lanes, where each entry can be upvoted optimistically — and the count rolls back with a visible error when the server refuses.
Settings Page
A settings page shell — section nav, per-section save, and an unsaved-changes guard that stops a section switch with save / discard / cancel instead of dropping the edit.