Terracotta
GitHub

For several sections that behave as a set, use Accordion.

import {
  Disclosure,
  DisclosureButton,
  DisclosurePanel,
} from 'terracotta/disclosure';
import { DisclosureStateChild, useDisclosureState } from 'terracotta/states';

Anatomy

<Disclosure>          {/* owns the open/closed state */}
  <DisclosureButton/> {/* toggles it */}
  <DisclosurePanel/>  {/* shown while open */}
</Disclosure>

Examples

Every demo below runs in its own document, wearing the theme selected in the header. None of the demos carry styles of their own: switch theme and the same markup renders differently.

Uncontrolled

Pass defaultOpen and the disclosure owns its state. This is the whole component: a button, a panel, and an attribute that says which way round they are.

The chevron turns with CSS alone. DisclosureButton carries tc-expanded while the panel is open, and the theme rotates on [tc-expanded]. No signal, no handler.

Controlled

Swap defaultOpen for isOpen and onChange and the state moves into your code. Nothing moves until setOpen runs; in controlled mode the component only reports the state it wants to move to.

Changing the label with the state

Disclosure, DisclosureButton and DisclosurePanel all accept a render prop that receives the disclosure state, so markup can follow the state without a second source of truth.

Closing from inside the panel

The same render prop on the panel hands you close, which is what a "done" or "apply" button in a filter popover needs.

Disabled

disabled blocks opening and closing, and stamps tc-disabled on all three elements so the styling follows.

Reacting to open and close

<Disclosure
  class="disclosure"
  defaultOpen={false}
  onOpen={() => void loadComments()}
  onClose={() => cancelPendingRequests()}
>
  …
</Disclosure>

onOpen runs before onChange, and onClose after it.

Keeping the panel mounted

unmount={false} leaves the panel in the DOM at all times, so form state and scroll position survive a collapse. You then own its visibility:

<DisclosurePanel class="disclosure-panel" unmount={false}>
  <textarea placeholder="Draft is preserved while collapsed" />
</DisclosurePanel>
/* Hide it yourself when the disclosure is closed */
.disclosure-panel:not([tc-expanded]) {
  display: none;
}

unmount="offscreen" is the middle ground. The panel is built once and reused, but still detached while closed, so its state survives without leaving anything in the document. Its effects keep running while it is hidden. See unmount.

Loading the panel content

A Loading boundary inside the panel behaves as it does anywhere else, and the panel does not wait for it: it opens, animates, and shows the fallback until the content arrives. See async content for how that lines up with transitions and with focus.

Animating the panel

<Disclosure class="disclosure" defaultOpen={false}>
  {({ isOpen }) => (
    <>
      <DisclosureButton class="disclosure-button">Toggle</DisclosureButton>
      <Transition
        show={isOpen()}
        enter="panel-enter"
        enterFrom="panel-from"
        enterTo="panel-to"
        leave="panel-leave"
        leaveFrom="panel-to"
        leaveTo="panel-from"
      >
        <DisclosurePanel class="disclosure-panel" unmount={false}>…</DisclosurePanel>
      </Transition>
    </>
  )}
</Disclosure>
.panel-enter { transition: opacity 150ms ease-out, translate 150ms ease-out; }
.panel-leave { transition: opacity 100ms ease-in, translate 100ms ease-in; }
.panel-from  { opacity: 0; translate: 0 -0.25rem; }
.panel-to    { opacity: 1; translate: none; }

Reading the state from a nested component

function PanelCount(): JSX.Element {
  const state = useDisclosureState();
  return <Show when={!state.isOpen()}><span class="badge">{count()}</span></Show>;
}

<DisclosureStateChild> does the same without a separate component.

State attributes

Element Attribute Present when
Disclosure tc-disclosure Always
Disclosure tc-expanded The disclosure is open
Disclosure tc-disabled The disclosure is disabled
DisclosureButton tc-disclosure-button, tc-button Always
DisclosureButton tc-expanded The disclosure is open
DisclosureButton tc-disabled The button or the disclosure is disabled
DisclosurePanel tc-disclosure-panel Always (whenever it is rendered)
DisclosurePanel tc-expanded The disclosure is open
DisclosurePanel tc-disabled The disclosure is disabled

The button also carries aria-expanded, and aria-controls pointing at the panel while open.

Styling

The demos above use these class names. Everything that changes as the disclosure opens is an attribute selector. There is no state in the CSS that Terracotta did not already put in the DOM.

.disclosure {
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  overflow: hidden;
}

.disclosure-button {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  inline-size: 100%;
  border: none;
  background: var(--surface-1);
  padding: 0.7rem 0.9rem;
  font: inherit;
  text-align: start;
  cursor: pointer;
}

/* The chevron turns when the section is open, with no JS involved */
.disclosure-chevron {
  transition: rotate 150ms ease;
}

.disclosure-button[tc-expanded] .disclosure-chevron {
  rotate: 90deg;
}

.disclosure[tc-disabled] .disclosure-button {
  opacity: 0.5;
  cursor: not-allowed;
}

.disclosure-panel {
  border-block-start: 1px solid var(--border);
  padding: 0.85rem 0.9rem;
}

State on the root cascades, so a single rule can restyle the whole widget:

[tc-disclosure][tc-expanded] { border-color: var(--accent); }

The button carries aria-expanded too, so [aria-expanded="true"] works just as well if you prefer to style from ARIA.

Reading the state in code

Member Type Description
isOpen() boolean Whether the disclosure is open.
open() / close() () => void Opens / closes it. No-op while disabled.
toggle() () => void Flips the state.
setState(value) (boolean) => void Sets the state directly.
disabled() boolean Whether the disclosure is disabled.

You can reach this state as the render-prop argument on Disclosure, DisclosureButton and DisclosurePanel, through <DisclosureStateChild>, or with useDisclosureState() in any descendant. Full reference in disclosure state.

Keyboard

Key Action
Enter / Space Toggles the disclosure, when the button has focus
Tab Moves through the button and, while open, into the panel

There is no Escape handling and no focus trap. A disclosure is inline content, not a modal. Use Popover if you need those.

API

<Disclosure>

Owns the disclosure state and renders a <div> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
defaultOpen boolean none Initial state, uncontrolled. Mutually exclusive with isOpen.
isOpen boolean none Current state, controlled. Mutually exclusive with defaultOpen.
disabled boolean false Blocks opening and closing.
onChange (state: boolean) => void none Called with the new state on every change.
onOpen () => void none Called when it opens, before onChange.
onClose () => void none Called when it closes, after onChange.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop receiving the state.
…rest props of as none Forwarded to the rendered element.

<DisclosureButton>

A Button that toggles the disclosure. Renders a <button> by default.

Prop Type Default Description
as ValidConstructor 'button' Element or component to render as.
disabled boolean false Disables this button. It is also disabled when the Disclosure is.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Label, or a render prop.
…rest props of as none Forwarded to the rendered element.

Its id is generated and linked to the panel through aria-controls.

<DisclosurePanel>

The content shown while open. Renders a <div> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
unmount boolean | 'offscreen' true How the panel behaves while closed. See unmount.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

DisclosureButton and DisclosurePanel throw if rendered outside a <Disclosure>.