diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts
index aef73a2ea..078b9d859 100644
--- a/packages/bits-ui/src/lib/bits/index.ts
+++ b/packages/bits-ui/src/lib/bits/index.ts
@@ -36,3 +36,4 @@ export * as Tooltip from "./tooltip/index.js";
export * as EscapeLayer from "./utilities/escape-layer/index.js";
export * as PreventTextSelectionOverflowLayer from "./utilities/prevent-text-selection-overflow-layer/index.js";
export * as DismissableLayer from "./utilities/dismissable-layer/index.js";
+export * as Portal from "./utilities/portal/index.js";
diff --git a/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte b/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte
index e69de29bb..0d0c8b29a 100644
--- a/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte
+++ b/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte
@@ -0,0 +1,37 @@
+
+
+{#if asChild}
+ {@render child?.({ props: mergedProps })}
+{:else}
+
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts b/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts
index eac37c7cb..f8e17fa1b 100644
--- a/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts
@@ -41,6 +41,10 @@ class PopoverRootState {
createContent(props: PopoverContentStateProps) {
return new PopoverContentState(props, this);
}
+
+ createClose(props: PopoverCloseStateProps) {
+ return new PopoverCloseState(props, this);
+ }
}
type PopoverTriggerStateProps = ReadonlyBoxedValues<{
@@ -66,6 +70,7 @@ class PopoverTriggerState {
this.root.open.value && this.root.contentId.value
? this.root.contentId.value
: undefined,
+ "data-popover-trigger": "",
//
onclick: this.#composedClick,
onkeydown: this.#composedKeydown,
@@ -105,6 +110,7 @@ class PopoverContentState {
tabindex: -1,
hidden: !this.root.open.value ? true : undefined,
"data-state": getDataOpenClosed(this.root.open.value),
+ "data-popover-content": "",
});
constructor(props: PopoverContentStateProps, root: PopoverRootState) {
@@ -114,6 +120,39 @@ class PopoverContentState {
}
}
+type PopoverCloseStateProps = ReadonlyBoxedValues<{
+ onclick: EventCallback;
+ onkeydown: EventCallback;
+}>;
+
+class PopoverCloseState {
+ root = undefined as unknown as PopoverRootState;
+ #composedClick = undefined as unknown as EventCallback;
+ #composedKeydown = undefined as unknown as EventCallback;
+ props = $derived({
+ onclick: this.#composedClick,
+ onkeydown: this.#composedKeydown,
+ type: "button",
+ "data-popover-close": "",
+ } as const);
+
+ constructor(props: PopoverCloseStateProps, root: PopoverRootState) {
+ this.root = root;
+ this.#composedClick = composeHandlers(props.onclick, this.#onclick);
+ this.#composedKeydown = composeHandlers(props.onkeydown, this.#onkeydown);
+ }
+
+ #onclick = () => {
+ this.root.close();
+ };
+
+ #onkeydown = (e: KeyboardEvent) => {
+ if (!(e.key === kbd.ENTER || e.key === kbd.SPACE)) return;
+ e.preventDefault();
+ this.root.close();
+ };
+}
+
//
// CONTEXT METHODS
//
@@ -136,3 +175,7 @@ export function setPopoverTriggerState(props: PopoverTriggerStateProps) {
export function setPopoverContentState(props: PopoverContentStateProps) {
return getPopoverRootState().createContent(props);
}
+
+export function setPopoverCloseState(props: PopoverCloseStateProps) {
+ return getPopoverRootState().createClose(props);
+}
diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte
index 1597dc258..de983cfaf 100644
--- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte
+++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte
@@ -22,6 +22,7 @@
dir = "ltr",
style = {},
present,
+ wrapperId,
}: ContentProps = $props();
const state = setFloatingContentState({
@@ -42,6 +43,7 @@
dir: readonlyBox(() => dir),
style: readonlyBox(() => style),
present: readonlyBox(() => present),
+ wrapperId: readonlyBox(() => wrapperId),
});
diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts
index 9c0b555d5..4f282d659 100644
--- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts
@@ -16,9 +16,7 @@ import {
type Box,
type ReadonlyBox,
type ReadonlyBoxedValues,
- afterTick,
boxedState,
- generateId,
styleToString,
useNodeById,
} from "$lib/internal/index.js";
@@ -43,15 +41,11 @@ export type Align = (typeof ALIGN_OPTIONS)[number];
export type Boundary = Element | null;
class FloatingRootState {
- wrapperId = boxedState(generateId());
- wrapperNode = undefined as unknown as Box;
+ wrapperId = undefined as unknown as ReadonlyBox;
contentNode = undefined as unknown as Box;
anchorNode = undefined as unknown as Box;
arrowNode = boxedState(null);
-
- constructor() {
- this.wrapperNode = useNodeById(this.wrapperId);
- }
+ wrapperNode = undefined as unknown as Box;
createAnchor(props: FloatingAnchorStateProps) {
return new FloatingAnchorState(props, this);
@@ -68,6 +62,7 @@ class FloatingRootState {
export type FloatingContentStateProps = ReadonlyBoxedValues<{
id: string;
+ wrapperId: string;
side: Side;
sideOffset: number;
align: Align;
@@ -227,6 +222,8 @@ class FloatingContentState {
this.root = root;
this.present = props.present;
this.arrowSize = useSize(this.root.arrowNode);
+ this.root.wrapperId = props.wrapperId;
+ this.root.wrapperNode = useNodeById(this.root.wrapperId);
this.root.contentNode = useNodeById(this.id);
this.floating = useFloating({
strategy: () => this.strategy.value,
diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts
index ec964d05f..0a2242840 100644
--- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts
+++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts
@@ -103,6 +103,11 @@ export type FloatingLayerContentProps = {
* Whether the floating layer is present.
*/
present: boolean;
+
+ /**
+ * The ID of the content wrapper element.
+ */
+ wrapperId?: string;
};
export type FloatingLayerArrowProps = {
diff --git a/packages/bits-ui/src/lib/bits/utilities/index.ts b/packages/bits-ui/src/lib/bits/utilities/index.ts
index 93f9e009c..c422cfa2f 100644
--- a/packages/bits-ui/src/lib/bits/utilities/index.ts
+++ b/packages/bits-ui/src/lib/bits/utilities/index.ts
@@ -6,3 +6,4 @@ export * as DismissableLayer from "./dismissable-layer/index.js";
export * as PreventTextSelectionOverflowLayer from "./prevent-text-selection-overflow-layer/index.js";
export * as PresenceLayer from "./presence-layer/index.js";
export * as PopperLayer from "./popper-layer/index.js";
+export * as Portal from "./portal/index.js";
diff --git a/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte
index 741a3cc3c..a099fa9c3 100644
--- a/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte
+++ b/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte
@@ -4,6 +4,7 @@
DismissableLayer,
EscapeLayer,
FloatingLayer,
+ Portal,
PresenceLayer,
PreventTextSelectionOverflowLayer,
} from "$lib/bits/utilities/index.js";
@@ -11,23 +12,34 @@
let { popper, ...restProps }: Props = $props();
-
- {#snippet presence({ present })}
-
- {#snippet content({ props })}
-
-
-
- {@render popper?.({
- props: { ...props, hidden: present.value ? undefined : true },
- })}
-
-
-
+
+ {#snippet portal({ portalProps })}
+
+ {#snippet presence({ present })}
+
+ {#snippet content({ props })}
+
+
+
+ {@render popper?.({
+ props: {
+ ...props,
+ hidden: present.value ? undefined : true,
+ },
+ })}
+
+
+
+ {/snippet}
+
{/snippet}
-
+
{/snippet}
-
+
diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/index.ts b/packages/bits-ui/src/lib/bits/utilities/portal/index.ts
new file mode 100644
index 000000000..730a09d44
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/utilities/portal/index.ts
@@ -0,0 +1,3 @@
+export { default as Root } from "./portal.svelte";
+
+export type { PortalProps } from "./types.js";
diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte b/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte
new file mode 100644
index 000000000..16e6f21de
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte
@@ -0,0 +1,17 @@
+
+
+{#if forceMount}
+ {@render portal?.({ portalProps: state.props })}
+{/if}
diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/types.ts b/packages/bits-ui/src/lib/bits/utilities/portal/types.ts
new file mode 100644
index 000000000..8ec21e63d
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/utilities/portal/types.ts
@@ -0,0 +1,33 @@
+import type { Snippet } from "svelte";
+
+export type PortalProps = {
+ /**
+ * Where to portal the content to.
+ *
+ * @defaultValue document.body
+ */
+ to?: HTMLElement | string;
+
+ /**
+ * Disable portaling and render the component inline
+ *
+ * @defaultValue false
+ */
+ disabled?: boolean;
+
+ /**
+ * Whether to force mount the portal content for more
+ * advanced animation control
+ *
+ * @defaultValue false
+ *
+ */
+ forceMount?: boolean;
+
+ /**
+ * The id of the portal content
+ */
+ id?: string;
+
+ portal?: Snippet<[{ portalProps: { id: string; "data-portal": string } }]>;
+};
diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts
new file mode 100644
index 000000000..ab69df34a
--- /dev/null
+++ b/packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts
@@ -0,0 +1,44 @@
+import { untrack } from "svelte";
+import type { ReadonlyBox } from "$lib/internal/box.svelte.js";
+import { useNodeById } from "$lib/internal/use-node-by-id.svelte.js";
+
+export function usePortal(id: ReadonlyBox, to: ReadonlyBox) {
+ const node = useNodeById(id);
+
+ const props = $derived({
+ id: id.value,
+ "data-portal": "",
+ });
+
+ $effect.pre(() => {
+ if (!node.value) return;
+ let target: HTMLElement | null = null;
+ if (typeof to.value === "string") {
+ target = document.querySelector(to.value);
+ if (!target) {
+ throw new Error(`Could not find target element with selector: ${to.value}`);
+ }
+ target.appendChild(node.value);
+ } else if (to.value instanceof HTMLElement) {
+ to.value.appendChild(node.value);
+ } else {
+ throw new TypeError(
+ `Unknown portal target type: ${
+ to.value === null ? "null" : typeof to.value
+ }. Allowed types: string (CSS selector) or HTMLElement.`
+ );
+ }
+ });
+
+ $effect(() => {
+ return () => {
+ untrack(() => node.value?.remove());
+ };
+ });
+
+ return {
+ get props() {
+ return props;
+ },
+ };
+}
diff --git a/packages/bits-ui/src/lib/internal/use-presence.svelte.ts b/packages/bits-ui/src/lib/internal/use-presence.svelte.ts
index fa39c07c0..fdbde4381 100644
--- a/packages/bits-ui/src/lib/internal/use-presence.svelte.ts
+++ b/packages/bits-ui/src/lib/internal/use-presence.svelte.ts
@@ -1,6 +1,6 @@
-import { onDestroy, untrack } from "svelte";
+import { onDestroy } from "svelte";
import { type Box, type ReadonlyBox, boxedState, watch } from "./box.svelte.js";
-import { afterTick, useNodeById, useStateMachine } from "$lib/internal/index.js";
+import { useNodeById, useStateMachine } from "$lib/internal/index.js";
export function usePresence(present: ReadonlyBox, id: ReadonlyBox) {
const styles = boxedState({}) as unknown as Box;
diff --git a/packages/bits-ui/src/lib/internal/use-size.svelte.ts b/packages/bits-ui/src/lib/internal/use-size.svelte.ts
index b48452359..8e8cd720f 100644
--- a/packages/bits-ui/src/lib/internal/use-size.svelte.ts
+++ b/packages/bits-ui/src/lib/internal/use-size.svelte.ts
@@ -1,6 +1,6 @@
///
-import { tick, untrack } from "svelte";
+import { untrack } from "svelte";
import type { Box } from "./box.svelte.js";
import { afterTick } from "./after-tick.js";
diff --git a/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts b/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts
index 686f38890..dfd3351ec 100644
--- a/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts
+++ b/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts
@@ -1,4 +1,4 @@
-import { box, boxedState } from "./box.svelte.js";
+import { boxedState } from "./box.svelte.js";
interface Machine {
[k: string]: { [k: string]: S };