Elements

Accordion

An accordion stacks sections that expand and collapse under their headings: the questions of an FAQ, the groups of a settings page, the steps of a checkout. Five parts make it, each owning one piece of behavior and none of the look:

el-accordion.tsx
el-accordion.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger } from "nsh/base";
import { desktop, focusRing, hoverFill, Icon, Log, Panel, Segmented, size, Split, ui } from "./palette";

// An FAQ: each item a heading whose trigger (a button that says whether it
// is expanded) opens its answer. The app owns which items are open
// (`value`) and sets what `onChange` reports; one at a time, or several
// with `multiple`. The panels spring open and shut (`reveal`) and the
// items below move with them; the chevron turns with `group-aria-expanded:`.
// The pointer's hover and the keyboard's focus ring come from the palette;
// the reveal and the chevron stay still with reduced motion.
const pad = desktop ? "px-3" : "px-4";

const FAQ: { value: string; question: string; answer: string; disabled?: boolean }[] = [
  { value: "shipping", question: "How long does shipping take?", answer: "Orders leave the warehouse within a day and arrive in two to five working days." },
  { value: "returns", question: "Can I return an item?", answer: "Within 30 days, unused and in its box: print the label from your order and drop it off." },
  { value: "warranty", question: "What does the warranty cover?", answer: "Two years against defects in materials and workmanship, parts and labour included." },
  { value: "gift", question: "Can I send it as a gift?", answer: "Gift wrapping opens with the holiday season.", disabled: true },
];

const MODES = ["one at a time", "several"] as const;

function App() {
  const open = signal<string[]>(["shipping"]);
  const mode = signal<(typeof MODES)[number]>("one at a time");
  const events = signal<string[]>([]);
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>accordion</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Click a question, or Tab to it and press Space" : "Tap a question to read its answer"}</Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="multiple">
              <Segmented
                options={MODES}
                value={mode()}
                onChange={(value) => {
                  mode.set(value);
                  // One at a time keeps the item opened last.
                  if (value === "one at a time") open.set(open().slice(-1));
                }}
              />
            </Panel>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text }}>{open().length ? open().join(", ") : "none open"}</Text>
            </Panel>
            <Log title="onChange({ value })" lines={events()} minHeight={92} />
          </>
        }
      >
        <Accordion
          value={open()}
          multiple={mode() === "several"}
          onChange={({ value }) => {
            open.set(value);
            events.set([`[${value.join(", ")}]`, ...events()].slice(0, 4));
          }}
          style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}
        >
          {FAQ.map((item, i) => (
            <AccordionItem value={item.value} disabled={item.disabled}>
              <View style={{ height: i === 0 ? 0 : 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />
              <AccordionHeader>
                <AccordionTrigger
                  hoverStyle={item.disabled ? undefined : hoverFill()}
                  focusStyle={focusRing()}
                  class={`group flex-row items-center gap-3 ${pad} active:opacity-70 disabled:opacity-40`}
                  style={{ minHeight: size.row + 8 }}
                >
                  <Text style={{ flex: 1, fontSize: size.label, fontWeight: "500", color: ui().text }}>{item.question}</Text>
                  <View class="transition-transform duration-200 motion-reduce:transition-none group-aria-expanded:rotate-180">
                    <Icon name="chevron-down" size={14} tint={ui().muted} />
                  </View>
                </AccordionTrigger>
              </AccordionHeader>
              <AccordionPanel reveal accessibilityLabel={item.question}>
                <View class={`${pad} pb-3`}>
                  <Text style={{ fontSize: size.label, lineHeight: size.label * 1.45, color: ui().muted }}>{item.answer}</Text>
                </View>
              </AccordionPanel>
            </AccordionItem>
          ))}
        </Accordion>
      </Split>
    </View>
  );
}

