CornerPanel
A round launcher pinned to the bottom corner that swaps for a panel: a full-width bottom sheet on a phone, a 380px card in the corner from md up. The shape behind a chat widget, an inbox or an account switcher — one application had hand-rolled it three times.
Import
import {
CornerPanel,
CornerPanelBody,
CornerPanelClose,
CornerPanelContent,
CornerPanelFooter,
CornerPanelHeader,
CornerPanelTitle,
CornerPanelTrigger,
} from "@mikenotthepope/substrateui"The swap
The launcher is not hidden behind the panel — it is unmounted. That is what makes the corner read as one object in two states rather than as two things fighting over the same 56 pixels.
It is also what makes returning focus awkward, and why this is worth having upstream at all. Focus cannot go back to an element that does not exist yet, so the hand-rolled copies reached for flushSync to force the launcher back into the DOM before focusing it. Here an effect in the root does it, after the commit that remounts the launcher — which is the ordering flushSync was buying.
It is not modal
There is no backdrop and nothing behind it is inert. A support widget that blanks the page it is meant to help with is the wrong trade, and aria-modal is false for the same reason. Reach for Dialog or Sheet when the rest of the page genuinely must wait.
Focus is trapped while it is open, which is the one modal-ish thing it does. None of the three copies trapped it; a Tab that wanders out of an open dialog and cannot find its way back is a keyboard user stranded, and closing it takes a reach for the mouse.
Placement
The trigger is fixed bottom-5 end-5 and the panel is inset-x-0 bottom-0 below md, md:bottom-5 md:end-5 md:w-[380px] above it. Both take a className, so passing absolute scopes them to a positioned ancestor instead of to the viewport — which is exactly what the specimen on this page does, so that three launchers do not pile up in one corner.
end-5 rather than right-5: the panel moves to the other corner under DirectionProvider without a second rule.
Accessibility
Escape closes it from anywhere inside, because the panel listens on itself and the event bubbles up from whatever had focus. The scrolling body is a role="region" with tabIndex={0}, named by the title: a scroll container nothing can focus is content a keyboard cannot read.
The launcher and CornerPanelClose are icon-only, so both need an accessible name. Close carries one already — override it through labels or LabelsProvider to translate it. The launcher is yours to name.
API Reference
CornerPanel
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Pair with `onOpenChange`. |
defaultOpen | boolean | false | Open state for the first render when uncontrolled. |
onOpenChange | (open: boolean) => void | — | Called with the state the panel wants to be in. |
Parts
| Prop | Type | Default | Description |
|---|---|---|---|
CornerPanelTrigger | Button props | — | The round launcher, fixed to the bottom end corner. Rendered only while the panel is closed. Icon-only, so give it an `aria-label`. |
CornerPanelContent | div props | — | The `role="dialog"` panel. Owns the focus trap and the Escape handler. Named by the title. |
CornerPanelHeader | div props + actions | — | The title row. `actions` takes the controls pinned to the end — Refresh, Close. |
CornerPanelTitle | h2 props | — | Names the panel and the scrolling body with it. |
CornerPanelClose | Button props + labels | — | An icon button that closes the panel. Labelled, because it is icon-only. |
CornerPanelBody | div props | — | The scrolling middle. `role="region"` with `tabIndex={0}`. |
CornerPanelFooter | div props | — | The pinned bottom — a composer, a pair of buttons. |