A floating overlay system for anchored, temporary content — context menus, dropdowns, submenus, tooltips. Supports cursor and element anchoring, viewport-quadrant positioning, promise-based results, and nested popup hierarchies. Positioning does not measure popup size or guarantee collision-free viewport fitting.
Import
import { Root, PopupContainer, createPopupManager, getPopupManager } from '@atom-forge/ui';
Components & Exports
| Export |
Description |
PopupContainer |
Renders the active popup. Place one globally (handled by Root) or wrap content for a scoped child manager. |
createPopupManager() |
Creates a PopupManager and registers it in Svelte context. Called automatically by Root. |
getPopupManager() |
Returns the nearest PopupManager from Svelte context. |
PopupManager |
Class managing popup state, opening, closing, and resolve logic. |
Setup
Root handles global setup automatically — no manual configuration needed.
<script lang="ts">
import { Root } from '@atom-forge/ui';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<Root>
{@render children()}
</Root>
For manual setup (without Root):
<script lang="ts">
import { createPopupManager, PopupContainer } from '@atom-forge/ui';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
createPopupManager();
</script>
{@render children()}
<PopupContainer />
open.snippet(snippet, params, args, ref?)
Opens a popup rendering a Svelte snippet.
<script lang="ts">
const popupManager = getPopupManager();
</script>
{#snippet myPopup(data)}
<Card class="p-4">Hello, {data.name}!</Card>
{/snippet}
<Button onclick={e => popupManager.open.snippet(myPopup, { name: 'World' }, { anchor: e })} label="Open"/>
open.component(component, params, args, ref?)
Opens a popup rendering a Svelte component. Useful when the popup is defined in a separate file.
import MyDropdown from './MyDropdown.svelte';
popupManager.open.component(MyDropdown, { items }, { anchor: event, align: 'left' });
Both methods return a Promise<any>. Explicit resolve() settles current/pending content, and an accepted close() settles the active popup with undefined; do not assume every replacement or overlapping open settles earlier promises (see limitations below). ref is the optional fourth argument, not a positioning property; a matching active ref returns the existing promise.
Closing & Resolving
| Method |
Description |
close() |
Schedules active-popup dismissal with undefined; ignored during the opening guard and does not cancel a pending open. |
resolve(value?) |
Closes the popup and resolves the promise with value. |
closeRoot() |
Calls the root manager's delayed close(); scoped child promises are not explicitly settled. |
resolveRoot(value?) |
In nested popups, resolves the root manager's promise with value. |
<Button label="Confirm" onclick={() => popupManager.resolve('confirmed')}/>
<Button label="Cancel" ghost onclick={() => popupManager.close()}/>
Awaiting results
const result = await popupManager.open.snippet(myPopup, {}, { anchor: event });
if (result === 'confirmed') { }
Navigation
With Root installed, route navigation calls resolveRoot(undefined) to clear the root manager's current popup and tracked pending open. Scoped child promises are not explicitly settled by the root manager. Modal and Drawer overlays are also cleared on navigation; Toast notifications remain visible.
Positioning args
The third parameter of open.snippet / open.component controls positioning.
| Property |
Type |
Default |
Description |
pos |
{ clientX, clientY } |
— |
Positions the popup at the cursor. Mutually exclusive with anchor. |
anchor |
Element | MouseEvent |
— |
Positions the popup relative to a DOM element. Passing a MouseEvent uses its currentTarget. |
align |
'auto' | 'left' | 'right' | 'both' | 'side' |
'auto' |
Horizontal alignment. See below. |
offset |
number |
4 |
Anchored popup gap; 0 falls back to 4. Cursor positioning always uses 4. |
Alignment modes
| Value |
Behavior |
auto |
Left-aligns if anchor is in the left half of the screen, right-aligns otherwise. |
left |
Aligns the popup's left edge to the anchor's left edge. |
right |
Aligns the popup's right edge to the anchor's right edge. |
both |
Popup matches the anchor's width (min-width). Ideal for full-width dropdowns. |
side |
Opens to the right or left of the anchor, aligned to its top or bottom edge. Used for submenus. |
For non-side alignment, vertical position is automatic: opens below if the anchor's center is in the top half of the viewport, above otherwise. side aligns with the anchor's top or bottom edge. Neither mode measures the popup's dimensions to prevent overflow.
Wrap popup content in <PopupContainer> to create an isolated child manager. The child manager can open its own popups independently. Use resolveRoot() to propagate a result up to the root promise.
<script lang="ts">
import { Button, Card, getPopupManager } from '@atom-forge/ui';
import { ChevronRight } from 'lucide-svelte';
const popupManager = getPopupManager();
</script>
{#snippet subMenu()}
<Card class="p-2">
<Button ghost label="Nested action" onclick={() => popupManager.resolveRoot('nested-action')}/>
</Card>
{/snippet}
<Card class="p-1 flex flex-col gap-0">
<Button ghost compact label="Action" onclick={() => popupManager.resolveRoot('action')}/>
<Button ghost compact label="Submenu" endIcon={ChevronRight}
onclick={e => popupManager.open.snippet(subMenu, {}, { anchor: e, align: 'side' }, subMenu)}/>
</Card>
In the parent component, import NestedMenu and render <PopupContainer><NestedMenu/></PopupContainer> as the root popup content. The separate child component is necessary: a snippet's lexical context does not become the scoped container's context, and getPopupManager() must not be called during template rendering.
The ref parameter on the submenu open call reuses the existing active popup promise rather than reopening it.
Architecture
Root
└── createPopupManager() → root PopupManager in context
└── PopupContainer (global) → renders root popup
Inside a popup snippet:
PopupContainer (scoped)
└── createPopupManager(parent) → child PopupManager in context
└── PopupContainer → renders child popup
- The root
PopupContainer listens for window click/contextmenu events to close the popup.
- Each scoped
PopupContainer adds its own listener, so clicking inside a child popup doesn't bubble to close the parent (click propagation is stopped on the popup element).
ignoreClose + a 100ms timeout prevents the open call from being immediately cancelled by the same click event.
- Positioning is recalculated every animation frame while an anchored popup is open, so it tracks scroll/resize correctly.
When to use
Use for anchored transient content, dropdowns, or nested menus with promise-based results.
Alternatives
Use Tooltip for hover-only explanations, ContextMenu for configured actions, or Modal for a blocking task.
Setup and limitations
Root creates the global manager and container. Manual providers must be created during component initialization, and consumers must be descendants. Scoped PopupContainer creates child context; obtain that child manager in a component initialized beneath the container rather than reusing a manager captured outside it. A manager holds one active popup, not a general stack. Avoid overlapping opens: a new open schedules replacement without reliably settling the previous active promise, and repeated opens before the timer runs can overwrite the pending resolver. Do not rely on replacement to complete earlier awaits. Pass ref as the fourth open argument; repeated matching ref returns the existing promise. close() is delayed and ignored during the first 100 ms after opening; resolve() immediately resolves current/pending content. There is no generic Escape handler or focus trap: popup content owns keyboard dismissal/focus. Capture a real element before awaiting if using an event anchor, since currentTarget is only available during event dispatch.
Popup
A floating overlay system for anchored, temporary content — context menus, dropdowns, submenus, tooltips. Supports cursor and element anchoring, viewport-quadrant positioning, promise-based results, and nested popup hierarchies. Positioning does not measure popup size or guarantee collision-free viewport fitting.
Import
import { Root, PopupContainer, createPopupManager, getPopupManager } from '@atom-forge/ui';Components & Exports
PopupContainerRoot) or wrap content for a scoped child manager.createPopupManager()PopupManagerand registers it in Svelte context. Called automatically byRoot.getPopupManager()PopupManagerfrom Svelte context.PopupManagerSetup
Roothandles global setup automatically — no manual configuration needed.<!-- src/routes/+layout.svelte --> <script lang="ts"> import { Root } from '@atom-forge/ui'; import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); </script> <Root> {@render children()} </Root>For manual setup (without
Root):<script lang="ts"> import { createPopupManager, PopupContainer } from '@atom-forge/ui'; import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); createPopupManager(); </script> {@render children()} <PopupContainer />PopupManager API
open.snippet(snippet, params, args, ref?)Opens a popup rendering a Svelte snippet.
<script lang="ts"> const popupManager = getPopupManager(); </script> {#snippet myPopup(data)} <Card class="p-4">Hello, {data.name}!</Card> {/snippet} <Button onclick={e => popupManager.open.snippet(myPopup, { name: 'World' }, { anchor: e })} label="Open"/>open.component(component, params, args, ref?)Opens a popup rendering a Svelte component. Useful when the popup is defined in a separate file.
import MyDropdown from './MyDropdown.svelte'; popupManager.open.component(MyDropdown, { items }, { anchor: event, align: 'left' });Both methods return a
Promise<any>. Explicitresolve()settles current/pending content, and an acceptedclose()settles the active popup withundefined; do not assume every replacement or overlapping open settles earlier promises (see limitations below).refis the optional fourth argument, not a positioning property; a matching active ref returns the existing promise.Closing & Resolving
close()undefined; ignored during the opening guard and does not cancel a pending open.resolve(value?)value.closeRoot()close(); scoped child promises are not explicitly settled.resolveRoot(value?)value.<!-- Inside a popup snippet --> <Button label="Confirm" onclick={() => popupManager.resolve('confirmed')}/> <Button label="Cancel" ghost onclick={() => popupManager.close()}/>Awaiting results
const result = await popupManager.open.snippet(myPopup, {}, { anchor: event }); if (result === 'confirmed') { /* handle */ }Navigation
With Root installed, route navigation calls
resolveRoot(undefined)to clear the root manager's current popup and tracked pending open. Scoped child promises are not explicitly settled by the root manager. Modal and Drawer overlays are also cleared on navigation; Toast notifications remain visible.Positioning args
The third parameter of
open.snippet/open.componentcontrols positioning.pos{ clientX, clientY }anchor.anchorElement | MouseEventMouseEventuses itscurrentTarget.align'auto' | 'left' | 'right' | 'both' | 'side''auto'offsetnumber40falls back to4. Cursor positioning always uses4.Alignment modes
autoleftrightbothsideFor non-
sidealignment, vertical position is automatic: opens below if the anchor's center is in the top half of the viewport, above otherwise.sidealigns with the anchor's top or bottom edge. Neither mode measures the popup's dimensions to prevent overflow.Nested Popups
Wrap popup content in
<PopupContainer>to create an isolated child manager. The child manager can open its own popups independently. UseresolveRoot()to propagate a result up to the root promise.<!-- NestedMenu.svelte: rendered as a descendant of a scoped PopupContainer --> <script lang="ts"> import { Button, Card, getPopupManager } from '@atom-forge/ui'; import { ChevronRight } from 'lucide-svelte'; const popupManager = getPopupManager(); </script> {#snippet subMenu()} <Card class="p-2"> <Button ghost label="Nested action" onclick={() => popupManager.resolveRoot('nested-action')}/> </Card> {/snippet} <Card class="p-1 flex flex-col gap-0"> <Button ghost compact label="Action" onclick={() => popupManager.resolveRoot('action')}/> <Button ghost compact label="Submenu" endIcon={ChevronRight} onclick={e => popupManager.open.snippet(subMenu, {}, { anchor: e, align: 'side' }, subMenu)}/> </Card>In the parent component, import
NestedMenuand render<PopupContainer><NestedMenu/></PopupContainer>as the root popup content. The separate child component is necessary: a snippet's lexical context does not become the scoped container's context, andgetPopupManager()must not be called during template rendering.The
refparameter on the submenu open call reuses the existing active popup promise rather than reopening it.Architecture
PopupContainerlistens forwindowclick/contextmenu events to close the popup.PopupContaineradds its own listener, so clicking inside a child popup doesn't bubble to close the parent (click propagation is stopped on the popup element).ignoreClose+ a 100ms timeout prevents the open call from being immediately cancelled by the same click event.When to use
Use for anchored transient content, dropdowns, or nested menus with promise-based results.
Alternatives
Use Tooltip for hover-only explanations, ContextMenu for configured actions, or Modal for a blocking task.
Setup and limitations
Root creates the global manager and container. Manual providers must be created during component initialization, and consumers must be descendants. Scoped PopupContainer creates child context; obtain that child manager in a component initialized beneath the container rather than reusing a manager captured outside it. A manager holds one active popup, not a general stack. Avoid overlapping opens: a new open schedules replacement without reliably settling the previous active promise, and repeated opens before the timer runs can overwrite the pending resolver. Do not rely on replacement to complete earlier awaits. Pass ref as the fourth open argument; repeated matching ref returns the existing promise.
close()is delayed and ignored during the first 100 ms after opening;resolve()immediately resolves current/pending content. There is no generic Escape handler or focus trap: popup content owns keyboard dismissal/focus. Capture a real element before awaiting if using an event anchor, since currentTarget is only available during event dispatch.