mount(App);
  • Accordion, the stack: it holds the app’s value, the values of the open items, and reports the next one with onChange. One item opens at a time, or several with multiple.
  • AccordionItem, one section, identified by its value. It is open while the accordion’s value holds it.
  • AccordionHeader, the item’s heading, of a level screen readers announce.
  • AccordionTrigger, the button in the heading that expands and collapses its item, and says whether it is expanded.
  • AccordionPanel, the item’s content, shown while it is open; with reveal its height springs open and shut.
use nsh_base::{accordion, accordion_header, accordion_item, accordion_panel, accordion_trigger, theme};
use nsh_core::prelude::*;

const FAQ: [(&str, &str, &str); 2] = [
    ("shipping", "When will my order ship?", "Orders placed before 2 pm leave the same day."),
    ("returns", "How do I return an item?", "Print the label from your order within 30 days."),
];

fn faq() -> impl IntoElement {
    let open = signal(vec![SharedString::from("shipping")]);
    let mut list = accordion()
        .value(open)
        .on_change(move |value: &Vec<SharedString>| open.set(value.clone()));
    for (value, question, answer) in FAQ {
        list = list.child(
            accordion_item()
                .value(value)
                .border_b_1()
                .border_color(move || theme().border)
                .child(
                    accordion_header().child(
                        accordion_trigger()
                            .flex_row()
                            .items_center()
                            .justify_between()
                            .py_3()
                            .child(text(question).font_weight(FontWeight::MEDIUM))
                            .child(text("⌄").transition_transform().rotate(move || {
                                let open = open.get().iter().any(|v| &**v == value);
                                if open { deg(180.0) } else { deg(0.0) }
                            })),
                    ),
                )
                .child(
                    accordion_panel()
                        .reveal(true)
                        .accessibility_label(question)
                        .child(view().pb_3().child(text(answer).text_color(move || theme().muted_foreground))),
                ),
        );
    }
    list
}

The parts come from nsh/base, the nsh-base crate in Rust. The value is the app’s: a trigger’s press reports the next value with onChange, and the accordion opens the items whose values the app passes back. Each part finds the one around it on its own, so the app arranges them freely: dividers between the items, a badge in a heading, an icon before a title. In Rust, accordion(), accordion_item(), accordion_header(), accordion_trigger() and accordion_panel() make them, with a setter per prop and .child(..) for what is inside.

#Props

#Accordion

PropRustTypeDefaultDescription
value.value(..)string[][]The open items’ values. The app owns them: a trigger’s press reports the next ones with onChange, and the accordion shows the ones the app passes back.
multiple.multiple(..)booleanfalseSeveral items open at once: opening one leaves the others open. Without it, opening an item closes the one that was.
disabled.disabled(..)booleanfalseEvery item is disabled: no trigger takes presses, and screen readers say each is dimmed.
children.child(..)JSX.ElementnoneThe items, and whatever the app lays out between them (dividers).
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe stack’s layout and look: a card around the items, the gap between them.

#AccordionItem

PropRustTypeDefaultDescription
value.value(..)stringrequiredWhat identifies the item in the accordion’s value: the app’s own id, never its title, which changes with the language.
disabled.disabled(..)booleanfalseIts trigger takes no presses, and screen readers say it is dimmed. It stays open or closed as the accordion’s value says.
children.child(..)JSX.ElementnoneThe item’s header and panel, and whatever else the app lays out in it.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe section: a divider under it, a card around it.

#AccordionHeader

PropRustTypeDefaultDescription
level.level(..)number3The heading’s level among the page’s headings, for screen readers (1 the top).
children.child(..)JSX.ElementnoneThe item’s trigger, and whatever else the header holds (a badge, an action of its own).
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe heading’s layout.

#AccordionTrigger

PropRustTypeDefaultDescription
expandedStyle.expanded_style(|s| ..)StyleProp<ViewStyle>noneThe trigger’s style while its item is open, as aria-expanded: classes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say, when the children have no text that says it.
children.child(..)JSX.ElementnoneThe title and the chevron; group-aria-expanded: classes inside follow the trigger with group on its class.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe trigger itself: the row the user presses.
activeStyle, hoverStyle, focusStyle, disabledStyle.active(|s| ..), .hover(..), .focus(..), .disabled_style(..)StyleProp<ViewStyle>noneThe trigger’s style while pressed, hovered, focused or disabled, as the <state>: classes.

