Terracotta
GitHub
import {
  Popover,
  PopoverButton,
  PopoverPanel,
  PopoverOverlay,
} from 'terracotta/popover';
import { DisclosureStateChild, useDisclosureState } from 'terracotta/states';

Anatomy

<Popover>          {/* owns the open state */}
  <PopoverButton/> {/* toggles it, anchors the panel */}
  <PopoverOverlay/>{/* optional backdrop; closes on click */}
  <PopoverPanel/>  {/* the floating content */}
</Popover>

Examples

Uncontrolled

Controlled

const [open, setOpen] = createSignal(false);

<Popover class="popover" isOpen={open()} onChange={setOpen}>
  <PopoverButton class="popover-button">Account</PopoverButton>
  <PopoverPanel class="popover-panel">…</PopoverPanel>
</Popover>

Closing from inside the panel

With a backdrop

PopoverOverlay closes the popover when clicked. It gives you click-outside behaviour with a visible scrim. It is not conditionally mounted, so render it inside the render prop when you want it only while open:

<Popover class="popover" defaultOpen={false}>
  {({ isOpen }) => (
    <>
      <PopoverButton class="popover-button">Account</PopoverButton>
      <Show when={isOpen()}>
        <PopoverOverlay class="popover-overlay" />
      </Show>
      <PopoverPanel class="popover-panel">…</PopoverPanel>
    </>
  )}
</Popover>
.popover-overlay {
  position: fixed;
  inset: 0;
  background: rgb(0 0 0 / 0.1);
}

As a dropdown menu

Pair it with Menu when the panel contains actions, and pass aria-haspopup="menu" to the button. The popover then follows the menu button pattern:

  • Opening it focuses the first menu item.
  • ↓ on the button opens it on the first item, and ↑ on the last.
  • Tab and activating an item close it, instead of keeping focus in the panel.
<Popover class="popover" defaultOpen={false}>
  <PopoverButton class="popover-button" aria-haspopup="menu">Actions</PopoverButton>
  <PopoverPanel class="popover-panel popover-panel-flush">
    <Menu as="ul" class="menu">
      <MenuItem class="menu-item" onClick={rename}>Rename</MenuItem>
      <MenuItem class="menu-item" onClick={duplicate}>Duplicate</MenuItem>
      <MenuItem class="menu-item" onClick={remove}>Delete</MenuItem>
    </Menu>
  </PopoverPanel>
</Popover>
.popover-panel-flush { padding: 0.25rem; }

Positioning

Terracotta gives you behaviour, not geometry. Anchor the panel with CSS as above, or use the CSS anchor positioning API where it is supported:

.popover-button { anchor-name: --popover-anchor; }

.popover-panel {
  position: absolute;
  position-anchor: --popover-anchor;
  inset-block-start: anchor(bottom);
  inset-inline-start: anchor(start);
  margin-block-start: 0.25rem;
}

For a full-featured solution, pass a ref to PopoverButton and PopoverPanel, then hand both elements to a positioning library. Put the panel in a Portal so a parent's overflow cannot clip it, and use the fixed strategy:

import { Portal } from '@solidjs/web';
import { autoUpdate, flip, offset } from '@floating-ui/dom';
import { useFloating } from 'solid-floating-ui';

const [button, setButton] = createSignal<HTMLElement>();
const [panel, setPanel] = createSignal<HTMLElement>();
const position = useFloating(button, panel, {
  strategy: 'fixed',
  placement: 'bottom-start',
  middleware: [offset(4), flip()],
  whileElementsMounted: autoUpdate,
});

<Popover>
  <PopoverButton ref={setButton}>Options</PopoverButton>
  <Portal>
    <PopoverPanel
      ref={setPanel}
      style={{
        position: position.strategy,
        top: `${position.y ?? 0}px`,
        left: `${position.x ?? 0}px`,
      }}
    >
      …
    </PopoverPanel>
  </Portal>
</Popover>

A panel in a portal still counts as inside the panel it opened from. Focus can move into a nested popover without closing the outer one. See Portals.

