# Popover

Floating content positioned by @floating-ui. Click or hover triggers, Title/Content subparts, inert outside content, click-outside + Escape dismiss, optional portal.

- Package: `@mielui/svelte`
- Component version: `3.3.0`
- Depends on Mielui components: _internal/utils, button
- Shared utilities: components/_internal/button-attributes, components/_internal/surface, hugeicons-icon, utils.cn, utils.createContext, utils.inertOutside, utils.isPointInSubmenuTriangle, utils.lockBodyScroll, utils.positionFloatingPanel, utils.submenuPanelOffset, transition

## Install

~~~~sh
pnpm dlx @mielui/svelte add popover
~~~~

## API

This reference is generated at build time from the component manifest, public `index.ts`, and documentation examples below. Changes to those source files are reflected here in the published Markdown. Standard Svelte and HTML attributes accepted by the exported prop types are supported.


### Content

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| surface | "solid" \| "glass" \| undefined | - | No | No | Surface treatment. Omit it to inherit `--mielui-surface` from the theme. |
| children | Snippet<[]> | - | Yes | No | Content rendered inside. |
| class | (string & (ClassValue \| null)) \| undefined | - | No | No | Classes added to the element. |
| surfaceClass | string \| undefined | - | No | No | Classes for the inset surface (where children live) — padding, layout, background overrides. The `class` prop styles the outer Panel frame. |
| allowClickOutside | boolean \| undefined | true | No | No | Closes when the pointer is pressed outside it. Set false to keep it open until it is dismissed another way. |
| dismissLayer | boolean \| undefined | true | No | No | Render the full-viewport dismiss layer under the panel while open. Defaults to `true`. Set to `false` for triggers that must stay clickable while open (e.g. an input-style combobox trigger); outside pointer dismissal still applies via `allowClickOutside`. |
| portal | boolean \| undefined | true | No | No | Moves the panel to the end of the document so ancestors cannot clip it. Set false to render it in place. |
| refElement | VirtualElement \| undefined | - | No | No | Positions the panel against this element or virtual element instead of the trigger. The panel then opens to the right of it, as a submenu does. |
| role | "dialog" \| "alertdialog" \| "menu" \| "listbox" \| "none" \| undefined | 'dialog' | No | No | ARIA role of the panel. |
| tabindex | number \| undefined | -1 | No | No | Tab index of the panel. |
| focusTrap | boolean \| undefined | true | No | No | Trap Tab focus inside the panel while open. Defaults to `true`. |
| lockScroll | boolean \| undefined | true | No | No | Lock document scrolling while the panel is open. Defaults to `true`. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Root

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |
| open | boolean \| undefined | false | No | Yes | Whether it is open. Bind it to control the state from outside. |
| onOpenChange | ((open: boolean) => void) \| undefined | - | No | No | Called with the new state whenever it opens or closes. |
| placement | Placement \| undefined | 'bottom' | No | No | Preferred side and alignment relative to the trigger, such as `bottom-start`. It flips when there is no room. |
| hoverable | boolean \| undefined | - | No | No | Opens on hover as well as on click. |
| delay | number \| undefined | 0 | No | No | Milliseconds the pointer rests on the trigger before it opens. |
| closeDelay | number \| undefined | 150 | No | No | Milliseconds after the pointer leaves before it closes. |
| inert | boolean \| undefined | true | No | No | Make document content outside an open non-hover popover inert. Defaults to `true`. |



### Title

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| class | string \| undefined | - | No | No | Classes added to the element. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |



### Trigger

| Prop | Type | Default | Required | Bindable | Description |
| --- | --- | --- | --- | --- | --- |
| icon | boolean \| undefined | false | No | No | Shows the chevron after the label. |
| variant | ButtonVariant \| undefined | - | No | No | Button style of the trigger. |
| size | "sm" \| "md" \| "lg" \| "icon" \| undefined | - | No | No | Control height of the trigger. |
| children | Snippet<[]> \| undefined | - | No | No | Content rendered inside. |
| class | string \| undefined | - | No | No | Classes added to the element. |
| element | HTMLButtonElement \| HTMLAnchorElement \| undefined | - | No | Yes | Bindable reference to the DOM element. |
| unstyled | boolean \| undefined | - | No | No | Forwarded to Button: render with `class` alone, skipping variant/size. |
| onclick | ((event: MouseEvent) => void) \| undefined | - | No | No | Called when it is clicked or activated. |
| onopen | (() => void) \| undefined | - | No | No | Called immediately before this trigger opens its popover. |
| style | string \| undefined | - | No | No | Inline styles for the element. |

Also accepts the native HTML attributes and events listed in the rendered API reference.

### Public types

~~~~ts
import type { VirtualElement } from '@floating-ui/dom';
import type { ButtonVariant } from '@mielui/svelte/components/button';
import type { DefaultProps } from '@mielui/svelte/utils';
import type { Snippet } from 'svelte';
import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements';
import Root from './popover.svelte';
import Content from './popover-content.svelte';
import Title from './popover-title.svelte';
import Trigger from './popover-trigger.svelte';