#AccordionPanel

PropRustTypeDefaultDescription
reveal.reveal(..)booleanfalseOpens and closes with a reveal, as Collapsible’s: the panel clips its content and its height springs from zero to the content’s own and back, critically damped (about 265 ms, no overshoot), and the items after it move along. With reduced motion it opens and closes at once.
keepMounted.keep_mounted(..)booleanfalseThe content stays in the tree while the item is closed, out of sight, of the keyboard focus and of screen readers: a video keeps its place, a web view its page. Without it the content leaves the tree as the panel closes (once the reveal has closed it) and comes back as it was.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers call the region: usually the item’s title, as its header says it.
children.child(..)JSX.ElementnoneThe content.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe region’s layout and look. With reveal the panel is in the layout while closed, zero high, so its padding and borders go on a view of its content.

#Events

EventRustDescription
onChange({ value }) (Accordion).on_change(|value: &Vec<SharedString>| ..)A trigger was pressed: a tap, a click, Space or Return while it has the keyboard focus, or a screen reader’s activation. value is the open items’ values as they would be: an open item’s value leaves it, a closed one’s joins it at the end, or, without multiple, replaces it. It runs before anything changes: set value to follow it.

#Parts

PartInsideStyled withStates
The stackAccordion’s class, style
An itemthe stackAccordionItem’s class, stylethe app’s value
The headingthe itemAccordionHeader’s class, style
The triggerthe headingAccordionTrigger’s class, stylearia-expanded:, disabled:, active:, hover:, focus: (or expandedStyle, disabledStyle, …)
The title and chevronthe triggertheir own elements (children)group-aria-expanded:, group-disabled: with group on the trigger’s class
The panelthe item, after the headingAccordionPanel’s class, style
The contentthe panelits own elements (children)

Without reveal, a closed panel is out of the layout and its content out of the tree. With reveal, the change is a layout animation of the commit that changes value (as LayoutAnimation.configureNext, run natively, so no JavaScript runs while it moves): the opening panel grows from zero to its content’s height, the closing one shrinks back, both in the same spring, and the items after them move along. A transition of the app’s goes with it: the chevron turns (transition-transform group-aria-expanded:rotate-180).

#Disabled

A disabled item’s trigger takes no presses, and screen readers announce it as dimmed; disabled: classes on the trigger and group-disabled: inside it follow. The accordion’s disabled disables every item. A disabled item still shows what the app’s value says: the app can open it.

#Accessibility

  • The stack is a group, each header a heading of its level, and each trigger a button inside it that says whether its item is expanded. Screen readers can move from heading to heading.
  • An open panel is a region, named by its accessibilityLabel; a closed one is hidden from screen readers, kept content included.
  • The look of the open item is the app’s, and should not rest on color alone: turn the chevron, as the examples do.

See Accessibility for labels, roles and announcements in general.

#Keyboard

Where views that take presses are stops of the keyboard focus (macOS and the web), each trigger is a stop, in the order of the page, and Space and Return press it. Disabled triggers are skipped, and so is the content of a closed panel. Tab moves from a trigger into its open panel’s controls and on to the next trigger.

#Platform notes

  • Every platform shows an open item’s panel under its heading and leaves a closed one out of the layout (zero high with reveal).
  • With reveal, every platform springs the panels’ heights on its own animator (iOS, Android, macOS, Windows, Linux, GPUI and the web), and opens and closes them at once while the system asks for reduced motion.
  • Each platform’s screen reader hears the heading and its level as it reads headings, and the trigger inside it as a button with its expanded state; on iOS, VoiceOver reads the trigger itself.
  • iOS, Android and Mac Catalyst: a trigger’s press is a tap. Desktops (macOS, Windows, Linux, GPUI) and the web: a click, and Space or Return on the focused trigger on macOS and the web.