Keyboard shortcut: Ctrl + K
Get started

Popover

The plumbing every floating panel in the library is built on: the native popover element, its anchoring and its open state. It carries no role, no keyboard and no dismissal policy of its own, which stay with whatever uses it.

Usage

vue

Anyone with the link can open this file.

<script setup lang="ts">
import { VButton, VPopover, VTypography } from 'vectis-ui'
</script>

<template>
  <VPopover>
    <template #trigger="{ triggerProps }">
      <VButton variant="outline" tone="neutral" v-bind="triggerProps">Details</VButton>
    </template>
    <VTypography>Anyone with the link can open this file.</VTypography>
  </VPopover>
</template>

Examples

Placements

placement offers twelve positions relative to the trigger, on either axis and aligned to either edge. It names a preference: the browser flips the panel to the opposite side when there is not enough room.

vue

Placed top-start

Placed top

Placed top-end

Placed bottom-start

Placed bottom

Placed bottom-end

Placed left-start

Placed left

Placed left-end

Placed right-start

Placed right

Placed right-end

<script setup lang="ts">
import { VButton, VPopover, VTypography, type PopoverPlacement } from 'vectis-ui'

const PLACEMENTS: PopoverPlacement[] = [
  'top-start',
  'top',
  'top-end',
  'bottom-start',
  'bottom',
  'bottom-end',
  'left-start',
  'left',
  'left-end',
  'right-start',
  'right',
  'right-end',
]
</script>

<template>
  <div class="grid">
    <!-- A preference rather than a position: a browser short of room on that side flips
         the panel to the opposite one by itself. -->
    <VPopover v-for="placement in PLACEMENTS" :key="placement" :placement="placement">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral" size="sm">
          {{ placement }}
        </VButton>
      </template>
      <VTypography variant="body-sm">Placed {{ placement }}</VTypography>
    </VPopover>
  </div>
</template>

<style scoped>
.grid {
  display: grid;
  grid-template-columns: repeat(3, max-content);
  justify-content: center;
  gap: var(--vectis-space-4);
  /* Room on every side, so nothing is flipped for want of space in the demo. */
  padding: var(--vectis-space-10) var(--vectis-space-12);
}
</style>

Interactive content

A panel can hold real controls: light dismiss only fires on a click outside, and the focus is not trapped. The component provides no role, no keyboard and no dismissal policy.

vue
Show

Showing: Active

<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VCheckbox, VPopover, VTypography } from 'vectis-ui'

const open = ref(false)

const filters = ref({ active: true, archived: false, mine: false })
const applied = ref('Active')

function apply() {
  const chosen = Object.entries(filters.value)
    .filter(([, on]) => on)
    .map(([key]) => key)
  applied.value = chosen.length ? chosen.join(', ') : 'none'
  open.value = false
}
</script>

<template>
  <div class="column">
    <!-- A panel holds real controls: light dismiss only fires on a click OUTSIDE, so
         everything inside keeps working, and the focus is not trapped, a popover being
         no dialog. -->
    <VPopover v-model:open="open" placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">Filters</VButton>
      </template>

      <div class="panel">
        <VTypography variant="overline" tone="muted">Show</VTypography>
        <VCheckbox v-model="filters.active" label="Active" />
        <VCheckbox v-model="filters.archived" label="Archived" />
        <VCheckbox v-model="filters.mine" label="Mine only" />
        <div class="actions">
          <VButton variant="ghost" tone="neutral" size="sm" @click="open = false">Cancel</VButton>
          <VButton size="sm" @click="apply">Apply</VButton>
        </div>
      </div>
    </VPopover>

    <VTypography variant="body-sm" tone="muted">Showing: {{ applied }}</VTypography>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-3);
}
.panel {
  display: grid;
  gap: var(--vectis-space-2);
  min-inline-size: 14rem;
}
.actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--vectis-space-2);
  margin-block-start: var(--vectis-space-2);
}
</style>

Modes

mode set to auto hands the dismissal to the browser, where manual hands it back to you and means the panel has to offer a way out. v-model:open is fed from the DOM, and the exposed show and close are the route when the opening has to be synchronous.

vue