export type PopoverContentProps = {
    /** Surface treatment. Omit it to inherit `--mielui-surface` from the theme. */
    surface?: 'solid' | 'glass';
    /** Content rendered inside. */
    children: Snippet;
    /** Classes added to the element. */
    class?: string;
    /** Classes for the inset surface (where children live) — padding, layout,
     * background overrides. The `class` prop styles the outer Panel frame. */
    surfaceClass?: string;
    /**
     * Closes when the pointer is pressed outside it. Set false to keep it open until it is
     * dismissed another way.
     */
    allowClickOutside?: boolean;
    /**
     * Render the full-viewport dismiss layer under the panel while open.
     * Defaults to `true`. Set to `false` for triggers that must stay
     * clickable while open (e.g. an input-style combobox trigger);
     * outside pointer dismissal still applies via `allowClickOutside`.
     */
    dismissLayer?: boolean;
    /**
     * Moves the panel to the end of the document so ancestors cannot clip it. Set false to render
     * it in place.
     */
    portal?: boolean;
    /**
     * Positions the panel against this element or virtual element instead of the trigger. The
     * panel then opens to the right of it, as a submenu does.
     */
    refElement?: VirtualElement;
    /** ARIA role of the panel. */
    role?: 'dialog' | 'alertdialog' | 'menu' | 'listbox' | 'none';
    /** Tab index of the panel. */
    tabindex?: number;
    /** Trap Tab focus inside the panel while open. Defaults to `true`. */
    focusTrap?: boolean;
    /** Lock document scrolling while the panel is open. Defaults to `true`. */
    lockScroll?: boolean;
} & DefaultProps &
    Partial<HTMLAttributes<HTMLElement>>;

export type PopoverProps = {
    /** Content rendered inside. */
    children?: Snippet;
    /** Whether it is open. Bind it to control the state from outside. */
    open?: boolean;
    /** Called with the new state whenever it opens or closes. */
    onOpenChange?: (open: boolean) => void;
    /**
     * Preferred side and alignment relative to the trigger, such as `bottom-start`. It flips when
     * there is no room.
     */
    placement?: Placement;
    /** Opens on hover as well as on click. */
    hoverable?: boolean;
    /** Milliseconds the pointer rests on the trigger before it opens. */
    delay?: number;
    /** Milliseconds after the pointer leaves before it closes. */
    closeDelay?: number;
    /** Make document content outside an open non-hover popover inert. Defaults to `true`. */
    inert?: boolean;
};

export type PopoverTriggerProps = {
    /** Shows the chevron after the label. */
    icon?: boolean;
    /** Button style of the trigger. */
    variant?: ButtonVariant;
    /** Control height of the trigger. */
    size?: 'sm' | 'md' | 'lg' | 'icon';
    /** Content rendered inside. */
    children?: Snippet;
    /** Classes added to the element. */
    class?: string;
    /** Bindable reference to the DOM element. */
    element?: HTMLButtonElement | HTMLAnchorElement | undefined;
    /** Forwarded to Button: render with `class` alone, skipping variant/size. */
    unstyled?: boolean;
    /** Called when it is clicked or activated. */
    onclick?: (event: MouseEvent) => void;
    /** Called immediately before this trigger opens its popover. */
    onopen?: () => void;
    /** Inline styles for the element. */
    style?: string;
} & Pick<
    HTMLButtonAttributes,
    | 'disabled'
    | 'type'
    | 'name'
    | 'value'
    | 'id'
    | 'role'
    | 'tabindex'
    | 'aria-label'
    | 'aria-controls'
    | 'aria-expanded'
    | 'aria-haspopup'
> &
    Partial<Record<`data-${string}`, string | boolean | null>>;
export type PopoverTitleProps = DefaultProps;

/** Mirrors floating-ui's placements: a side, optionally aligned to a corner. */
export type Placement =
    | 'top'
    | 'top-start'
    | 'top-end'
    | 'bottom'
    | 'bottom-start'
    | 'bottom-end'
    | 'left'
    | 'left-start'
    | 'left-end'
    | 'right'
    | 'right-start'
    | 'right-end';

export type PopoverState = {
    open: boolean;
    buttonRef: HTMLElement | null;
    popoverRef: HTMLElement | undefined;
    placement: Placement;
    closeTimeout: ReturnType<typeof setTimeout> | undefined;
    hoverTimeout?: ReturnType<typeof setTimeout> | undefined;
    hoverable: boolean;
    delay: number | undefined;
    closeDelay: number | undefined;
    inert: boolean;
};

export { Content, Root, Title, Trigger };
~~~~

## Examples

### Basic

~~~~svelte
<script lang="ts">
    import * as Popover from '@mielui/svelte/components/popover';
</script>

