Navigation

v0.0.4
Overlays

Popover

A floating sheet anchored to the button that opens it.

On this page

A popover is a floating sheet that belongs to the button that opened it. Add the popover attribute and .popover to any element, and point a button at it with popovertarget. The browser moves it to the top layer, closes it on an outside click or Esc, and keeps it next in the tab order after its button. CSS anchor positioning places it, so there is no positioning script.

Popover

<button type="button" class="btn" popovertarget="share">Share</button><div id="share" popover class="popover">  <div class="flex flex-col gap-sm w(260)">    <p class="m(0) fg-text-strong">Share this project</p>    <div class="input-group input-group-sm">      <input class="input-group-field" value="https://atlas.dev/p/9f2c" readonly aria-label="Link">      <button type="button" class="btn">Copy</button>    </div>  </div></div>

#Placement

A popover opens 6px below its button and lines up with the button's start edge. The button is its anchor without any name: the popover API connects the two. When the popover would leave the viewport it flips to the other side.

ClassPlacement
popoverBelow, lined up with the start edge
popover-endLined up with the end edge, for a button at the end of a row
popover-centerCentered on the button
popover-topAbove the button; combine it with popover-end or popover-center

Placement

<button type="button" class="btn" popovertarget="place-start">Start</button><button type="button" class="btn" popovertarget="place-center">Center</button><button type="button" class="btn" popovertarget="place-end">End</button><button type="button" class="btn" popovertarget="place-top">Top</button><div id="place-start" popover class="popover">Lined up with the start edge.</div><div id="place-center" popover class="popover popover-center">Centered on the button.</div><div id="place-end" popover class="popover popover-end">Lined up with the end edge.</div><div id="place-top" popover class="popover popover-top popover-center">Above the button.</div>

In a browser without anchor positioning the popover still opens and closes; it appears centered in the viewport, where the browser places any popover.

#With a form

A popover can hold controls. Focus stays on the button when it opens; Tab moves into the popover, or put autofocus on the first field to start there.

With a form

<button type="button" class="btn" popovertarget="rename">Rename</button><form id="rename" popover class="popover">  <div class="flex flex-col gap-sm w(240)">    <label class="field">Project name      <input class="input input-sm" value="atlas" autofocus>    </label>    <div class="flex justify-end gap-xs">      <button type="button" class="btn btn-sm" popovertarget="rename" popovertargetaction="hide">Cancel</button>      <button type="button" class="btn btn-sm btn-primary" popovertarget="rename" popovertargetaction="hide">Save</button>    </div>  </div></form>

#On hover

A popover can also open when a link or a button is hovered or focused, as a preview of where it leads. Give it popover="hint" and point the trigger at it with interestfor. A hint does not close a menu or another popover that is already open. Keep the content a preview: a reader on a touch screen reaches it only by a long press, so nothing should be available here alone.

On hover

<p class="m(0) text-sm fg-text-body">Deployed by  <a class="link" href="#ada" interestfor="ada-card">Ada Lovelace</a>two hours ago.</p><div id="ada-card" popover="hint" class="popover">  <div class="flex items-center gap-sm">    <span class="avatar avatar-lg">      <img class="avatar-image" src="/demo/portrait-1.svg" alt>    </span>    <div class="flex flex-col">      <span class="fg-text-strong">Ada Lovelace</span>      <span class="text-xs fg-text-muted">Owner · 412 deployments</span>    </div>  </div></div>
Hover the name, or move to it with Tab.

Interest invokers ship in Chrome and Edge. Elsewhere the link works as a link and the preview does not appear.

#Accessibility

  • The button gets aria-expanded from the browser; do not set it yourself.
  • A popover is not modal: the page stays interactive. For a task that must be finished or dismissed first, use a dialog.
  • For a list of actions use a menu, and for a label on hover a tooltip.

#React, Svelte and Vue

The popover component opens and closes the popover from the application's state and says when the browser did. The trigger and the content stay the page's own markup. Copy it into the project with npx master-ui add popover; React, Svelte and Vue says how the components are built and used.