Click outside or press Escape: the browser closes this one.

Clicking outside leaves this open. Escape does nothing either.

Opened synchronously, with no tick in between.

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { VButton, VPopover, VTypography } from 'vectis-ui'

const manualOpen = ref(false)
const syncPanel = useTemplateRef<InstanceType<typeof VPopover>>('syncPanel')
</script>

<template>
  <div class="column">
    <!-- auto: the browser dismisses it on a click outside or on Escape, and stacks it
         with the other panels on the page. The model is written back from the DOM, so
         nothing has to be reset by hand. -->
    <VPopover>
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">auto</VButton>
      </template>
      <VTypography variant="body-sm">
        Click outside or press Escape: the browser closes this one.
      </VTypography>
    </VPopover>

    <!-- manual: nothing dismisses it but you. This is what a panel with rules of its
         own needs, and it means the panel must offer a way out. -->
    <VPopover v-model:open="manualOpen" mode="manual">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">manual</VButton>
      </template>
      <div class="panel">
        <VTypography variant="body-sm">
          Clicking outside leaves this open. Escape does nothing either.
        </VTypography>
        <VButton size="sm" @click="manualOpen = false">Close</VButton>
      </div>
    </VPopover>

    <!-- The model costs a tick. When the opening has to be synchronous, because a focus
         move or a timer is armed on the assumption the panel is already there, the
         exposed methods are the route. -->
    <div class="row">
      <VPopover ref="syncPanel" mode="manual">
        <template #trigger="{ triggerProps }">
          <VButton v-bind="triggerProps" variant="outline" tone="neutral">
            Opened through the ref
          </VButton>
        </template>
        <div class="panel">
          <VTypography variant="body-sm"
            >Opened synchronously, with no tick in between.</VTypography
          >
          <VButton size="sm" @click="syncPanel?.close()">Close</VButton>
        </div>
      </VPopover>
      <VButton variant="ghost" tone="neutral" @click="syncPanel?.show()">show()</VButton>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-4);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-3);
}
.panel {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-3);
  max-inline-size: 16rem;
}
</style>

Match trigger

matchTrigger stops the panel being narrower than whatever it is anchored to. It is a floor and not a clamp: content needing more room still gets it.

vue

Short.

Short.

The content is wider than the trigger, so the panel grows past it rather than being squeezed into its width.

<script setup lang="ts">
import { VButton, VPopover, VTypography } from 'vectis-ui'
</script>

<template>
  <div class="column">
    <!-- Left alone, the panel is only as wide as its content. -->
    <VPopover placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral" class="wide">
          A wide trigger, a narrow panel
        </VButton>
      </template>
      <VTypography variant="body-sm">Short.</VTypography>
    </VPopover>

    <!-- The panel can no longer be narrower than what it is anchored to. -->
    <VPopover match-trigger placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral" class="wide">
          A wide trigger, matched
        </VButton>
      </template>
      <VTypography variant="body-sm">Short.</VTypography>
    </VPopover>

    <!-- It is a FLOOR and not a clamp: content that needs more room still gets it,
         which is what a list of long labels under a short field wants. -->
    <VPopover match-trigger placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">Narrow</VButton>
      </template>
      <VTypography variant="body-sm">
        The content is wider than the trigger, so the panel grows past it rather than being squeezed
        into its width.
      </VTypography>
    </VPopover>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-4);
}
.wide {
  inline-size: 22rem;
}
</style>

Anchoring to your own element

anchor takes the name of an anchor you have set yourself, VPopover then rendering no wrapper of its own. It is the required route as soon as the trigger is a text input. Put the name on the element the panel should sit under, the field's own box rather than a wrapper that also holds a label, and confine it from an enclosing element.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VPopover, VTypography } from 'vectis-ui'

const CITIES = ['Bordeaux', 'Lyon', 'Marseille', 'Nantes', 'Paris', 'Toulouse']

const query = ref('')
const open = ref(false)

const matches = () =>
  CITIES.filter((city) => city.toLowerCase().startsWith(query.value.toLowerCase()))

function choose(city: string) {
  query.value = city
  open.value = false
}
</script>

