useDismissable
A headless dismiss layer — the "close the overlay on an outside interaction" glue a non-platform
overlay needs, the pattern Radix DismissableLayer / Floating-UI useDismiss standardise. While
enabled, it attaches document listeners and calls onDismiss() when an interaction lands outside
the overlay's own elements. Each overlay picks the strategy that fits it:
- a menu (no single focus anchor) closes on pointerdown outside (
pointerDownOutside); - a combobox (focus lives on its input) closes on focus-out (
focusOutside).
OriMenu and OriCombobox use this internally. (An OriPopover / OriDialog get dismiss for free from
the native [popover] / <dialog> top-layer, and Escape lives in the core connects — so they don't need
it.) The pure isTargetOutside(target, elements) predicate it's built on is exported from
@oriui/headless.
Import
import { useDismissable } from '@oriui/headless/vue';
Options
useDismissable(() => ({ … })) takes its options as a getter (Svelte: a plain object or a store) so
enabled / elements stay reactive.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | — | Attach the listeners only while this is true — typically the overlay's open. |
elements | () => (HTMLElement | null | undefined)[] | — | The overlay's own elements (content + trigger). An interaction inside ANY of them does NOT dismiss. |
onDismiss | () => void | — | Called to dismiss — typically () => setOpen(false). |
pointerDownOutside | boolean | false | Close on a pointerdown outside elements. |
focusOutside | boolean | false | Close when focus lands outside elements. |
It returns nothing — it just manages the listeners (removed when enabled flips false or the scope
disposes). Attach happens after the render flush, so the interaction that opened the overlay can't
immediately dismiss it.
Usage
Pass the overlay's open, its element refs, and a close callback — pick the strategy for your widget:
<!-- MyMenu.vue — pointerdown-outside (a menu has no single focus anchor) -->
<script setup lang="ts">
import { ref } from 'vue';
import { useDismissable } from '@oriui/headless/vue';
const open = ref(false);
const content = ref<HTMLElement>();
const trigger = ref<HTMLElement>();
useDismissable(() => ({
enabled: open.value,
elements: () => [content.value, trigger.value],
onDismiss: () => (open.value = false),
pointerDownOutside: true
}));
</script>
The Svelte binding is the same — options are a plain object or a store, and it wires the identical
listeners over the shared isTargetOutside:
<script>
import { writable, derived } from 'svelte/store';
import { useDismissable } from '@oriui/headless/svelte';
export let controlEl; // bound to the combobox control
const open = writable(false);
// focus-out for a combobox (focus lives on its input)
useDismissable(derived(open, ($open) => ({
enabled: $open,
elements: () => [controlEl],
onDismiss: () => open.set(false),
focusOutside: true
})));
</script>
Accessibility
- Dismiss is one half of an overlay's a11y contract; the other keys live elsewhere — Escape is handled
by the Menu / Combobox core connects, and roving focus by
their own machines.
useDismissableonly adds the outside-interaction close. pointerDownOutsidefires onpointerdown(beforeclick), so the overlay closes as the outside press begins — matching native menus.focusOutsidecloses when focus genuinely leaves the widget (Tab-away or a click that moves focus out), so a screen-reader user Tabbing past a combobox closes its list.
See also
- @oriui/headless — the
isTargetOutsidepredicate the composables share. - useMenu · useCombobox — the overlays that consume it.
- Popover · Dialog — the platform overlays that dismiss without it.