ui/popover.tsx
'use client'import { useEffect, useId, useReducer, useRef, type ComponentProps, type ReactNode } from 'react'export interface PopoverProps extends Omit<ComponentProps<'div'>, 'popover'> {  /** Whether the popover is open, when this component is told. Leave it out and the trigger opens it. */  open?: boolean  onOpenChange?: (open: boolean) => void  /** The button that opens the popover: spread what it is given on a button of your own. */  trigger?: (attributes: { popoverTarget: string }) => ReactNode  /** `auto` closes on a press outside and on Esc; `manual` stays until it is closed. */  mode?: 'auto' | 'manual'}/** * A popover. The browser opens it from the trigger, places it against the trigger and dismisses it; this * component only opens and closes it from `open`, and says when the browser did. */export function Popover({ open, onOpenChange, trigger, mode = 'auto', className, id, children, ...root }: PopoverProps) {  const generated = useId()  const name = id ?? generated  const element = useRef<HTMLDivElement>(null)  const heard = useRef(onOpenChange)  /** What the owner knows: the last state it set or was told. */  const known = useRef(false)  const [tick, again] = useReducer((count: number) => count + 1, 0)  useEffect(() => { heard.current = onOpenChange })  useEffect(() => {    const popover = element.current    if (!popover || open === undefined) return    const shown = popover.matches(':popover-open')    known.current = open    if (open && !shown) popover.showPopover()    else if (!open && shown) popover.hidePopover()  }, [open, tick])  useEffect(() => {    const popover = element.current    if (!popover) return    const report = () => {      const shown = popover.matches(':popover-open')      if (shown !== known.current) heard.current?.(known.current = shown)      // An `open` this component is told wins: if its owner did not take the change, the popover goes back.      again()    }    popover.addEventListener('toggle', report)    return () => popover.removeEventListener('toggle', report)  }, [])  return (    <>      {trigger?.({ popoverTarget: name })}      <div id={name} popover={mode} className={className ? `popover ${className}` : 'popover'} ref={element} {...root}>{children}</div>    </>  )}
ui/Popover.svelte
<script lang="ts" module>  import type { Snippet } from 'svelte'  import type { HTMLAttributes } from 'svelte/elements'  export interface PopoverProps extends Omit<HTMLAttributes<HTMLDivElement>, 'popover'> {    /** Whether the popover is open. Bind it, or leave it out and the trigger opens it. */    open?: boolean    onOpenChange?: (open: boolean) => void    /** The button that opens the popover: spread what it is given on a button of your own. */    trigger?: Snippet<[{ popovertarget: string }]>    /** `auto` closes on a press outside and on Esc; `manual` stays until it is closed. */    mode?: 'auto' | 'manual'  }</script><script lang="ts">  import type { Attachment } from 'svelte/attachments'  let { open = $bindable(), onOpenChange, trigger, mode = 'auto', class: className, id, children, ...root }: PopoverProps = $props()  const generated = $props.id()  const name = $derived(id ?? generated)  /** What the owner knows: the last state it set or was told. */  let known = false  const presence: Attachment<HTMLDivElement> = (popover) => {    const report = () => {      const shown = popover.matches(':popover-open')      if (shown === known) return      open = known = shown      onOpenChange?.(known)    }    popover.addEventListener('toggle', report)    $effect(() => {      if (open === undefined) return      const shown = popover.matches(':popover-open')      known = open      if (open && !shown) popover.showPopover()      else if (!open && shown) popover.hidePopover()    })    return () => popover.removeEventListener('toggle', report)  }</script><!--  @component  A popover. The browser opens it from the trigger, places it against the trigger and dismisses it; this  component only opens and closes it from `open`, and says when the browser did.-->{@render trigger?.({ popovertarget: name })}<div id={name} popover={mode} class={['popover', className]} {@attach presence} {...root}>{@render children?.()}</div>
ui/Popover.vue
<script setup lang="ts">import { computed, nextTick, onMounted, useId, useTemplateRef, watch } from 'vue'defineOptions({ inheritAttrs: false })const { id, mode = 'auto' } = defineProps<{  id?: string  /** `auto` closes on a press outside and on Esc; `manual` stays until it is closed. */  mode?: 'auto' | 'manual'}>()/** Whether the popover is open: `v-model:open`. Leave it out and the trigger opens it. */const open = defineModel<boolean>('open')defineSlots<{  /** The button that opens the popover: bind what it is given on a button of your own. */  trigger?: (attributes: { popovertarget: string }) => unknown  default?: () => unknown}>()/* * A popover. The browser opens it from the trigger, places it against the trigger and dismisses it; this * component only opens and closes it from `open`, and says when the browser did. */const generated = useId()const name = computed(() => id ?? generated)const root = useTemplateRef<HTMLDivElement>('root')/** What the owner knows: the last state it set or was told. */let known = falseconst sync = () => {  const popover = root.value  if (!popover || open.value === undefined) return  const shown = popover.matches(':popover-open')  known = open.value  if (open.value && !shown) popover.showPopover()  else if (!open.value && shown) popover.hidePopover()}const report = () => {  const popover = root.value  if (!popover) return  const shown = popover.matches(':popover-open')  if (shown !== known) open.value = known = shown  // An `open` this component is told wins: if its owner did not take the change, the popover goes back.  void nextTick(sync)}onMounted(sync)watch(open, sync, { flush: 'post' })</script><template>  <slot name="trigger" v-bind="{ popovertarget: name }" />  <div :id="name" :popover="mode" class="popover" ref="root" v-bind="$attrs" @toggle="report"><slot /></div></template>

#Classes

ClassDescription
popoverThe floating sheet, on an element with the popover attribute; without the attribute it is never shown. It opens below its button, lined up with the start edge, at a distance of --anchored-gap (6px), and flips at the viewport edge
popover-endLines up with the button's end edge, for a button at the end of a row. With popover
popover-centerCenters on the button. With popover
popover-topOpens above the button; it combines with popover-end or popover-center. With popover

#Custom properties

PropertyDefaultDescription
--popover-padding1remPadding inside the sheet
--popover-max-width22remWidest the sheet grows before its text wraps
--anchored-gapSpace between the surface and the control that opened it; 6px when not set. On popover

#Source

@master/ui/components/popover.css
/* * Popover: a floating sheet opened by a button with popovertarget (or commandfor and command="toggle-popover"). * The popover attribute supplies the top layer, light dismiss and Escape; CSS anchor positioning places the * sheet against its button and flips it at the viewport edge. Geometry: a 12px corner, the same as a menu's, * and 16px of padding. */@layer components {  .popover {    --popover-padding: 1rem;    --popover-max-width: 22rem;    --surface-color: var(--color-surface-floating);    width: max-content;    max-width: min(var(--popover-max-width), calc(100vw - 1rem));    padding: var(--popover-padding);    border: 0;    border-radius: calc(var(--radius-control-sm) * var(--corner-scale) + 4px);    corner-shape: var(--corner-shape);    overflow: auto;    background: var(--color-surface-floating);    color: var(--color-text-body);    box-shadow: var(--shadow-floating);    font-size: .875rem;    line-height: 1.5;    transform-origin: top;    @apply --anchored;    @apply --pop;    scrollbar-width: thin;    scrollbar-color: var(--color-line-strong) transparent;    overflow-wrap: anywhere;    overscroll-behavior: contain;    /* Line up with the button's end edge instead of its start. */    &.popover-end {      --anchored-inline: span-inline-start;    }    &.popover-center {      --anchored-inline: span-all;    }    /* Open above the button. */    &.popover-top {      --anchored-block: block-start;      transform-origin: bottom;    }    @media (forced-colors: active) {      outline: 1px solid CanvasText;      outline-offset: -1px;    }  }}