Aligning to the end of the button

.popover-panel-end {
  inset-inline-start: auto;
  inset-inline-end: 0;
}

With a transition

<Popover class="popover" defaultOpen={false}>
  {({ isOpen }) => (
    <>
      <PopoverButton class="popover-button">Menu</PopoverButton>
      <Transition
        show={isOpen()}
        enter="pop-enter" enterFrom="pop-from" enterTo="pop-to"
        leave="pop-leave" leaveFrom="pop-to" leaveTo="pop-from"
      >
        <PopoverPanel class="popover-panel" unmount={false}>…</PopoverPanel>
      </Transition>
    </>
  )}
</Popover>
.pop-enter { transition: opacity 150ms ease-out, scale 150ms ease-out; }
.pop-leave { transition: opacity 100ms ease-in, scale 100ms ease-in; }
.pop-from  { opacity: 0; scale: 0.95; }
.pop-to    { opacity: 1; scale: 1; }

Disabled

<Popover class="popover" defaultOpen={false} disabled={!hasFilters()}>
  <PopoverButton class="popover-button">Filters</PopoverButton>
  <PopoverPanel class="popover-panel">…</PopoverPanel>
</Popover>
.popover-button[tc-disabled] { opacity: 0.5; cursor: not-allowed; }

Reading the state from a nested component

function CloseButton(): JSX.Element {
  const state = useDisclosureState();
  return <button type="button" onClick={() => state.close()}>Done</button>;
}

State attributes

Element Attribute Present when
Popover tc-popover Always
Popover tc-expanded The popover is open
Popover tc-disabled The popover is disabled
PopoverButton tc-popover-button, tc-button Always
PopoverButton tc-expanded The popover is open
PopoverButton tc-disabled The button or the popover is disabled
PopoverPanel tc-popover-panel Always (whenever rendered)
PopoverPanel tc-expanded, tc-disabled Mirrors the popover
PopoverOverlay tc-popover-overlay Always
PopoverOverlay tc-expanded, tc-disabled Mirrors the popover

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

Styling

/* Root-level state cascades to everything inside */
[tc-popover][tc-expanded] .popover-label { color: #1d4ed8; }

/* Rotate a caret on the button */
[tc-popover-button]::after {
  content: "▾";
  margin-inline-start: 0.375rem;
  display: inline-block;
  transition: rotate 150ms ease;
}
[tc-popover-button][tc-expanded]::after { rotate: 180deg; }

/* The overlay is always mounted, so hide it while closed */
[tc-popover-overlay]:not([tc-expanded]) { display: none; }

/* With unmount={false} on the panel, you own its visibility */
[tc-popover-panel]:not([tc-expanded]) { display: none; }

Reading the state in code

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

You can reach this state as the render-prop argument on Popover and each of its parts, through <DisclosureStateChild>, or with useDisclosureState() in any descendant. Full reference in disclosure state.

Behaviour and keyboard

Key / event Action
Click on PopoverButton Toggles the popover
Enter / Space on the button Same
Escape Closes it
Tab / Shift+Tab Cycles focus within the panel. Closes it when the panel holds a Menu.
↓ / ↑ on a button with aria-haspopup="menu" Opens it on the first / last menu item
Activating a menu item in the panel Closes it
Focus leaves the panel Closes it, unless the pointer is hovering the button, so a click on the button reads as a toggle rather than a close-then-reopen

The panel focuses its first menu item when it opens, or else its first focusable element. Focus returns to where it was when the popover closes.

API

<Popover>

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.
…rest props of as none Forwarded to the rendered element.

<PopoverButton>

A Button that toggles the popover. 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 Popover 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.

<PopoverPanel>

The floating content, with the focus trap. Renders a <div> by default.

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.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

<PopoverOverlay>

An optional backdrop that closes the popover when clicked. Renders a <div> by default. It is not conditionally mounted, so hide it with CSS or wrap it in a <Show>.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

Every descendant throws if rendered outside a <Popover>.