Retour au catalogue

Careers Hover Reveal

Liste de postes minimaliste. Au hover, un panneau se revele avec description, requirements et bouton postuler. Slide depuis la droite avec AnimatePresence.

careerscomplex Both Responsive a11y
minimalelegantsaasuniversalagencysplit
Theme

How to build a hover-reveal job listings section in React

A hover-reveal job listing in React tracks which row is active with a single useState index, then uses Framer Motion AnimatePresence to slide a detail panel in from the right whenever the active index changes. The panel exits before the next one enters thanks to mode="wait", preventing two panels from overlapping.

  • Stack: React 18 + Framer Motion 11 + Lucide React, ~160 lines, zero extra dependencies.
  • Core API: useState for active index, AnimatePresence mode="wait", motion.div with x/opacity transitions.
  • Detail panel is sticky (top: 6rem) so it stays in view as the user scans a long job list.
  • Accessible: job titles use h3 headings; the apply button is a native anchor tag with a real URL.
  • On mobile the two columns wrap, the detail panel stacks below the list, still fully usable on touch via onClick.

Careers Hover Reveal is a split-layout React section built for job boards and recruiting pages. A scannable list of open roles sits on the left; hovering any row slides a contextual detail panel in from the right with the full job description, requirements, and an apply CTA. The result is a focused, magazine-style reading flow that lets candidates explore roles without navigating away.

Anatomy

The layout is a flex row with two flex children that wrap on narrow screens. The left column is a stacked list of job rows, each showing title, department badge, location and contract type. An arrow icon animates its opacity and x offset to signal the active row. The right column holds a sticky container with a single AnimatePresence slot: when active is non-null, a detail card renders with department label, job title, description paragraph, animated requirements list, and an apply anchor.

How it works

The entire interaction hinges on one piece of state: `const [active, setActive] = useState<number | null>(null)`. Each job row sets active on mouseEnter and toggles it on click (for touch users). The detail panel is wrapped in AnimatePresence with mode="wait", which drains the exiting element before mounting the next one. The panel itself enters with `{ opacity: 0, x: 30 }` and exits with the same values, so the transition reads as a lateral swap rather than a fade. Inside the panel, each requirement `<li>` staggered via a per-item delay (`delay: ri * 0.04`) so the list cascades in after the card settles.

How to build it in React

  1. Set up the split layout and state

    Create a flex container with two children: a job list column (flex: 1 1 340px) and a detail column (flex: 1 1 380px). Declare `const [active, setActive] = useState<number | null>(null)` at the component root. This single integer drives every interactive part of the component.

    const [active, setActive] = useState<number | null>(null);
  2. Build the job rows with hover and click handlers

    Map over your jobs array and render a motion.div for each entry. Attach `onMouseEnter={() => setActive(i)}` for desktop and `onClick={() => setActive(active === i ? null : i)}` to toggle the panel on touch devices. Animate the background from transparent to your alt-background color when the row is active.

    onMouseEnter={() => setActive(i)}
    onClick={() => setActive(active === i ? null : i)}
  3. Wrap the detail panel in AnimatePresence

    Place AnimatePresence with mode="wait" in the right column. Inside it, conditionally render a motion.div keyed to `active`. Set initial/exit to `{ opacity: 0, x: 30 }` and animate to `{ opacity: 1, x: 0 }`. The key change forces AnimatePresence to exit the old panel and enter the new one whenever the active job changes.

    <AnimatePresence mode="wait">
      {active !== null && (
        <motion.div
          key={active}
          initial={{ opacity: 0, x: 30 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: 30 }}
          transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
        >
          {/* job detail content */}
        </motion.div>
      )}
    </AnimatePresence>
  4. Stagger the requirements list inside the panel

    Map requirements to `<motion.li>` elements. Give each one `initial={{ opacity: 0, x: -8 }}`, `animate={{ opacity: 1, x: 0 }}`, and a transition delay of `ri * 0.04` seconds. Because AnimatePresence remounts the panel on every active change, the stagger replays automatically for each new job.

    <motion.li
      key={ri}
      initial={{ opacity: 0, x: -8 }}
      animate={{ opacity: 1, x: 0 }}
      transition={{ duration: 0.25, delay: ri * 0.04 }}
    >

When to use it

Reach for this pattern when a company has 4 to 12 open roles and wants a dedicated careers section rather than a plain list page. It works well as a late-page section paired with a culture or perks block. Skip it if you have more than 20 roles, a filterable table or search interface scales better. On content-critical pages where every pixel matters, the sticky panel column costs half the viewport width on desktop.

Used by

  • Notion, Uses a clean split-layout careers page where clicking a role reveals full details inline without a page navigation.
  • Linear, Job listing with a minimal row design and contextual detail view that keeps the candidate focused on the list.
  • Vercel, Roles grouped by department with inline expand behavior, keeping the browsing experience on a single page.
  • Stripe, Split-panel job browser with department filters and a persistent detail column for the selected role.

FAQ

Why use AnimatePresence mode="wait" instead of mode="sync"?

mode="wait" ensures the exiting panel fully unmounts before the new one enters, preventing two detail cards from being visible at the same time. With mode="sync" both panels overlap briefly during the transition, which looks chaotic when the user hovers quickly between rows.

How do I add the panel on mobile where there is no hover?

The onClick handler already handles touch, tapping a row sets it as active and a second tap clears it. On small screens the two flex columns wrap, so the detail panel stacks below the list. You may want to scroll the panel into view with a ref and scrollIntoView on active change.

How many jobs can this component handle before it becomes unwieldy?

The component works best with 4 to 12 roles. Beyond that, the left column becomes a long scroll and the sticky panel starts to feel disconnected. For larger lists, switch to a filterable table with a drawer or modal for the detail view.

Can I trigger the panel from a URL hash to deep-link to a specific job?

Yes. On mount, read window.location.hash, find the matching job index and call setActive with it. Combine this with an effect that updates the hash when active changes. The component already uses numeric indices, so map each job to a stable slug for the hash.

"use client";

import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
import { ArrowRight, MapPin, Clock } from "lucide-react";

interface Job {
  title: string;
  department: string;
  location: string;
  type: string;
  description: string;
  requirements: string[];
  url: string;
}

interface CareersHoverRevealProps {
  title?: string;
  subtitle?: string;
  jobs?: Job[];
}

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Job Listings with Hover Reveal Panel, Tutorial