<template>
  <!-- The wrapper CONFINES the name to this instance. Without it, a shown popover moves
       to the top layer and is resolved against the whole document, so every panel on the
       page would attach to the last element that named the anchor. -->
  <div class="field-wrapper">
    <!-- A plain input, because that is the case the prop exists for: `popovertarget` is
         not valid on a text field, so the panel cannot be wired to it that way. -->
    <input
      v-model="query"
      class="field"
      type="text"
      placeholder="A city"
      aria-label="A city"
      @focus="open = true"
      @input="open = true"
    />

    <!-- Given a name, VPopover renders no wrapper of its own and positions the panel
         against whatever carries it. -->
    <VPopover v-model:open="open" anchor="--city-anchor" match-trigger mode="manual" bare>
      <ul class="list">
        <li v-for="city in matches()" :key="city">
          <button type="button" class="row" @click="choose(city)">{{ city }}</button>
        </li>
        <li v-if="matches().length === 0" class="empty">
          <VTypography variant="body-sm" tone="muted">No city matches</VTypography>
        </li>
      </ul>
    </VPopover>
  </div>
</template>

<style scoped>
.field-wrapper {
  anchor-scope: --city-anchor;
  inline-size: 16rem;
}
.field {
  anchor-name: --city-anchor;
  inline-size: 100%;
  padding: 0 var(--vectis-space-3);
  block-size: var(--vectis-control-height-md);
  border: 1px solid var(--vectis-color-border);
  border-radius: var(--vectis-radius-interactive);
  background: var(--vectis-color-surface);
  color: var(--vectis-color-text);
  font: inherit;
}
.list {
  display: grid;
  margin: 0;
  padding: var(--vectis-space-1);
  border: 1px solid var(--vectis-color-border);
  border-radius: var(--vectis-radius-overlay);
  background: var(--vectis-color-surface-overlay);
  box-shadow: var(--vectis-shadow-lg);
  list-style: none;
}
.row {
  inline-size: 100%;
  padding: var(--vectis-space-2) var(--vectis-space-3);
  border: none;
  border-radius: var(--vectis-radius-interactive);
  background: none;
  color: inherit;
  font: inherit;
  text-align: start;
  cursor: pointer;
}
.row:hover {
  background: var(--vectis-color-surface-muted);
}
.empty {
  padding: var(--vectis-space-2) var(--vectis-space-3);
}
</style>

API

Props

PropTypeDefault
idstringnone
The id of the panel, which the trigger points at. One is generated when none is given, so this is only needed to tie the panel to something outside the component.
placementPopoverPlacement'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end''bottom-start'
Where the panel is placed relative to its trigger. The browser flips it to the opposite side by itself when there is not enough room.
modePopoverMode'auto' | 'manual''auto'
How the panel closes. auto lets the browser dismiss it on a click outside or on Escape, and stack it with other panels; manual leaves everything to you, which is what a panel with its own focus and dismissal rules needs.
anchorstringnone
The name of an anchor you have set on your own control, written as a CSS dashed identifier such as --tooltip-anchor. Supplying it replaces the internal wrapper, which is the required route as soon as the trigger is a text input, where the browser's own popovertarget attribute is not allowed.
barebooleanfalse
Strips the panel of the design system's surface: no background, no border, no shadow and no rounded corners. It is what a panel whose content brings its own asks for, as VDatePicker does.
matchTriggerbooleanfalse
Stops the panel being narrower than whatever it is anchored to. It is a floor, so a panel with a width of its own still grows past it rather than being clamped to the trigger, which is what a list of long labels under a short field wants.
v-model:openbooleanfalse
Whether the panel is showing. It starts closed and is bidirectional, fed from the DOM: in auto mode the browser's own light dismiss writes back to it. Setting it opens and closes the panel; when the change has to be synchronous, use the exposed show and close instead, which is what VTooltip and the pickers do.

Slots

SlotType
trigger{ triggerProps: PopoverTriggerProps; }
The element that opens the panel. Bind the triggerProps it receives onto a button of your own: that is what wires the two together.
default{}
What the panel contains.

Types

The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.

export type PopoverTriggerProps = {
  popovertarget: string
  'aria-expanded': boolean
  'aria-controls': string
}