<Popover.Root>
    <Popover.Trigger>Open popover</Popover.Trigger>
    <Popover.Content class="w-64" aria-label="Details">
        <p class="m-0 text-sm text-foreground-muted">Popover content goes here.</p>
    </Popover.Content>
</Popover.Root>
~~~~

### Glass

~~~~svelte
<script lang="ts">
    import * as Popover from '@mielui/svelte/components/popover';
</script>

<Popover.Root>
    <Popover.Trigger variant="outline">Release notes</Popover.Trigger>
    <Popover.Content surface="glass" class="w-64" surfaceClass="flex flex-col gap-1">
        <Popover.Title>Version 0.4</Popover.Title>
        <p class="m-0 text-sm text-foreground-muted">Glass panels blur what sits behind them.</p>
    </Popover.Content>
</Popover.Root>
~~~~

### Hero

~~~~svelte
<script lang="ts">
    import { Button } from '@mielui/svelte/components/button';
    import { Input } from '@mielui/svelte/components/input';
    import * as Popover from '@mielui/svelte/components/popover';

    let email = $state('');
    let members = $state(['alex@example.com']);
    let message = $state('');

    function invite(event: SubmitEvent) {
        event.preventDefault();
        const address = email.trim().toLowerCase();
        if (members.includes(address)) {
            message = 'This person already has access.';
            return;
        }
        members = [...members, address];
        email = '';
        message = `Added ${address} to this preview. No invitation was sent.`;
    }

    async function copyLink() {
        try {
            await navigator.clipboard.writeText(
                `${window.location.origin}/docs/components/popover`
            );
            message = 'Documentation link copied.';
        } catch {
            message = 'Clipboard access is unavailable. Copy the address from your browser.';
        }
    }
</script>

<Popover.Root placement="bottom">
    <Popover.Trigger variant="outline">Share project</Popover.Trigger>
    <Popover.Content class="w-80 max-w-[calc(100vw-var(--spacing)*8)]">
        <div class="flex flex-col gap-3">
            <div class="space-y-1">
                <Popover.Title class="text-sm font-medium">Share project</Popover.Title>
                <p class="text-xs text-foreground-muted">Try inviting a teammate in this demo.</p>
            </div>
            <form onsubmit={invite} class="flex min-w-0 items-center gap-2">
                <Input
                    aria-label="Email address"
                    type="email"
                    required
                    bind:value={email}
                    placeholder="Email address"
                    class="min-w-0 flex-1"
                />
                <Button type="submit" class="shrink-0">Invite</Button>
            </form>
            <ul
                class="flex max-h-40 flex-col gap-2 overflow-y-auto"
                aria-label="People with access"
            >
                {#each members as member (member)}
                    <li class="flex min-w-0 items-center gap-2">
                        <span
                            class="flex size-7 shrink-0 items-center justify-center rounded-full bg-secondary text-xs font-medium"
                            aria-hidden="true"
                        >
                            {member.slice(0, 1).toUpperCase()}
                        </span>
                        <span class="min-w-0 flex-1 truncate text-xs">{member}</span>
                        <span class="shrink-0 text-xs text-foreground-muted">
                            {member === members[0] ? 'Owner' : 'Member'}
                        </span>
                    </li>
                {/each}
            </ul>
            <div class="flex items-center justify-between gap-2">
                <span class="text-xs text-foreground-muted">Documentation</span>
                <Button variant="ghost" size="sm" onclick={copyLink}>Copy link</Button>
            </div>
            <p role="status" class="text-xs text-foreground-muted empty:hidden">{message}</p>
        </div>
    </Popover.Content>
</Popover.Root>
~~~~

### Hover

~~~~svelte
<script lang="ts">
    import * as Popover from '@mielui/svelte/components/popover';
</script>

<Popover.Root hoverable delay={200} closeDelay={300} placement="top">
    <Popover.Trigger variant="outline">Hover for the build status</Popover.Trigger>
    <Popover.Content class="w-64">
        <Popover.Title>Build 2418</Popover.Title>
        <p class="text-sm text-foreground-muted">
            Passed 4 minutes ago. It opens after 200 ms and lingers for 300.
        </p>
    </Popover.Content>
</Popover.Root>
~~~~

### Placements

~~~~svelte
<script lang="ts">
    import * as Popover from '@mielui/svelte/components/popover';

    const placements = ['top', 'right', 'bottom', 'left'] as const;
</script>

<div class="flex flex-wrap justify-center gap-4">
    {#each placements as placement (placement)}
        <Popover.Root {placement}>
            <Popover.Trigger variant="outline">{placement}</Popover.Trigger>
            <Popover.Content class="w-48" aria-label={`${placement} placement`}>
                <p class="m-0 text-sm text-foreground-muted">Anchored to the {placement}</p>
            </Popover.Content>
        </Popover.Root>
    {/each}
</div>
~~~~

For the rendered reference, visit [/docs/components/popover](/docs/components/popover).
