# Popover

## Summary

`Popover` is a [compound component](https://vega-ui.dev/docs/glossary/#compound-component) for non-modal floating content anchored to a trigger. `PopoverContent` is a native `popover="auto"` element opened from the trigger through `popovertarget`, so the browser shows it in the top layer, closes it on an outside click or `Escape`, and it is never clipped by `overflow` or covered by a modal dialog. It sits between tooltip-level hinting and full modal overlays.

## Imports

```tsx
import {
  Popover,
  PopoverTrigger,
  PopoverContent,
  type PopoverProps,
} from '@vega-ui/react';
```

## Exported Types

- `PopoverProps`
- `PopoverTriggerProps`
- `PopoverContentProps`

## Minimal Composition

```tsx
import { useId } from 'react';
import {
  Button,
  Heading,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

export default function MinimalComposition() {
  const titleId = useId();

  return (
    <Popover role='dialog'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Storage · 82% used</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={titleId}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', maxWidth: 280 }}>
          <Heading id={titleId} as='h3' size={5} fontWeight={600}>16.4 GB of 20 GB used</Heading>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Videos take 11.2 GB. Old exports are removed after 90 days.</Text>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

## Required Parts

- `Popover`: root state and context provider
- `PopoverTrigger`: trigger surface
- `PopoverContent`: floating content

## Optional Parts

None. For a dimmed backdrop, use `Dialog` or `Sheet` instead.

## Composition Order

1. `Popover`
2. `PopoverTrigger`
3. `PopoverContent`

## Variants

- Trigger-driven or [controlled](https://vega-ui.dev/docs/glossary/#controlled) open state
- Lightweight informational popover versus richer custom content

## Edge Cases

- Use `Dialog` or `Sheet` instead when interaction should be modal or needs a dimmed backdrop.
- Floating content must be tested inside scroll containers and overlays.
- Trigger content should clearly indicate whether the popover is open.
- If the content starts accumulating form sections, confirmations, or large action groups, the correct abstraction is usually no longer a popover.

## Common Mistakes

- Putting critical task flow inside a popover.
- Forgetting to test placement inside clipped or scrolling containers.
- Letting popover content grow into a mini-dialog with too much text or too many actions.

## Examples

### Basic

#### Basic: account menu

`role='dialog'` on the root gives the content `role="dialog"` and the trigger `aria-haspopup="dialog"`, and `aria-label` names the content. Layout styles go on an inner element: a `display` set on `PopoverContent` itself would override the browser's hiding of a closed popover.

```tsx
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Separator,
  Text,
} from '@vega-ui/react';

export default function AnchoredUserMenu() {
  return (
    <Popover role='dialog' placement='bottom-end'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Maria Chen</Button>
      </PopoverTrigger>
      <PopoverContent aria-label='Account' style={{ padding: 'var(--spacing-2)' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 220 }}>
          <div style={{ display: 'grid', paddingInline: 'var(--spacing-6)', paddingBlock: 'var(--spacing-4)' }}>
            <Text size={3} fontWeight={600}>Maria Chen</Text>
            <Text size={2} style={{ color: 'var(--label-secondary)' }}>maria@northwind.dev</Text>
          </div>
          <Separator />
          <Button asChild variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }}>
            <a href='#profile'>Profile</a>
          </Button>
          <Button asChild variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }}>
            <a href='#billing'>Billing</a>
          </Button>
          <Separator />
          <Button variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }}>Sign out</Button>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

### Controlled/Stateful

#### Controlled/Stateful: notifications that mark themselves read

`onChangeOpen` reports every open and close, from the trigger, an outside click or `Escape`. Opening clears the unread count, and the trigger's `aria-label` carries the count for screen readers.

