Popover

Opens a panel next to its trigger. Set placement to any side and alignment; the panel flips to the opposite side when it would overflow the viewport.

<script lang="ts">
    import Building2 from '@lucide/svelte/icons/building-2';
    import * as Avatar from '@sivir-ui/svelte/components/avatar';
    import { Button } from '@sivir-ui/svelte/components/button';
    import { CopyButton } from '@sivir-ui/svelte/components/copy-button';
    import { Input } from '@sivir-ui/svelte/components/input';
    import * as Popover from '@sivir-ui/svelte/components/popover';
    import * as Select from '@sivir-ui/svelte/components/select';

    type Role = 'edit' | 'comment' | 'view';

    type Collaborator = {
        name: string;
        email: string;
        role: Role;
    };

    const roleLabels: Record<Role, string> = {
        edit: 'Can edit',
        comment: 'Can comment',
        view: 'Can view'
    };

    const roles = Object.keys(roleLabels) as Role[];

    const docLink = 'https://sivir.dev/d/q3-roadmap';

    let invite = $state('');

    let collaborators = $state<Collaborator[]>([
        {
            name: 'Evan',
            email: 'evan@sivir.dev',
            role: 'edit'
        },
        {
            name: 'Priya',
            email: 'priya@sivir.dev',
            role: 'comment'
        }
    ]);

    function initials(name: string) {
        return name
            .split(/[\s@.]+/)
            .slice(0, 2)
            .map((part) => {
                return part.charAt(0).toUpperCase();
            })
            .join('');
    }

    function addCollaborator(event: SubmitEvent) {
        event.preventDefault();

        const email = invite.trim();
        const alreadyInvited = collaborators.some((collaborator) => {
            return collaborator.email.toLowerCase() === email.toLowerCase();
        });

        if (!email || alreadyInvited) {
            return;
        }

        collaborators.push({
            name: email.split('@')[0],
            email,
            role: 'view'
        });
        invite = '';
    }
</script>

<div class="flex w-full max-w-md justify-center">
    <Popover.Root>
        <Popover.Trigger variant="outline" size="md">Share</Popover.Trigger>
        <Popover.Content aria-label="Share" class="w-[28rem] max-w-[calc(100vw-2rem)]">
            <div class="flex flex-col gap-4">
                <form class="flex items-center gap-2" onsubmit={addCollaborator}>
                    <div class="min-w-0 flex-1">
                        <Input
                            bind:value={invite}
                            type="email"
                            placeholder="Add people by email"
                            aria-label="Email address"
                        />
                    </div>
                    <Button type="submit" size="md" disabled={!invite.trim()}>Invite</Button>
                </form>

                <ul class="m-0 flex list-none flex-col gap-3 p-0">
                    <li class="flex items-center gap-3">
                        <Avatar.Root size="sm">
                            <Avatar.Fallback>S</Avatar.Fallback>
                        </Avatar.Root>
                        <div class="flex min-w-0 flex-1 flex-col">
                            <p class="m-0 truncate text-sm">Seth (you)</p>
                            <p class="m-0 truncate text-xs text-foreground-muted">seth@sivir.dev</p>
                        </div>
                        <p class="m-0 w-40 shrink-0 pr-3 text-right text-sm text-foreground-muted">
                            Owner
                        </p>
                    </li>

                    {#each collaborators as collaborator (collaborator.email)}
                        <li class="flex items-center gap-3">
                            <Avatar.Root size="sm">
                                <Avatar.Fallback>{initials(collaborator.name)}</Avatar.Fallback>
                            </Avatar.Root>
                            <div class="flex min-w-0 flex-1 flex-col">
                                <p class="m-0 truncate text-sm">{collaborator.name}</p>
                                <p class="m-0 truncate text-xs text-foreground-muted">
                                    {collaborator.email}
                                </p>
                            </div>
                            <Select.Root bind:value={collaborator.role}>
                                <Select.Trigger
                                    variant="ghost"
                                    size="sm"
                                    aria-label={`Access for ${collaborator.name}`}
                                    class="w-40 shrink-0 justify-between"
                                >
                                    {roleLabels[collaborator.role]}
                                </Select.Trigger>
                                <Select.Content>
                                    {#each roles as role (role)}
                                        <Select.Item value={role}>{roleLabels[role]}</Select.Item>
                                    {/each}
                                </Select.Content>
                            </Select.Root>
                        </li>
                    {/each}
                </ul>

                <div class="flex items-center gap-3 border-t border-border pt-4">
                    <Building2 size={16} class="shrink-0 text-foreground-muted" />
                    <div class="flex min-w-0 flex-1 flex-col">
                        <p class="m-0 text-sm">Anyone at Sivir with the link</p>
                        <p class="m-0 text-xs text-foreground-muted">Can view</p>
                    </div>
                    <CopyButton
                        text={docLink}
                        label="Copy link"
                        copiedLabel="Copied"
                        variant="outline"
                        class="shrink-0 whitespace-nowrap"
                    >
                        Copy link
                    </CopyButton>
                </div>
            </div>
        </Popover.Content>
    </Popover.Root>
</div>

Installation

Copy the command below and run it in your terminal.

bunx @sivir-ui/svelte add popover

Usage

Popover.Content has role="dialog" and takes its accessible name from Popover.Title, so include one or pass aria-label.

While a popover is open, the rest of the page is inert unless hoverable is set. Set inert={false} on Popover.Root only when the surrounding page must remain interactive.

import * as Popover from '@sivir-ui/svelte/components/popover';
import { Textarea } from '@sivir-ui/svelte/components/textarea';

<Popover.Root>
  <Popover.Trigger>Feedback</Popover.Trigger>
  <Popover.Content class="w-80">
    <Popover.Title>Send feedback</Popover.Title>
    <Textarea placeholder="What’s working, and what isn’t?" />
  </Popover.Content>
</Popover.Root>

Examples

Placement variants

<script lang="ts">
    import * as Popover from '@sivir-ui/svelte/components/popover';
</script>

<div class="flex flex-wrap gap-4 justify-center">
    {#each ['top', 'right', 'bottom', 'left'] as placement (placement)}
        <Popover.Root placement={placement as 'top' | 'right' | 'bottom' | 'left'}>
            <Popover.Trigger size="md" variant="outline">{placement}</Popover.Trigger>
            <Popover.Content class="w-48">
                <div class="p-3">
                    <p class="m-0 text-sm text-foreground-muted">Anchored to {placement}</p>
                </div>
            </Popover.Content>
        </Popover.Root>
    {/each}
</div>

Anatomy

Popover.Root - Controls popover state.

Popover.Trigger - Opens the popover.

Popover.Content - Renders the popover surface.

Popover.Title - Renders the popover title.