Skip to content
edmi-ui

Popover

Rich content in a floating panel opened by a button. Unlike Dialog, the page behind stays usable.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal window
npx shadcn@latest add @edmi-ui/popover
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/popover
Terminal window
yarn dlx shadcn@latest add @edmi-ui/popover
Terminal window
bunx shadcn@latest add @edmi-ui/popover

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/popover
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/popover
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/popover
Terminal window
bunx shadcn-vue@latest add @edmi-ui/popover

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/popover.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/popover.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/popover.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/popover.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
<Popover>
<PopoverTrigger render={<Button variant="outline" />}>
Set limits
</PopoverTrigger>
<PopoverContent align="start" className="w-80">
<PopoverHeader>
<PopoverTitle>Rebalance limits</PopoverTitle>
<PopoverDescription>
Applied to the next keeper run.
</PopoverDescription>
</PopoverHeader>
<div className="grid grid-cols-[1fr_120px] items-center gap-2 text-[13px]">
<span>Drift</span>
<Input defaultValue="5%" />
<span>Max slippage</span>
<Input defaultValue="1%" />
</div>
</PopoverContent>
</Popover>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline">Set limits</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-80">
<PopoverHeader>
<PopoverTitle>Rebalance limits</PopoverTitle>
<PopoverDescription>Applied to the next keeper run.</PopoverDescription>
</PopoverHeader>
<div class="grid grid-cols-[1fr_120px] items-center gap-2 text-[13px]">
<span>Drift</span>
<Input default-value="5%" />
<span>Max slippage</span>
<Input default-value="1%" />
</div>
</PopoverContent>
</Popover>
</template>
<script lang="ts">
import * as Popover from "$lib/components/ui/popover";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
</script>
<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}>Set limits</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-80">
<Popover.Header>
<Popover.Title>Rebalance limits</Popover.Title>
<Popover.Description>Applied to the next keeper run.</Popover.Description>
</Popover.Header>
<div class="grid grid-cols-[1fr_120px] items-center gap-2 text-[13px]">
<span>Drift</span>
<Input value="5%" />
<span>Max slippage</span>
<Input value="1%" />
</div>
</Popover.Content>
</Popover.Root>
registry/ui/popover.tsx
"use client";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { cn } from "cn";
import type * as React from "react";
import {
type Elevation,
SurfaceProvider,
useElevation,
} from "@/registry/edmi/ui/elevation";
// ✦ depth (v4): overlay role. Natural level is floating in layered mode; flat otherwise.
const overlayElevation = {
sunken: "",
flat: "",
raised: "border-transparent shadow-raised",
floating: "border-transparent shadow-floating",
};
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 6,
elevation,
children,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
> & {
/** ✦ depth: flat 0, raised +1 (bevel), floating +2 (bevel + drop). */
elevation?: Elevation;
}) {
const level = useElevation(elevation, "overlay");
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
// Flat (recipes.surface.popover); `elevation` adds the bevel / drop.
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-xl border border-border bg-popover p-3 text-sm text-popover-foreground outline-hidden transition-[opacity,scale] duration-100 data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0",
overlayElevation[level],
className,
)}
{...props}
>
<SurfaceProvider level={level}>{children}</SurfaceProvider>
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium tracking-[-0.1px]", className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-[13px] text-muted-foreground", className)}
{...props}
/>
);
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
};
registry/ui/popover/Popover.vue
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</PopoverRoot>
</template>
registry/ui/popover/PopoverAnchor.vue
<script setup lang="ts">
import type { PopoverAnchorProps } from 'reka-ui'
import { PopoverAnchor } from 'reka-ui'
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<slot />
</PopoverAnchor>
</template>
registry/ui/popover/PopoverContent.vue
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/registry/edmi/lib/utils'
import { type Elevation, provideSurface, useElevation } from '@/registry/edmi/ui/elevation'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'], elevation?: Elevation }>(),
{
elevation: undefined,
align: 'center',
sideOffset: 6,
},
)
const emits = defineEmits<PopoverContentEmits>()
// ✦ depth (v4): overlay role. Natural level is floating in layered mode; flat otherwise.
const overlayElevation = {
sunken: '',
flat: '',
raised: 'border-transparent shadow-raised',
floating: 'border-transparent shadow-floating',
}
const level = useElevation(() => props.elevation, 'overlay')
provideSurface(() => level.value)
const delegatedProps = reactiveOmit(props, 'class', 'elevation')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
// Flat by default (recipes.surface.popover); `elevation` ✦ adds the bevel / drop.
'z-50 flex w-72 max-w-(--reka-popover-content-available-width) origin-(--reka-popover-content-transform-origin) flex-col gap-2.5 rounded-xl border border-border bg-popover p-3 text-sm text-popover-foreground outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
overlayElevation[level],
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>
registry/ui/popover/PopoverDescription.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-description"
:class="cn('text-[13px] text-muted-foreground', props.class)"
>
<slot />
</div>
</template>
registry/ui/popover/PopoverHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-header"
:class="cn('flex flex-col gap-0.5 text-sm', props.class)"
>
<slot />
</div>
</template>
registry/ui/popover/PopoverTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="popover-title"
:class="cn('font-medium tracking-[-0.1px]', props.class)"
>
<slot />
</div>
</template>
registry/ui/popover/PopoverTrigger.vue
<script setup lang="ts">
import type { PopoverTriggerProps } from 'reka-ui'
import { PopoverTrigger } from 'reka-ui'
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<slot />
</PopoverTrigger>
</template>
registry/ui/popover/index.ts
export { default as Popover } from "./Popover.vue";
export { default as PopoverAnchor } from "./PopoverAnchor.vue";
export { default as PopoverContent } from "./PopoverContent.vue";
export { default as PopoverDescription } from "./PopoverDescription.vue";
export { default as PopoverHeader } from "./PopoverHeader.vue";
export { default as PopoverTitle } from "./PopoverTitle.vue";
export { default as PopoverTrigger } from "./PopoverTrigger.vue";
src/lib/registry/ui/popover/popover.svelte
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
src/lib/registry/ui/popover/popover-close.svelte
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
src/lib/registry/ui/popover/popover-content.svelte
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { type Elevation, setSurface, useElevation } from "$lib/registry/ui/elevation/index.js";
import PopoverPortal from "./popover-portal.svelte";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
sideOffset = 6,
align = "center",
portalProps,
elevation = "auto",
...restProps
}: PopoverPrimitive.ContentProps & {
/** ✦ depth: flat 0, raised +1 (bevel), floating +2 (bevel + drop). */
elevation?: Elevation;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
// ✦ depth (v4): overlay role. Natural level is floating in layered mode; flat otherwise.
const overlayElevation = {
sunken: "",
flat: "",
raised: "border-transparent shadow-raised",
floating: "border-transparent shadow-floating",
};
const level = useElevation(() => elevation, "overlay");
setSurface(() => level.current);
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
// recipes.surface.popover (flat); elevation ✦ adds the bevel / drop.
"z-50 flex w-72 origin-(--bits-popover-content-transform-origin) flex-col gap-2.5 rounded-xl border border-border bg-popover p-3 text-sm text-popover-foreground outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
overlayElevation[level.current],
className
)}
{...restProps}
/>
</PopoverPortal>
src/lib/registry/ui/popover/popover-description.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-description"
class={cn("text-[13px] text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/popover/popover-header.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-header"
class={cn("flex flex-col gap-0.5 text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/popover/popover-portal.svelte
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
src/lib/registry/ui/popover/popover-title.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-title"
class={cn("font-medium tracking-[-0.1px]", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/popover/popover-trigger.svelte
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn(className)}
{...restProps}
/>
src/lib/registry/ui/popover/index.ts
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Description from "./popover-description.svelte";
import Header from "./popover-header.svelte";
import Portal from "./popover-portal.svelte";
import Title from "./popover-title.svelte";
import Trigger from "./popover-trigger.svelte";
export {
Close,
Close as PopoverClose,
Content,
Content as PopoverContent,
Description,
Description as PopoverDescription,
Header,
Header as PopoverHeader,
Portal,
Portal as PopoverPortal,
Root,
//
Root as Popover,
Title,
Title as PopoverTitle,
Trigger,
Trigger as PopoverTrigger,
};

See the Elevation guide for the levels, roles and layered mode.

Flat by default. On PopoverContent, raised (+1) adds the bevel and floating (+2) the bevel plus one soft drop (the natural level in layered mode).

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Prop Type Default
PopoverContent side top | right | bottom | left bottom
PopoverContent align start | center | end center
PopoverContent sideOffset number 6
PopoverTrigger render ReactElement none (Base UI, replaces asChild)
elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"

Parts: Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription.

  • Flat by default; elevation adds the bevel / drop. Solid --popover surface with a 1px border.