Back

Accordion

Easy

Streak

0 days

Progress

0%

Submitted

0

Accordion

React30 minEasyFreeNew

Prompt

An Accordion is a vertically stacked list of items where each item has a clickable header that expands or collapses a content panel below it. You've seen this pattern in FAQs, settings panels, and navigation menus across virtually every web application. Your task is to build a fully functional, accessible accordion in React. The state management for an accordion is deceptively simple — one number is all you need. But the accessibility layer is where most candidates fall short. Accordions are a defined WAI-ARIA pattern with specific roles, attributes, and keyboard interactions that interviewers at senior-level positions specifically look for. Your implementation should render a list of items from a data array, allow only one panel to be open at a time, animate the expand/collapse smoothly, and include correct ARIA attributes on the header buttons and content panels.

Requirements

  • →Render a list of accordion items from a data array (each with a title and content)
  • →Clicking a header expands that item's content panel
  • →Only one item can be open at a time — opening a new one closes the current one
  • →Clicking an already-open item's header collapses it
  • →Smooth expand/collapse animation (max-height transition)
  • →Correct ARIA: aria-expanded on header buttons, aria-controls linking button to panel, aria-hidden on closed panels
Example
Loading preview...
For the best coding experience, we recommend using a desktop device.
Preparing Sandbox...
Premium interview report

What interviewers score in this build

Use this before reading the code. It tells you what to say, what to test, and where machine-coding candidates usually lose points.

Interview signals

  • React: Single openId state (null when all closed). Toggle logic is one line: setOpenId(prev => prev === id ? null : id). No per-item state — everything driven from one value.
  • HTML/CSS: Header uses <button> not <div>. aria-expanded, aria-controls on button. Panel has role=region, aria-labelledby. max-height transition for animation. overflow:hidden on panel.
  • Component Architecture: Accordion accepts items prop (array of {id, title, content}). isOpen derived per item: openId === item.id. Clean separation of data and layout.
  • State Management: Single openId value tracks the open item. All open/closed states are derived from comparison with openId. No isOpen boolean per item.

Time checkpoints

  1. 1

    0:00: Read prompt. Ask: single-open or multi-open? Animation required? What does the items data shape look like?

  2. 2

    3:00: Plan state: single openId. Sketch toggle logic. Define items data shape.

  3. 3

    7:00: Render list of items with header buttons. Wire onClick toggle.

  4. 4

    12:00: Conditionally render content. Verify open/close toggle works.

Edge-case checklist

Empty data and first-load state
Slow network, failed request, and retry path
Keyboard navigation and focus movement
Large input size, re-render pressure, and cleanup

Common mistakes

  • Starting with JSX before naming state and events.
  • Ignoring accessibility until the final minute.
  • Over-building abstractions instead of finishing the required behavior.
  • Failing to narrate trade-offs while coding.
SolutionRead-only · Live Preview

Technical Explanation

Problem Understanding

Before writing any code, there's a critical clarifying question to ask the interviewer: single-open or multi-open? Single-open means only one panel can be expanded at a time — opening a new panel automatically closes the previous one. Multi-open means any number of panels can be open simultaneously. The state design is completely different for each.

  • Single-open: One openId value in state. Simple, most common in interview questions.
  • Multi-open: A Set of open IDs in state. Slightly more complex.

Ask this question. Don't assume. Asking clarifying questions is itself something interviewers are evaluating.

State Design — One Value Is Enough

For single-open mode, this is all you need:

const [openId, setOpenId] = useState(null); // null = all closed

Whether an item is open is derived, not stored:

const isOpen = openId === item.id; // derived per item
⚠️ Common Mistake: Storing an isOpen boolean inside each item object, then updating the whole items array on every click. This is a state explosion — you're managing N state variables when 1 is enough. If you have 10 accordion items, you only ever need to know which ONE is open, not the open/closed status of all 10 individually.

The Toggle Logic — One Line

function toggle(id) {
  setOpenId(prev => prev === id ? null : id);
}

Let's trace through this:

  • All closed (openId = null). User clicks item "q2". null === "q2" is false → openId becomes "q2". Panel "q2" opens.
  • Item "q2" is open. User clicks "q2" again. "q2" === "q2" is true → openId becomes null. Panel closes.
  • Item "q2" is open. User clicks "q3". "q2" === "q3" is false → openId becomes "q3". "q2" closes, "q3" opens.

One ternary handles all three cases. This is the most elegant formulation of accordion toggle logic, and it's worth memorizing.

💡 Interview Tip: Use the functional form of setState (prev => ...) here. If you call toggle rapidly, the functional form always uses the latest state value, not a stale closure capture. It's a good habit to use it whenever the new state depends on the old state.

Animation — The max-height Technique

This is where many candidates get stuck. CSS cannot animate height: auto. It's a known limitation — the browser doesn't know what "auto" is until after layout, so it can't interpolate between two values. The workaround is max-height:

/* Closed state */
.accordion-panel {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
}

/* Open state — set max-height to more than the content will ever be */
.accordion-panel-open {
  max-height: 600px;
}

Or as inline styles:

<div
  style={{ maxHeight: isOpen ? '600px' : '0', overflow: 'hidden', transition: 'max-height 0.3s ease' }}
>
  {item.content}
</div>

The transition animates from 0 → 600px on open and 600px → 0 on close. The actual content height doesn't matter as long as 600px is larger than it. The slight imperfection of this approach (the timing is based on 600px travel even if content is only 100px tall) is acceptable and widely used in production.

💡 Interview Tip: If asked "why not transition height directly?", explain: "CSS can't animate height:auto because the browser doesn't know the computed height until after layout. max-height is the standard workaround — you pick a value large enough to contain the content."

