PixelPopover

Controlled floating panel anchored to a trigger, with focus return, dismiss-on-escape, and outside-click handling.

  • Controlled open state for predictable behaviour: open + onOpenChange (React), v-model:open (Vue), [(open)] (Angular)
  • Floating-UI placement with side, align, and sideOffset
  • closeOnEscape and closeOnOutsideClick dismissal
  • Portal-rendered content with surface-aware theming
  • Compound API: Trigger, Content, Arrow
Status
stable
Category
overlay-foundation
Since
v1.8.0

API

import { PixelPopover } from '@pxlkit/ui-kit';

PixelPopover

PropTypeDefaultDescription
open (required)boolean—Whether the popover is open; set it from onOpenChange.
onOpenChange (required)(open: boolean) => void—Called with the open state the trigger, Escape or a press outside asks for.
children (required)React.ReactNode—PixelPopover.Trigger and PixelPopover.Content.
side'top' | 'bottom' | 'left' | 'right''bottom'Side of the trigger the content opens on; flips when there is no room.
align'start' | 'center' | 'end''center'Alignment of the content along that side.
sideOffsetnumber8Gap between trigger and content, in px.
closeOnEscapebooleantrueClose when Escape is pressed.
closeOnOutsideClickbooleantrueClose on a press outside the trigger and the content.
surface'pixel' | 'linear'—Surface override; defaults to the nearest provider.
haspopup'dialog' | 'listbox' | 'menu' | 'tree' | 'grid''dialog'ARIA aria-haspopup value advertised on the trigger. Default 'dialog'. Set to 'listbox' for combobox patterns, 'menu' for menu patterns, etc.
role'dialog' | 'none' | 'listbox' | 'menu''dialog'Role applied to the content element. Default 'dialog'. Set to 'none' (or any non-dialog value) when the popover wraps an inner widget that owns the semantics (e.g. a listbox inside a combobox).

PixelPopover.Trigger

PropTypeDefaultDescription
children (required)React.ReactElement—The trigger: one element, which toggles the popover and gets its aria-expanded, aria-haspopup and aria-controls.

ref points to its element (HTMLElement).

PixelPopover.Content

PropTypeDefaultDescription
surface'pixel' | 'linear'—Surface override; defaults to the popover's.

Also takes the native attributes and event handlers of <div> (HTMLAttributes<HTMLDivElement>).

ref points to <div>.

PixelPopover.Arrow

No props of its own.

Also takes the native attributes and event handlers of <span> (HTMLAttributes<HTMLSpanElement>).

ref points to <span>.

Accessibility

WCAG target: 2.1 AA

  • dialog

Content renders with role="dialog" by default; pair with aria-labelledby on Content. Set role="none" when an inner widget owns semantics. While the content is open, the trigger points at it with aria-controls (Content keeps the id it is given, or gets a generated one); a trigger that sets its own aria-controls keeps it. When the content closes while it holds focus, focus returns to the trigger; after a press outside, focus follows the pointer instead.

Keyboard

KeyActionWhen
EscapeCloses the popover when closeOnEscape is true and returns focus to the trigger—

Usage

import { useState } from 'react';
import { PixelPopover } from '@pxlkit/ui-kit';

export function Default() {
  const [open, setOpen] = useState(false);
  return (
    <PixelPopover open={open} onOpenChange={setOpen}>
      <PixelPopover.Trigger>
        <button type="button">Open popover</button>
      </PixelPopover.Trigger>
      <PixelPopover.Content aria-labelledby="popover-title">
        <h3 id="popover-title" className="font-bold mb-1">
          Popover
        </h3>
        <p className="text-sm">Floating content anchored to the trigger.</p>
      </PixelPopover.Content>
    </PixelPopover>
  );
}

Examples

Default

import { useState } from 'react';
import { PixelPopover } from '@pxlkit/ui-kit';

export function Default() {
  const [open, setOpen] = useState(false);
  return (
    <PixelPopover open={open} onOpenChange={setOpen}>
      <PixelPopover.Trigger>
        <button type="button">Open popover</button>
      </PixelPopover.Trigger>
      <PixelPopover.Content aria-labelledby="popover-title">
        <h3 id="popover-title" className="font-bold mb-1">
          Popover
        </h3>
        <p className="text-sm">Floating content anchored to the trigger.</p>
      </PixelPopover.Content>
    </PixelPopover>
  );
}

With arrow

import { useState } from 'react';
import { PixelPopover } from '@pxlkit/ui-kit';

export function WithArrow() {
  const [open, setOpen] = useState(false);
  return (
    <PixelPopover open={open} onOpenChange={setOpen} side="bottom" align="center">
      <PixelPopover.Trigger>
        <button type="button">With arrow</button>
      </PixelPopover.Trigger>
      <PixelPopover.Content aria-labelledby="popover-arrow-title">
        <h3 id="popover-arrow-title" className="font-bold mb-1">
          Pointed popover
        </h3>
        <p className="text-sm">Includes a decorative arrow.</p>
        <PixelPopover.Arrow />
      </PixelPopover.Content>
    </PixelPopover>
  );
}

Side placement

import { useState } from 'react';
import { PixelPopover } from '@pxlkit/ui-kit';

export function SidePlacement() {
  const [open, setOpen] = useState(false);
  return (
    <PixelPopover
      open={open}
      onOpenChange={setOpen}
      side="right"
      align="start"
      sideOffset={12}
    >
      <PixelPopover.Trigger>
        <button type="button">Right / start</button>
      </PixelPopover.Trigger>
      <PixelPopover.Content aria-labelledby="popover-side-title">
        <h3 id="popover-side-title" className="font-bold mb-1">
          Side placement
        </h3>
        <p className="text-sm">Anchored to the right of the trigger.</p>
      </PixelPopover.Content>
    </PixelPopover>
  );
}

Interactive content

import { useState } from 'react';
import { PixelPopover } from '@pxlkit/ui-kit';

export function InteractiveContent() {
  const [open, setOpen] = useState(false);
  return (
    <PixelPopover open={open} onOpenChange={setOpen} align="start">
      <PixelPopover.Trigger>
        <button type="button">Rename layer</button>
      </PixelPopover.Trigger>
      <PixelPopover.Content aria-labelledby="popover-form-title" className="w-64">
        <h3 id="popover-form-title" className="font-bold mb-2">
          Rename layer
        </h3>
        <label htmlFor="popover-form-name" className="block text-sm mb-1">
          Name
        </label>
        <input
          id="popover-form-name"
          defaultValue="Background"
          className="w-full mb-3 px-2 py-1 text-sm bg-retro-surface border border-retro-border text-retro-text"
        />
        <button type="button" onClick={() => setOpen(false)}>
          Save
        </button>
      </PixelPopover.Content>
    </PixelPopover>
  );
}

Related