Popover
A panel that opens next to a button and holds more than a tooltip can: text, a form, a few actions. It closes with Escape or a click outside, and the button always says whether it is open.
The preview needs JavaScript. The code is in the Code tab.
<template>
<div>
<Popover>
<span slot="trigger">Share</span>
<strong>Share this page</strong>
<p>Anyone with the link can read it.</p>
</Popover>
<Popover placement="right">
<span slot="trigger">Filters</span>
<p>Show only items from this week.</p>
</Popover>
</div>
</template>
Installation
npx @pixelscriptio/tessera add Popoverpnpm exec tessera add Popoveryarn tessera add Popoverbunx @pixelscriptio/tessera add Popover- Works with Tessera 0.9.0 or newer.
Copy and paste the code into your project
Put it in
system/components.system/components/Popover.tess <contract> { "name": "Popover", "role": "generic", "description": "A panel that opens next to a button and holds more than a tooltip can: text, a form, a few actions. It closes with Escape or a click outside, and the button always says whether it is open.", "since": "0.8.0", "parts": ["root", "trigger", "panel"], "variants": { "placement": ["bottom", "top", "right", "left"] // first value is the default }, "states": ["hover", "focus-visible", "open"], "slots": { "trigger": "the text of the button that opens it", "default": "the content of the panel" }, "events": { "change": "fired with { open } when the panel opens or closes; the open prop sets it from outside" }, "preview": { "slots": { "trigger": "Share", "default": [ { "tag": "strong", "text": "Share this page" }, { "tag": "p", "text": "Anyone with the link can read it." } ] } } } </contract> <script> const { open, label } = props({ open: false, label: '' }) // The component owns whether it is open, starts from the prop and follows it when it changes. const isOpen = state(open.value) effect(() => { isOpen.value = open.value }) const uid = `t-popover-${Math.random().toString(36).slice(2, 9)}` const panel = state(null) const trigger = state(null) // Browsers that cannot place a panel next to its button by themselves are placed here, under the button. const anchored = typeof CSS !== 'undefined' && CSS.supports('position-area', 'bottom') function place() { const el = panel.value const button = trigger.value if (anchored || !el || !button) return const box = button.getBoundingClientRect() el.style.top = box.bottom + 8 + 'px' el.style.left = box.left + 'px' } // The native popover is the source of what is shown: this makes it match the component's state... effect(() => { const el = panel.value const want = isOpen.value if (!el || !el.showPopover) return const shown = el.matches(':popover-open') if (want && !shown) { el.showPopover() place() } else if (!want && shown) el.hidePopover() }) // ...and this makes the state follow the browser, which opens and closes it too: the button, Escape, a click outside. let listening = null effect(() => { const el = panel.value if (!el || listening === el) return listening = el el.addEventListener('toggle', (event) => { const now = event.newState === 'open' if (now === isOpen.value) return isOpen.value = now if (now) place() emit('change', { open: now }) }) }) </script> <template> <span class="root" :data-open="isOpen ? 'true' : null"> <button class="trigger" type="button" ref="trigger" :id="uid + '-trigger'" :popovertarget="uid + '-panel'" aria-haspopup="dialog" :aria-expanded="isOpen ? 'true' : 'false'" :aria-controls="uid + '-panel'" :style="'anchor-name: --' + uid" > <slot name="trigger" /> </button> <div class="panel" popover="auto" ref="panel" role="dialog" :id="uid + '-panel'" :aria-label="label || null" :aria-labelledby="label ? null : uid + '-trigger'" :style="'position-anchor: --' + uid" > <slot /> </div> </span> </template> <style> .root { display: inline-block; } .trigger { display: inline-flex; align-items: center; gap: $space.2; padding: $space.2 $space.4; border: 1px solid $color.border.strong; border-radius: $radius.md; background: transparent; color: $color.text.default; font: inherit; font-size: $font.size.sm; cursor: pointer; transition-duration: $motion.duration.fast; &:hover { background: $color.surface.raised; } &:focus-visible { outline: 2px solid $color.focus.ring; outline-offset: 2px; } } .panel { position: fixed; inset: auto; margin: 0; min-width: 12rem; max-width: min(24rem, calc(100vw - 2rem)); padding: $space.4; border: 1px solid $color.border.default; border-radius: $radius.md; background: $color.surface.default; color: $color.text.default; box-shadow: $shadow.md; font-family: var(--t-font-family-sans); font-size: $font.size.sm; /* Under the button, starting at its left edge, flipping to the other side when there is no room. */ position-area: bottom span-right; position-try-fallbacks: flip-block, flip-inline; } .root[data-placement='top'] .panel { position-area: top span-right; } .root[data-placement='right'] .panel { position-area: right span-bottom; } .root[data-placement='left'] .panel { position-area: left span-bottom; } .panel:not(:popover-open) { display: none; } .panel > * + * { margin-top: $space.2; } </style>Check your design tokens
Your design system needs these.
tessera addreports the ones that are missing; by hand, runnpx @pixelscriptio/tessera check.color.border.defaultcolor.border.strongcolor.focus.ringcolor.surface.defaultcolor.surface.raisedcolor.text.defaultfont.size.smmotion.duration.fastradius.mdshadow.mdspace.2space.4
Usage
Using Popover
A panel that opens next to a button and holds more than a tooltip can: text, a short form, a few actions.
- The
triggerslot is the text of the button. The button says whether the panel is open, and opening it is a click, Enter or Space. - The panel is in the browser's top layer, so nothing clips it and nothing covers it. Escape and a click outside close it, and focus goes back to the button.
- It is placed next to the button by the browser, and moves to the other side when there is no room.
placementpicks the side it prefers:bottom,top,rightorleft. - The panel has a name: the button's, or the
labelprop if you give one. Uselabelwhen the button's text does not name the panel well. changefires with{ open }when it opens or closes, from any cause. Theopenprop opens or closes it from outside.- A popover is not a dialog: the page behind it stays usable. For something the person must answer first, use a Dialog. For a one-line hint, use a Tooltip.
The preview needs JavaScript. The code is in the Code tab.
<template>
<div>
<Popover>
<span slot="trigger">Share</span>
<strong>Share this page</strong>
<p>Anyone with the link can read it.</p>
</Popover>
<Popover placement="right">
<span slot="trigger">Filters</span>
<p>Show only items from this week.</p>
</Popover>
</div>
</template>
API reference
Variants
| Variant | Values |
|---|---|
placement | bottom default top right left |
Slots
| Slot | Content |
|---|---|
trigger | the text of the button that opens it |
default | the content of the panel |
Events
| Event | When |
|---|---|
change | fired with { open } when the panel opens or closes; the open prop sets it from outside |
Anatomy
| Role | generic |
| Parts | root trigger panel |
| States | hover focus-visible open |
| Since | 0.8.0 |
Design tokens it uses
Your design system needs these. tessera add checks that it has them, and can add the ones it lacks.
color.border.default color.border.strong color.focus.ring color.surface.default color.surface.raised color.text.default font.size.sm motion.duration.fast radius.md shadow.md space.2 space.4
Checksum
bf94b2ff6edf5b97adfae11004f39229e14d329053fd5ea770696f5d5deec543