```tsx
import { useId, useState } from 'react';
import {
  Badge,
  Heading,
  Icon,
  IconButton,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';
import { BellIcon } from '@vega-ui/icons';

const notifications = [
  { id: 1, text: 'Tom Okafor mentioned you in Pricing table', time: '4 min ago' },
  { id: 2, text: 'Export of Q3 invoices is ready', time: '1 h ago' },
  { id: 3, text: 'Lea Novak shared Homepage hero with you', time: 'Yesterday' },
];

export default function ControlledOpenState() {
  const titleId = useId();
  const [open, setOpen] = useState(false);
  const [unread, setUnread] = useState(notifications.length);

  const changeOpen = (next: boolean) => {
    setOpen(next);
    if (next) setUnread(0);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-4)',
        paddingInlineEnd: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <Popover role='dialog' placement='bottom-start' open={open} onChangeOpen={changeOpen}>
        <PopoverTrigger asChild>
          <IconButton aria-label={unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'} variant='secondary' appearance='ghost'>
            <Icon><BellIcon /></Icon>
          </IconButton>
        </PopoverTrigger>
        <PopoverContent aria-labelledby={titleId}>
          <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: 280 }}>
            <Heading id={titleId} as='h3' size={5} fontWeight={600}>Notifications</Heading>
            {notifications.map((item) => (
              <div key={item.id} style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
                <Text size={3}>{item.text}</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)' }}>{item.time}</Text>
              </div>
            ))}
          </div>
        </PopoverContent>
      </Popover>
      {unread > 0 && <Badge variant='info' size='sm'>{unread} new</Badge>}
      <Text size={2} style={{ marginInlineStart: 'auto', color: 'var(--label-secondary)' }}>Popover is {open ? 'open' : 'closed'}</Text>
    </div>
  );
}
```

#### Controlled/Stateful: menu that closes on select

A popover does not close by itself when a button inside is pressed. Control `open` and close it from the item handler, which also records the chosen action.

```tsx
import { useState } from 'react';
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const actions = ['Duplicate', 'Move to folder', 'Download as PDF', 'Archive'];

export default function MenuClosesOnSelect() {
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState('');

  const run = (action: string) => {
    setLast(action);
    setOpen(false);
  };

  return (
    <div style={{ display: 'grid', justifyItems: 'center', gap: 'var(--spacing-6)', width: '100%' }}>
      <Popover placement='bottom-start' open={open} onChangeOpen={setOpen}>
        <PopoverTrigger asChild>
          <Button variant='secondary' appearance='ghost'>Brand guidelines.fig</Button>
        </PopoverTrigger>
        <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
          <div role='group' aria-label='File actions' style={{ display: 'grid', minWidth: 200 }}>
            {actions.map((action) => (
              <Button
                key={action}
                variant='secondary'
                appearance='transparent'
                style={{ justifyContent: 'flex-start' }}
                onClick={() => run(action)}
              >
                {action}
              </Button>
            ))}
          </div>
        </PopoverContent>
      </Popover>
      <Text size={2} style={{ minHeight: '1lh', color: 'var(--label-secondary)' }}>{last ? `Last action: ${last}` : ''}</Text>
    </div>
  );
}
```

#### Controlled/Stateful: lightweight delete confirmation

Each comment has its own controlled popover with Keep and Delete. `autoFocus` puts focus on the safe answer when the popover opens, and the content is named by its question through `aria-labelledby`.

```tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

const initialComments = [
  { id: 1, author: 'Tom Okafor', text: 'Can we try the darker blue for the header?' },
  { id: 2, author: 'Lea Novak', text: 'Typo in the second paragraph: “recieve”.' },
];

export default function DestructiveConfirmInAPopover() {
  const [comments, setComments] = useState(initialComments);

  return (
    <div
      style={{
        display: 'grid',
        boxSizing: 'border-box',
        width: '100%',
        minHeight: 160,
        alignContent: 'start',
        paddingInline: 'var(--spacing-8)',
        paddingBlock: 'var(--spacing-2)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      {comments.map((comment, index) => (
        <div
          key={comment.id}
          style={{
            display: 'flex',
            alignItems: 'flex-start',
            justifyContent: 'space-between',
            gap: 'var(--spacing-6)',
            paddingBlock: 'var(--spacing-6)',
            borderBlockStart: index > 0 ? '1px solid var(--separator-opaque)' : undefined,
          }}
        >
          <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 0 }}>
            <Text size={3} fontWeight={500}>{comment.author}</Text>
            <Text size={3} style={{ color: 'var(--label-secondary)' }}>{comment.text}</Text>
          </div>
          <DeleteComment onDelete={() => setComments(comments.filter((item) => item.id !== comment.id))} />
        </div>
      ))}
      {comments.length === 0 && <Text size={3} style={{ paddingBlock: 'var(--spacing-6)', color: 'var(--label-secondary)' }}>No comments yet.</Text>}
    </div>
  );
}

function DeleteComment({ onDelete }: { onDelete: () => void }) {
  const textId = useId();
  const [open, setOpen] = useState(false);

  return (
    <Popover role='dialog' placement='bottom-end' open={open} onChangeOpen={setOpen}>
      <PopoverTrigger asChild>
        <Button size='sm' variant='secondary' appearance='ghost' style={{ flexShrink: 0 }}>Delete</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={textId}>
        <div style={{ display: 'grid', gap: 'var(--spacing-8)', maxWidth: 240 }}>
          <Text id={textId} size={3}>Delete this comment? It cannot be restored.</Text>
          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
            <Button size='sm' variant='secondary' appearance='ghost' autoFocus onClick={() => setOpen(false)}>Keep</Button>
            <Button size='sm' style={destructive} onClick={onDelete}>Delete</Button>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

### Form/Integration

#### Form/Integration: inline rename form

The form validates natively (`required`, `pattern`, `maxLength`), reads the new name with `FormData` and closes the popover by setting `open` to `false`. The round close button is a plain `IconButton` that sets `open` to `false`, since there is no close part for popovers.

```tsx
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Button,
  Heading,
  Icon,
  IconButton,
  Label,
  Popover,
  PopoverContent,
  PopoverTrigger,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function InlineRenameFlow() {
  const titleId = useId();
  const fieldId = useId();
  const [open, setOpen] = useState(false);
  const [name, setName] = useState('Q4 launch plan');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setName(String(new FormData(event.currentTarget).get('name')).trim());
    setOpen(false);
  };

  return (
    <Popover role='dialog' placement='bottom-start' open={open} onChangeOpen={setOpen}>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>{name}</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={titleId}>
        <form onSubmit={onSubmit} style={{ display: 'grid', width: 260 }}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h3' size={5} fontWeight={600}>Rename document</Heading>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              onClick={() => setOpen(false)}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </div>
          <div style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-6)' }}>
            <Label htmlFor={fieldId}>Name</Label>
            <TextField size='sm'>
              <TextFieldInput id={fieldId} name='name' defaultValue={name} required pattern='.*\S.*' maxLength={80} autoFocus />
            </TextField>
          </div>
          <Button type='submit' size='sm' style={{ justifySelf: 'end', marginBlockStart: 'var(--spacing-8)' }}>Rename</Button>
        </form>
      </PopoverContent>
    </Popover>
  );
}
```

### Layout/Overlay

#### Layout/Overlay: popovers inside a scrolling list

Each row has its own popover. The content renders in the top layer, so the list's `overflow: auto` never clips it, and it follows its trigger while the list scrolls and flips when there is no room below.

```tsx
import {
  Icon,
  IconButton,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';
import { InfoIcon } from '@vega-ui/icons';

const names = ['Maria Chen', 'Tom Okafor', 'Lea Novak', 'Sam Rivera', 'Ana Silva'];

const members = Array.from({ length: 30 }, (_, index) => ({
  id: index,
  name: names[index % names.length],
  role: index % 4 === 0 ? 'Admin' : 'Editor',
  lastSeen: `${index + 2} h ago`,
}));

export default function ScrollContainer() {
  return (
    <ul
      tabIndex={0}
      aria-label='Team members'
      style={{
        boxSizing: 'border-box',
        width: '100%',
        height: 220,
        overflow: 'auto',
        overscrollBehavior: 'contain',
        margin: 0,
        paddingBlock: 'var(--spacing-2)',
        paddingInline: 0,
        listStyle: 'none',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      {members.map((member) => (
        <li key={member.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-6)', paddingInline: 'var(--spacing-8)', paddingBlock: 'var(--spacing-3)' }}>
          <Text size={3}>{member.name}</Text>
          <Popover role='dialog' placement='bottom-end'>
            <PopoverTrigger asChild>
              <IconButton aria-label={`Details for ${member.name}`} size='sm' variant='secondary' appearance='ghost'>
                <Icon size='xs'><InfoIcon /></Icon>
              </IconButton>
            </PopoverTrigger>
            <PopoverContent aria-label={`${member.name} details`}>
              <div style={{ display: 'grid', gap: 'var(--spacing-1)', minWidth: 180 }}>
                <Text size={3} fontWeight={600}>{member.name}</Text>
                <Text size={2} style={{ color: 'var(--label-secondary)' }}>{member.role} · last seen {member.lastSeen}</Text>
              </div>
            </PopoverContent>
          </Popover>
        </li>
      ))}
    </ul>
  );
}
```

### Error

#### Error: invalid link address

The form uses `noValidate` and checks the address on submit: an invalid one keeps the popover open and shows an error linked with `aria-describedby` and `aria-invalid`, a valid one is inserted and closes it. Submit the prefilled value to see the error.

```tsx
import { useId, useState, type FormEvent } from 'react';
import {
  Button,
  HelperText,
  Icon,
  IconButton,
  Label,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { LinkIcon } from '@vega-ui/icons';

const parseUrl = (value: string) => {
  const withProtocol = /^[a-z]+:\/\//i.test(value) ? value : `https://${value}`;

  try {
    const url = new URL(withProtocol);
    if (!url.hostname.includes('.')) return null;
    return url.href;
  } catch {
    return null;
  }
};

export default function LinkError() {
  const fieldId = useId();
  const errorId = useId();
  const [open, setOpen] = useState(false);
  const [error, setError] = useState('');
  const [href, setHref] = useState('');

  const changeOpen = (next: boolean) => {
    setError('');
    setOpen(next);
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const url = parseUrl(String(new FormData(event.currentTarget).get('url')).trim());

    if (!url) {
      setError('Enter a full address, for example northwind.dev/pricing.');
      return;
    }
    setHref(url);
    setOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-4)',
        paddingInlineEnd: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <Popover role='dialog' placement='bottom-start' open={open} onChangeOpen={changeOpen}>
        <PopoverTrigger asChild>
          <IconButton aria-label='Insert link' variant='secondary' appearance='ghost' style={{ flexShrink: 0 }}>
            <Icon><LinkIcon /></Icon>
          </IconButton>
        </PopoverTrigger>
        <PopoverContent aria-label='Insert link'>
          <form noValidate onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-3)', width: 280 }}>
            <Label htmlFor={fieldId}>Link address</Label>
            <TextField size='sm' error={error !== ''}>
              <TextFieldInput
                id={fieldId}
                name='url'
                inputMode='url'
                defaultValue='northwind'
                autoFocus
                aria-invalid={error !== ''}
                aria-describedby={error ? errorId : undefined}
                onChange={() => setError('')}
              />
            </TextField>
            {error && <HelperText id={errorId} error size='sm'>{error}</HelperText>}
            <Button type='submit' size='sm' style={{ justifySelf: 'end', marginBlockStart: 'var(--spacing-5)' }}>Insert</Button>
          </form>
        </PopoverContent>
      </Popover>
      <Text size={2} style={{ minWidth: 0, color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>{href ? `Linked to ${href}` : 'No link yet'}</Text>
    </div>
  );
}
```

### Disabled

#### Disabled: trigger disabled until something is selected

The Bulk actions trigger is disabled while no file is selected, and the hint is linked with `aria-describedby` while it is. A disabled button cannot open its popover through `popovertarget`.

```tsx
import { useId, useState } from 'react';
import {
  Button,
  Checkbox,
  CheckboxCheckedIcon,
  CheckboxIndeterminateIcon,
  CheckboxIndicator,
  CheckboxInput,
  HelperText,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const actions = ['Download', 'Move to archive', 'Share'];

const files = ['Contract – Kestrel Robotics.pdf', 'Invoice INV-2041.pdf', 'Brand guidelines.fig'];

export default function DisabledTrigger() {
  const hintId = useId();
  const [selected, setSelected] = useState<string[]>([]);
  const [open, setOpen] = useState(false);
  const [status, setStatus] = useState('');

  const toggle = (file: string, checked: boolean) => {
    if (checked) {
      setSelected([...selected, file]);
      return;
    }
    setSelected(selected.filter((item) => item !== file));
  };

  const run = (action: string) => {
    setStatus(`${action}: ${selected.length} file(s)`);
    setOpen(false);
  };

  const none = selected.length === 0;

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%' }}>
      <div
        style={{
          display: 'grid',
          gap: 'var(--spacing-6)',
          padding: 'var(--spacing-8)',
          background: 'var(--surface-primary)',
          border: '1px solid var(--separator-opaque)',
          borderRadius: 'var(--radius-5)',
        }}
      >
        {files.map((file) => (
          <Checkbox key={file} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)', minWidth: 0 }}>
            <CheckboxInput checked={selected.includes(file)} onChange={(event) => toggle(file, event.currentTarget.checked)} />
            <CheckboxIndicator>
              <CheckboxCheckedIcon />
              <CheckboxIndeterminateIcon />
            </CheckboxIndicator>
            <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file}</span>
          </Checkbox>
        ))}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
        <Popover placement='bottom-start' open={open} onChangeOpen={setOpen}>
          <PopoverTrigger asChild>
            <Button variant='secondary' appearance='ghost' disabled={none} aria-describedby={none ? hintId : undefined} style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
              {none ? 'Bulk actions' : `Bulk actions (${selected.length})`}
            </Button>
          </PopoverTrigger>
          <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
            <div role='group' aria-label='Bulk actions' style={{ display: 'grid', minWidth: 180 }}>
              {actions.map((action) => (
                <Button key={action} variant='secondary' appearance='transparent' style={{ justifyContent: 'flex-start' }} onClick={() => run(action)}>
                  {action}
                </Button>
              ))}
            </div>
          </PopoverContent>
        </Popover>
        {none && <HelperText id={hintId}>Select at least one file.</HelperText>}
        {!none && <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>{status}</Text>}
      </div>
    </div>
  );
}
```

### Edge

#### Edge: popover inside a popover

The label picker opens from inside the filters popover. Nested `popover="auto"` elements stack natively: choosing a label closes only the inner one, and an outside click closes both.

```tsx
import { useState } from 'react';
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const labels = ['Bug', 'Feature', 'Design', 'Docs'];

export default function NestedPopover() {
  const [label, setLabel] = useState('Bug');
  const [labelOpen, setLabelOpen] = useState(false);

  const choose = (next: string) => {
    setLabel(next);
    setLabelOpen(false);
  };

  return (
    <Popover role='dialog' placement='bottom-start'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Filters · {label}</Button>
      </PopoverTrigger>
      <PopoverContent aria-label='Filters'>
        <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-6)' }}>
          <Text size={3}>Label</Text>
          <Popover placement='right-start' open={labelOpen} onChangeOpen={setLabelOpen}>
            <PopoverTrigger asChild>
              <Button size='sm' variant='secondary' appearance='ghost'>{label}</Button>
            </PopoverTrigger>
            <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
              <div role='group' aria-label='Labels' style={{ display: 'grid', minWidth: 120 }}>
                {labels.map((item) => (
                  <Button
                    key={item}
                    size='sm'
                    variant='secondary'
                    appearance={item === label ? 'ghost' : 'transparent'}
                    aria-pressed={item === label}
                    style={{ justifyContent: 'flex-start' }}
                    onClick={() => choose(item)}
                  >
                    {item}
                  </Button>
                ))}
              </div>
            </PopoverContent>
          </Popover>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

#### Edge: long unbreakable content

`PopoverContent` is `width: max-content`, so a long URL would make it as wide as the URL. A `max-width` on the inner element and `overflow-wrap: anywhere` keep it within the screen.

```tsx
import {
  Button,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

const webhook = 'https://hooks.northwind.dev/integrations/v2/workspaces/8f3a9c1e-42b7-4d0a-9e61-b5c7d2f40a18/events?signature=sha256';

export default function LongContent() {
  return (
    <Popover role='dialog'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Webhook URL</Button>
      </PopoverTrigger>
      <PopoverContent aria-label='Webhook URL'>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', maxWidth: 'min(320px, calc(100vw - 48px))' }}>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Events are sent to</Text>
          <Text size={3} style={{ overflowWrap: 'anywhere', fontFamily: 'monospace' }}>{webhook}</Text>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

## Anatomy

### Overview

`Popover` owns open state and floating-layer coordination between the trigger and content. It stays non-modal by default, so the composition contract is about anchoring and contextual content rather than isolation.

### Required Parts

#### `Popover`

Required. Owns open state and anchor coordination.

#### `PopoverTrigger`

Required for trigger-driven usage. Toggles the popover through the native `popovertarget`, so it must be a button; with `asChild`, the child must render a `<button>`.

#### `PopoverContent`

Required. Renders the floating content surface as a native `popover="auto"` element in the top layer, so no portal part is needed. It is always rendered and hidden while closed; its children render only while it is open.

### Optional Parts

None. For a dimmed backdrop, use `Dialog` or `Sheet` instead.

### Composition Order

1. `Popover`
2. `PopoverTrigger`
3. `PopoverContent`

### Valid Composition Patterns

```tsx
import { useId } from 'react';
import {
  Button,
  Heading,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';

export default function MinimalComposition() {
  const titleId = useId();

  return (
    <Popover role='dialog'>
      <PopoverTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Storage · 82% used</Button>
      </PopoverTrigger>
      <PopoverContent aria-labelledby={titleId}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', maxWidth: 280 }}>
          <Heading id={titleId} as='h3' size={5} fontWeight={600}>16.4 GB of 20 GB used</Heading>
          <Text size={2} style={{ color: 'var(--label-secondary)' }}>Videos take 11.2 GB. Old exports are removed after 90 days.</Text>
        </div>
      </PopoverContent>
    </Popover>
  );
}
```

### Invalid Composition Patterns

#### Critical modal task inside `PopoverContent`

If the flow blocks progress or requires strong focus trapping, use `Dialog`, `Drawer`, or `Sheet` instead.

#### Large page-like layout inside the popover

This weakens the contextual nature of the component and usually indicates the wrong overlay pattern.

#### Trigger and content split across unrelated state owners

That makes open state and placement harder to reason about than a single popover tree.
