Rendering
How Terracotta turns its parts into DOM: which element it renders, which props reach that element, how you get a handle on it, and what happens to hidden content.
as, or polymorphic rendering
Every rendered component accepts as. It picks the element or component to
render, and takes either an intrinsic tag name ('section', 'li', …) or any
Solid component.
<DisclosureButton as="a" href="#panel">Toggle</DisclosureButton>
<DialogPanel as={MyCard}>…</DialogPanel>Each component page documents its own default. The types follow as: with
as="a", the component also accepts <a> props such as href.
Behaviour comes from the component, not the tag. A Button rendered as="div"
still responds to Enter and Space, because Terracotta adds
the keyboard handling that a native <button> would have given you.
Prop passthrough
Props that a component does not use itself are forwarded to the rendered
element. That includes class, classList, style, id and DOM event
handlers. Terracotta strips its own props (value, disabled, unmount, …)
first, so they never show up in the DOM as stray attributes.
<ListboxOption value={person} class="option" data-analytics="picker">
{person.name}
</ListboxOption>The component consumes value. class and data-analytics land on the <li>.
ref
Components that need a DOM handle accept ref. Terracotta assigns it from an
effect instead of writing to it directly. A ref callback therefore runs in the
same reactive scope it would have if you had written the element yourself, so
createEffect works inside one.
Components that take no ref say so in their reference table.
unmount
Components that hide content accept unmount:
| Value | Behaviour |
|---|---|
true (default) |
Children are removed from the DOM while hidden, and rebuilt when shown. |
false |
Children are always rendered. Use this to hide with CSS, or to wrap the element in a <Transition> that needs it to survive a leave animation. |
'offscreen' |
Children are created once and reused, but still attached conditionally. Keeps expensive subtrees alive across toggles. |
The default is the safe one. Hidden content is really gone, so Tab
cannot reach it and screen readers cannot announce it. Use false only when
something else needs the element to stay alive, such as a transition or a
measurement, and hide it yourself.
Inside a Transition, unmount={false} is
handled for you: the transitioning element is marked inert once it starts
leaving and stays that way while hidden, so its content leaves the tab order and
the accessibility tree even though it is still in the DOM.
unmount="offscreen"
'offscreen' sits between the two. The children are built the first time the
component renders and are never torn down again; hiding only detaches them from
the document, and showing puts the very same nodes back.
<PopoverPanel class="panel" unmount="offscreen">
<ExpensiveChart data={points()} />
</PopoverPanel>The chart is constructed once. Reopening the popover reattaches the element that was already there rather than mounting a second chart, so anything the component holds in its own state, whether a computed layout, a loaded dataset or an uncontrolled input's value, is still there. Being detached, the content is out of the document while hidden, so Tab cannot reach it and screen readers cannot announce it, exactly as with the default.
What does not stop is the work. A detached subtree is still mounted as far as
Solid is concerned: its effects keep running, its subscriptions stay subscribed,
and any interval or polling loop it started keeps firing while nothing is on
screen. 'offscreen' is worth it for a subtree that is expensive to build and
cheap to keep; it is the wrong choice for one that is expensive to run. Prefer
the default there, or pause the work yourself while the component is closed.
Measurements are the other thing to watch. A detached element has no layout, so
getBoundingClientRect() reads zeroes and offsetWidth is 0 until it is
attached again. Measure after it becomes visible, not while it is hidden.
Portals
Terracotta renders every part where you put it. It never moves a panel into a
portal for you. Wrap a panel in Solid's <Portal> when it needs to escape a
parent's overflow, transform or stacking context.
import { Portal } from '@solidjs/web';
<Popover>
<PopoverButton>Options</PopoverButton>
<Portal>
<PopoverPanel class="popover-panel">…</PopoverPanel>
</Portal>
</Popover>A panel in a portal still belongs to the panel it was opened from. Terracotta
tracks nesting through the component tree, not the DOM. This applies to
Dialog, AlertDialog, CommandBar, PopoverPanel, ContextMenuPanel,
ListboxOptions and ComboboxOptions.
- Moving focus into a nested panel does not close the panel around it.
- Moving focus from a nested panel to the page closes both.
- A click in a nested panel is not a click outside a
ContextMenuPanel. - An open modal does not make its nested panels inert.
Positioning stays yours. A panel in a portal is no longer next to its button,
so position: absolute relative to the button stops working. Use
position: fixed with a positioning library such as solid-floating-ui. See
Popover positioning.
To stack nested panels, render each one later in the DOM than its parent with
the same z-index, or give it a higher one.
Async content
Panels hold whatever you put in them, Loading included, and a boundary inside
a panel behaves the way it would anywhere else. What is worth knowing is how it
lines up with the two things a panel does when it opens: transition, and move
focus.
<Transition show={isOpen()} enter="panel-enter" enterFrom="from" enterTo="to">
<DisclosurePanel unmount={false}>
<Loading fallback={<Spinner />}>
<Comments />
</Loading>
</DisclosurePanel>
</Transition>Transitions do not wait for the resource. The transition drives the panel element, and the boundary swaps its own contents underneath. The panel animates in on schedule with the fallback inside it, and the content replaces the fallback whenever it arrives, mid-animation or long after. Closing while the resource is still pending is fine too: the leave transition runs, and the pending boundary is removed along with the panel.
Put the boundary the other way round, with a Loading wrapping the
Transition, and the transitioning element is not in the document while
suspended, so nothing
animates until the boundary resolves. Solid holds back the effects created under
a suspended boundary, so the transition runs when the content lands rather than
being missed.
Focus is taken once, when the panel opens. Dialog, AlertDialog, Popover, ContextMenu and CommandBar look through the panel for something focusable as they open. A panel showing a fallback usually has nothing to offer.
- Dialog, AlertDialog, CommandBar and ContextMenu then focus the panel itself. Escape and Tab still work, but the content that arrives later does not get focus.
- Popover leaves focus where it was.
No second look is taken. The dialog pattern expects focus on an element inside the dialog, so this is worth designing around. An async read defers even when its value has already settled, so a boundary always shows its fallback for the render the panel looks at. Having the data ready in advance does not help.
Two ways to keep focus where it belongs:
-
Keep something focusable in the panel that is not behind the boundary, such as a close button, which a dialog usually wants anyway. It is there when the panel opens, takes focus, and keeps it while the content loads.
<DialogPanel> <button type="button" onClick={close}>Close</button> <Loading fallback={<Spinner />}> <Details /> </Loading> </DialogPanel> -
Or hoist the boundary above the component, so the panel is built only once its content is ready and finds it on the first look.
<Loading fallback={<Spinner />}> <Dialog isOpen={isOpen()}> <DialogPanel> <Details /> </DialogPanel> </Dialog> </Loading>The trade-off is that nothing of the dialog exists while loading, so the fallback has to stand in for the whole thing rather than for its contents.