Full Implementation

const ITEMS = [
  { id: 'q1', title: 'What is React?', content: 'React is a JavaScript library for building user interfaces...' },
  { id: 'q2', title: 'What is a hook?', content: 'Hooks are functions that let you use React features in function components...' },
  { id: 'q3', title: 'What is the virtual DOM?', content: 'The virtual DOM is a lightweight in-memory representation...' },
];

export default function Accordion() {
  const [openId, setOpenId] = useState(null);

  function toggle(id) {
    setOpenId(prev => prev === id ? null : id);
  }

  return (
    <div className="accordion">
      {ITEMS.map(item => {
        const isOpen = openId === item.id; // derived
        return (
          <div key={item.id} className="accordion-item">
            {/* Header button */}
            <button
              className="accordion-header"
              onClick={() => toggle(item.id)}
              aria-expanded={isOpen}
              aria-controls={`panel-${item.id}`}
              id={`btn-${item.id}`}
            >
              <span>{item.title}</span>
              <span
                className="accordion-chevron"
                style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
              >
                ▾
              </span>
            </button>

            {/* Content panel */}
            <div
              id={`panel-${item.id}`}
              role="region"
              aria-labelledby={`btn-${item.id}`}
              aria-hidden={!isOpen}
              style={{
                maxHeight: isOpen ? '600px' : '0',
                overflow: 'hidden',
                transition: 'max-height 0.3s ease',
              }}
            >
              <div className="accordion-content">{item.content}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

ARIA Breakdown — Why Each Attribute Matters

  • aria-expanded={isOpen}: Tells screen readers whether this button controls an expanded or collapsed panel. Reads as "collapsed" or "expanded" before the button label.
  • aria-controls={`panel-${item.id}`}: Links the button to the panel it controls. Screen readers can jump to the associated content.
  • role="region": Makes the panel a landmark that screen readers can navigate to. Combined with aria-labelledby, it becomes a named region.
  • aria-labelledby={`btn-${item.id}`}: Associates the panel with its header button for labeling purposes.
  • aria-hidden={!isOpen}: Hides the collapsed panel content from the accessibility tree entirely. Screen readers won't read hidden content.
💡 Interview Tip: Most candidates only add aria-expanded. Adding aria-controls, role=region, and aria-labelledby shows you've actually read the ARIA specification for the disclosure widget pattern. Say: "I'm following the ARIA disclosure pattern — aria-controls links the button to its panel, and role=region with aria-labelledby makes the panel a named accessible landmark."

Chevron Animation

<span style={{
  transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
  transition: 'transform 0.3s ease',
  display: 'inline-block', // required for transform to work on inline elements!
}}>
  ▾
</span>
⚠️ Common Mistake: Forgetting display: inline-block on the chevron span. CSS transforms don't work on inline elements by default. Without it, the chevron won't rotate even though the styles look correct. This is a subtle CSS gotcha that's easy to miss.

Common Pitfalls Summary

  • Per-item isOpen state: Storing an isOpen boolean inside each item object requires updating the entire array on every click. One openId is simpler, faster, and easier to reason about.
  • Using array index as the ID: If items can be added, removed, or reordered, the index-based identification breaks. Always use a stable, unique id from the data.
  • Transitioning height:auto: CSS cannot animate auto values. Use max-height instead.
  • Forgetting display:inline-block on the chevron: Transforms don't apply to inline elements.
  • Using display:none for hiding: display:none removes the element from the DOM entirely — no animation possible. Use max-height:0 with overflow:hidden instead.

How to Communicate During the Interview

  • "First I want to clarify — should only one panel be open at a time, or can multiple be open? That determines whether I need one openId or a Set of open IDs."
  • "I'm using a single openId state — null means all closed. The toggle is one line: if the clicked ID is already open, set openId to null, otherwise set it to the clicked ID."
  • "For the animation, I can't transition height:auto, so I'm using the max-height technique — transition from 0 to a value large enough to contain any content."
  • "I'm following the ARIA disclosure pattern: aria-expanded on the button, aria-controls linking it to the panel, role=region and aria-labelledby on the panel itself."

Interview Criteria

React

Single openId state (null when all closed). Toggle logic is one line: setOpenId(prev => prev === id ? null : id). No per-item state — everything driven from one value.

HTML/CSS

Header uses <button> not <div>. aria-expanded, aria-controls on button. Panel has role=region, aria-labelledby. max-height transition for animation. overflow:hidden on panel.

Component Architecture

Accordion accepts items prop (array of {id, title, content}). isOpen derived per item: openId === item.id. Clean separation of data and layout.

State Management

Single openId value tracks the open item. All open/closed states are derived from comparison with openId. No isOpen boolean per item.

Edge Cases

Handles empty items array gracefully. Clicking open item closes it (toggle). Items with very long content don't break layout. Works with dynamic items (add/remove).

Time Checkpoints

0:00

0:00: Read prompt. Ask: single-open or multi-open? Animation required? What does the items data shape look like?

3:00

3:00: Plan state: single openId. Sketch toggle logic. Define items data shape.

7:00

7:00: Render list of items with header buttons. Wire onClick toggle.

12:00

12:00: Conditionally render content. Verify open/close toggle works.

17:00

17:00: Add ARIA: aria-expanded, aria-controls on button. role=region, aria-labelledby on panel.

22:00

22:00: Replace conditional render with max-height CSS transition for animation.

27:00

27:00: Style chevron rotation on open. Polish spacing and borders.

Streak

0 days

Last active: Sign in to track

Progress

0%

0/0 solved

Submitted

0

Solutions pushed to review history.