Skip to content
Twilight React Playground
ثيم رائدaren

Collapse

componentBeginnerserverbrowserlive demo

A section that opens and closes under its heading, like one FAQ question, where the trigger and the content each receive the open state.

import { Collapse, CollapseProps, CollapseTriggerProps, CollapseContentProps } from '@salla.sa/twilight-theme-engine/components/collapse';

In plain words

A collapse is a heading you click to show or hide the text under it, like the questions on an FAQ page. Several together make an accordion.

Like the other overlays, it keeps no state of its own; your component decides what is open. The catch is where that goes: Collapse.Trigger needs isOpen and onToggle (the function to call when it is clicked), and Collapse.Content needs isOpen. The outer Collapse asks for both too, but ignores them.

The content opens with a short height animation, without your code measuring anything.

Signature

function Collapse(props: CollapseProps): JSX.Element  // <div class="overflow-hidden">

interface CollapseProps {
  isOpen: boolean;       // required, but not read
  onToggle: () => void;  // required, but not read
  children: ReactNode;
  className?: string;
}

Collapse.Trigger(props: CollapseTriggerProps)  // <button type="button" aria-expanded>
Collapse.Content(props: CollapseContentProps)  // grid rows 0fr ↔ 1fr, 200ms

interface CollapseTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode;
  isOpen?: boolean;       // default false
  onToggle?: () => void;
}
interface CollapseContentProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
  isOpen?: boolean;       // default false
}

Try it live

An FAQ accordion. The parent keeps which answers are open; each part is told on its own.Try this: turn "inert while closed" off, close the last answer, then press Tab through the list: the hidden button still gets focus.
Storefront canvas · en · LTR

Two to five working days in most cities.

Yes, within 14 days of delivery, unused and in its box.

Send us a message and we usually answer within a day.

Controls
One answer at a time
inert while closedClosed content stays in the page. inert takes it out of Tab order and screen readers.
What a theme writes
import { useState } from 'react';
import { Collapse } from '@salla.sa/twilight-theme-engine/components/collapse';

export function Faq({ items }: { items: { id: string; question: string; answer: string }[] }) {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <div>
      {items.map(({ id, question, answer }) => {
        const isOpen = openId === id;
        const toggle = () => setOpenId(isOpen ? null : id);
        return (
          <Collapse key={id} isOpen={isOpen} onToggle={toggle}>
            <Collapse.Trigger isOpen={isOpen} onToggle={toggle} className="w-full py-3 font-bold">
              {question}
            </Collapse.Trigger>
            <Collapse.Content isOpen={isOpen} inert={!isOpen}>
              <p>{answer}</p>
            </Collapse.Content>
          </Collapse>
        );
      })}
    </div>
  );
}

Example

app/components/Faq.tsx
import { useState } from 'react';
import { Collapse } from '@salla.sa/twilight-theme-engine/components/collapse';

const QUESTIONS = [
  { id: 'delivery', question: 'How long does delivery take?', answer: 'Two to five working days.' },
  { id: 'returns', question: 'Can I return an item?', answer: 'Yes, within 14 days.' },
];

export function Faq() {
  const [openId, setOpenId] = useState<string | null>(null);

  return (
    <div className="divide-y">
      {QUESTIONS.map(({ id, question, answer }) => {
        const isOpen = openId === id;
        const toggle = () => setOpenId(isOpen ? null : id);
        return (
          <Collapse key={id} isOpen={isOpen} onToggle={toggle}>
            {/* Trigger and Content do not read the outer Collapse: pass the state to each. */}
            <Collapse.Trigger isOpen={isOpen} onToggle={toggle} className="w-full py-3 font-bold">
              {question}
            </Collapse.Trigger>
            <Collapse.Content isOpen={isOpen} inert={!isOpen}>
              <p>{answer}</p>
            </Collapse.Content>
          </Collapse>
        );
      })}
    </div>
  );
}

How it behaves

  • There is no context between the parts: the root only renders <div className="overflow-hidden …">, and each part is controlled on its own. An accordion is one piece of state (the open id) in the parent.

  • Collapse.Content always renders its children. Open it is grid grid-rows-[1fr] opacity-100, closed grid grid-rows-[0fr] opacity-0, with transition-all duration-200 and an inner overflow-hidden wrapper holding a pb-2 box. Because the text is in the HTML either way, answers are in the server-rendered page.

  • Collapse.Trigger is a real <button type="button">, styled flat (border-0 bg-transparent p-0 text-start), with aria-expanded. Nothing sets aria-controls; add an id to the content and aria-controls to the trigger yourself (both pass extra attributes through).

  • It has no effects and needs no provider, so it renders the same on the server and in the browser. The same export is also at @salla.sa/twilight-theme-engine/collapse.

Gotchas

  • Passing isOpen and onToggle only to the outer Collapse gives a section that never opens: Collapse ignores them, the trigger has no click handler, and the content's isOpen defaults to false. The engine's own example at the top of Collapse.tsx does exactly this. Pass isOpen and onToggle to Collapse.Trigger, and isOpen to Collapse.Content.

  • The accordion example in the engine's collapse/index.ts gives the trigger onToggle={() => setOpenId(item.id)}, so an open question can never be closed again. Toggle back to null when it is already open, as in the example above.

  • Closed content is only squeezed to zero height, not removed or hidden: links, buttons and inputs inside a closed section can still be reached with Tab and are read by screen readers. Add inert={!isOpen} to Collapse.Content (it passes attributes through).

  • Collapse.Trigger spreads your props after its own, so an onClick you pass replaces onToggle rather than adding to it.

Related

Source and docs