Elements
Collapsible
A collapsible is a region whose content expands and collapses: details under a summary, a “show more”, a section of
advanced settings. It renders its children always and its content after them while open. The app owns open and
the trigger that flips it (one of the children), and the look is the app’s: a Collapsible has no trigger, chevron,
motion or label of its own.
import { type JSX, mount, signal, Text, View } from "nsh";
import { Collapsible, Switch } from "nsh/base";
import { desktop, focusRing, hoverFill, Log, Panel, size, Split, ui } from "./palette";
// Each section: a summary row, the app's trigger (it says whether it is
// expanded to screen readers), and details the collapsible shows while the
// app has it open. The chevron turns with a transition of the app's. One
// inset per card, leading and trailing: the summary's chevron lane is as
// wide as the turned chevron, so it ends where the values below it end.
// The pointer's hover and the keyboard's focus ring come from the palette;
// the chevron and the switch stay still with reduced motion.
const pad = desktop ? "px-3" : "px-4";
function Section(props: { label: string; detail: string; open: boolean; onToggle: () => void; content: JSX.Element }) {
return (
<Collapsible open={props.open} content={props.content}>
<View
role="button"
accessibilityState={{ expanded: props.open }}
onPress={props.onToggle}
hoverStyle={hoverFill()}
focusStyle={focusRing()}
class={`flex-row items-center gap-3 ${pad} active:opacity-70`}
style={{ minHeight: size.row + 8 }}
>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
</View>
<View class="h-5 w-2 items-center justify-center transition-transform duration-150 motion-reduce:transition-none" style={{ transform: [{ rotate: props.open ? "90deg" : "0deg" }] }}>
<Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
</View>
</View>
</Collapsible>
);
}
const USAGE: [string, string][] = [
["Photos", "120 GB"],
["Apps", "54 GB"],
["System", "38 GB"],
];
function App() {
const storage = signal(true);
const advanced = signal(false);
const originals = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
const toggle = (name: string, open: { (): boolean; set: (v: boolean) => void }) => () => {
open.set(!open());
log(`${name} → ${open() ? "open" : "closed"}`);
};
const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>collapsible</Text>
<Text style={{ color: ui().muted }}>{desktop ? "Click a summary, or Tab to it and press Space" : "Tap a summary to show its details"}</Text>
<Split
gap={14}
inspector={
<>
<Panel title="open">
<Text style={{ fontSize: 13, color: ui().text }}>
Storage {storage() ? "open" : "closed"} · Advanced {advanced() ? "open" : "closed"}
</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>Keep originals {originals() ? "on" : "off"}: kept while collapsed</Text>
</Panel>
<Log title="onPress (trigger) → open" lines={events()} minHeight={92} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
<Section
label="Storage"
detail="212 GB of 256 GB used"
open={storage()}
onToggle={toggle("Storage", storage)}
content={
<View class={`${pad} pb-2`}>
{USAGE.map(([name, value]) => (
<View class="flex-row items-center" style={{ minHeight: size.rowSm }}>
<Text style={{ flex: 1, fontSize: size.label, color: ui().muted }}>{name}</Text>
<Text style={{ fontSize: size.label, color: ui().muted, fontVariant: ["tabular-nums"] }}>{value}</Text>
</View>
))}
</View>
}
/>
{line()}
<Section
label="Advanced"
detail="Options most people leave alone"
open={advanced()}
onToggle={toggle("Advanced", advanced)}
content={
<Switch
checked={originals()}
onChange={(next) => {
originals.set(next);
}}
class={`flex-row-reverse items-center justify-between gap-3 ${pad} pb-3 active:opacity-70`}
hoverStyle={hoverFill()}
focusStyle={focusRing()}
trackClass={`${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`}
trackStyle={originals() ? ui().accentFill : ui().inset}
thumbClass={desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5"}
thumbStyle={originals() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ flex: 1, fontSize: size.label, color: ui().text }}>Keep originals</Text>
</Switch>
}
/>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{collapsible, theme};
use nsh_core::{AccessibilityState, ui::prelude::*};
fn storage() -> impl IntoElement {
let open = signal(false);
collapsible()
.open(open)
.gap_2()
.child(
view()
.role(AccessibilityRole::Button)
.accessibility_state(move || AccessibilityState {
expanded: Some(open.get()),
..AccessibilityState::default()
})
.on_press(move || open.update(|o| *o = !*o))
.flex_row()
.items_center()
.justify_between()
.child(text("Storage"))
.child(text("›").transition_transform().rotate(move || {
if open.get() { deg(90.0) } else { deg(0.0) }
})),
)
.content(text("Photos 120 GB · Apps 54 GB · System 38 GB").text_color(move || theme().muted_foreground))
}import { mount, signal, Text, View } from "nsh";
import { Collapsible } from "nsh/base";
function App() {
const open = signal(false);
return (
<Collapsible
open={open()}
class="gap-2"
content={<Text class="text-muted-foreground">Photos 120 GB · Apps 54 GB · System 38 GB</Text>}
>
<View
role="button"
accessibilityState={{ expanded: open() }}
onPress={() => open.set(!open())}
class="flex-row items-center justify-between active:opacity-70"
>
<Text class="font-medium">Storage</Text>
<Text class={`transition-transform ${open() ? "rotate-90" : ""}`}>›</Text>
</View>
</Collapsible>
);
}
mount(App);Collapsible comes from nsh/base, the nsh-base crate in Rust. The value is the app’s:
its trigger flips open, and the collapsible shows the value the app passes. In Rust, collapsible() makes one with a
setter per prop (.open(..), .content(..)), the typed style methods for the region and .child(..) for what shows
always.
#Props
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
open | .open(..) | boolean | false | Expanded: the content is in the tree, after the children. The app owns the value. |
content | .content(..) | JSX.Element | none | What expands. It is made once and kept while collapsed, so what is inside keeps its state (an input’s text, a scroll position, a switch) from one opening to the next. |
children | .child(..) | JSX.Element | none | What shows either way: a summary, the trigger that flips open. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The region around the children and the content (gap-2 spaces them). |
#Events
A collapsible reports nothing of its own: the trigger is the app’s, and its press (onPress of the View with
role="button") is where the app flips open.
#Parts
| Part | Inside | Styled with |
|---|---|---|
| The region | class, style | |
| The children | the region, first | their own elements |
| The content | the region, after the children, while open | its own elements (content) |
The content comes and goes at once. A transition of the app’s shows the change: the trigger’s chevron turns
(transition-transform), and the content can fade in with its own classes. Each platform lays the region out again
when the content arrives or leaves.
#Accessibility
- The region is no element of its own for screen readers: its children and content are read as they are.
- The trigger says whether the region is expanded: give it
accessibilityState={{ expanded: open }}and a button role (role="button"on aView), and screen readers announce “expanded” or “collapsed” with its label. - On macOS and the web the trigger is a stop of the keyboard focus when it takes presses: Tab reaches it, Space and Return flip the region.
See Accessibility for labels, roles and announcements in general.
#Platform notes
- Every platform shows the content after the children while
open, and leaves it out of the layout otherwise. - iOS, Android and Mac Catalyst: the 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.