Overlay
Popover
Floating panel with a custom trigger
Preview
Anything goes here. Text, a control or a list.
side
align
Code
import css from "./Popover.module.css";
import {
cloneElement,
isValidElement,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
interface TriggerProps {
ref?: React.Ref<HTMLElement>;
onClick?: (ev: React.MouseEvent<HTMLElement>) => void;
"aria-expanded"?: boolean;
"aria-controls"?: string;
}
interface PopoverProps {
trigger: React.ReactElement<TriggerProps>;
side: Side;
align: Align;
children: React.ReactNode | ((close: () => void) => React.ReactNode);
}
export default function Popover({
trigger,
side,
align,
children,
}: PopoverProps) {
const popover = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement>(null);
const content = useRef<HTMLDivElement>(null);
const contentId = useId();
const [open, setOpen] = useState(false);
const [flipSide, setFlipSide] = useState<Side>(side);
const [shift, setShift] = useState(0);
const [placed, setPlaced] = useState(false);
const toggle = () => setOpen((open) => !open);
// Closing from inside (an item was chosen) hands focus back to the trigger:
// the content goes hidden, and a focused item would drop it to the body.
const close = () => {
if (content.current?.contains(document.activeElement)) {
triggerRef.current?.focus();
}
setOpen(false);
};
useLayoutEffect(() => {
if (!open) {
setPlaced(false);
return;
}
if (!triggerRef.current || !content.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const { width, height } = content.current.getBoundingClientRect();
const { clientWidth: viewWidth, clientHeight: viewHeight } = document.documentElement;
const gutter = parseFloat(getComputedStyle(content.current).getPropertyValue("--page-gutter"));
const spaceAbove = rect.top;
const spaceBelow = viewHeight - rect.bottom;
const spaceLeft = rect.left;
const spaceRight = viewWidth - rect.right;
let finalSide = side;
if (side === "bottom" && spaceBelow < height && spaceAbove > spaceBelow) {
finalSide = "top";
} else if (side === "top" && spaceAbove < height && spaceBelow > spaceAbove) {
finalSide = "bottom";
} else if (side === "right" && spaceRight < width && spaceLeft > spaceRight) {
finalSide = "left";
} else if (side === "left" && spaceLeft < width && spaceRight > spaceLeft) {
finalSide = "right";
}
const across = finalSide === "top" || finalSide === "bottom";
const size = across ? Math.max(width, rect.width) : height;
const [from, to] = across ? [rect.left, rect.right] : [rect.top, rect.bottom];
const start =
align === "start"
? from
: align === "end"
? to - size
: (from + to - size) / 2;
const room = (across ? viewWidth : viewHeight) - gutter - size;
setFlipSide(finalSide);
setShift(Math.min(Math.max(start, gutter), Math.max(room, gutter)) - start);
setPlaced(true);
}, [open]);
useEffect(() => {
if (!open) return;
const onOutside = (ev: Event) => {
if (popover.current && !popover.current.contains(ev.target as Node)) setOpen(false);
};
const onEscape = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("click", onOutside);
document.addEventListener("keydown", onEscape);
document.addEventListener("focusin", onOutside);
return () => {
document.removeEventListener("click", onOutside);
document.removeEventListener("keydown", onEscape);
document.removeEventListener("focusin", onOutside);
};
}, [open]);
return (
<div
ref={popover}
className={`
${css.popover}
${css[`side_${flipSide}`]}
${css[`align_${align}`]}
${open ? css.open : ""}
${open && !placed ? css.measuring : ""}
`}
>
{isValidElement(trigger) &&
cloneElement(trigger, {
ref: triggerRef,
onClick: (ev: React.MouseEvent<HTMLElement>) => {
trigger.props.onClick?.(ev);
toggle();
},
"aria-expanded": open,
"aria-controls": contentId,
})}
<div
ref={content}
id={contentId}
className={css.popover_content}
style={{ "--popover-shift": `${shift}px` } as React.CSSProperties}
>
{typeof children === "function" ? children(close) : children}
</div>
</div>
);
}
.popover {
position: relative;
display: inline-block;
.popover_trigger {
display: inline-flex;
cursor: pointer;
}
.popover_content {
display: none;
flex-direction: column;
gap: var(--space-12);
position: absolute;
width: max-content;
max-width: calc(100vw - 2 * var(--page-gutter));
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
padding: var(--space-8);
/* Tight layer draws the edge, wide layer the lift; light-dark() takes
colours only, so the geometry is shared. */
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 10;
}
&.open .popover_content {
display: flex;
}
&.side_bottom .popover_content {
top: calc(100% + var(--space-12));
}
&.side_bottom.align_start .popover_content {
left: 0;
}
&.side_bottom.align_center .popover_content {
left: 50%;
transform: translateX(-50%);
}
&.side_bottom.align_end .popover_content {
right: 0;
}
&.side_top .popover_content {
bottom: calc(100% + var(--space-12));
}
&.side_top.align_start .popover_content {
left: 0;
}
&.side_top.align_center .popover_content {
left: 50%;
transform: translateX(-50%);
}
&.side_top.align_end .popover_content {
right: 0;
}
/* Never narrower than the trigger: the root is the trigger's size, so 100% is the trigger. */
&.side_bottom .popover_content,
&.side_top .popover_content {
min-width: 100%;
translate: var(--popover-shift) 0;
}
&.side_right .popover_content,
&.side_left .popover_content {
translate: 0 var(--popover-shift);
}
&.side_right .popover_content {
left: calc(100% + var(--space-12));
}
&.side_right.align_start .popover_content {
top: 0;
}
&.side_right.align_center .popover_content {
top: 50%;
transform: translateY(-50%);
}
&.side_right.align_end .popover_content {
bottom: 0;
}
&.side_left .popover_content {
right: calc(100% + var(--space-12));
}
&.side_left.align_start .popover_content {
top: 0;
}
&.side_left.align_center .popover_content {
top: 50%;
transform: translateY(-50%);
}
&.side_left.align_end .popover_content {
bottom: 0;
}
&.measuring .popover_content {
position: fixed;
min-width: 0;
translate: none;
visibility: hidden;
}
}
<script setup lang="ts">
import {
cloneVNode,
ref,
useId,
useSlots,
useTemplateRef,
watch,
watchEffect,
type ComponentPublicInstance,
type VNode,
} from "vue";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
interface PopoverProps {
side: Side;
align: Align;
}
const { side, align } = defineProps<PopoverProps>();
const slots = useSlots();
const popover = useTemplateRef<HTMLDivElement>("popover");
const content = useTemplateRef<HTMLDivElement>("content");
const triggerRef = ref<HTMLElement | ComponentPublicInstance | null>(null);
const contentId = useId();
const open = ref(false);
const flipSide = ref<Side>(side);
const shift = ref(0);
const placed = ref(false);
// A ref on a component vnode is its instance; $el reaches the real element whatever the slot
// brings.
const triggerEl = (): HTMLElement | null => {
const target = triggerRef.value;
if (target instanceof HTMLElement) return target;
return (target?.$el as HTMLElement) ?? null;
};
const toggle = () => {
open.value = !open.value;
};
watch(
open,
(isOpen) => {
if (!isOpen) {
placed.value = false;
return;
}
const el = triggerEl();
if (!el || !content.value) return;
const rect = el.getBoundingClientRect();
const { width, height } = content.value.getBoundingClientRect();
const { clientWidth: viewWidth, clientHeight: viewHeight } = document.documentElement;
const gutter = parseFloat(getComputedStyle(content.value).getPropertyValue("--page-gutter"));
const spaceAbove = rect.top;
const spaceBelow = viewHeight - rect.bottom;
const spaceLeft = rect.left;
const spaceRight = viewWidth - rect.right;
let finalSide = side;
if (side === "bottom" && spaceBelow < height && spaceAbove > spaceBelow) {
finalSide = "top";
} else if (side === "top" && spaceAbove < height && spaceBelow > spaceAbove) {
finalSide = "bottom";
} else if (side === "right" && spaceRight < width && spaceLeft > spaceRight) {
finalSide = "left";
} else if (side === "left" && spaceLeft < width && spaceRight > spaceLeft) {
finalSide = "right";
}
const across = finalSide === "top" || finalSide === "bottom";
const size = across ? Math.max(width, rect.width) : height;
const [from, to] = across ? [rect.left, rect.right] : [rect.top, rect.bottom];
const start =
align === "start"
? from
: align === "end"
? to - size
: (from + to - size) / 2;
const room = (across ? viewWidth : viewHeight) - gutter - size;
flipSide.value = finalSide;
shift.value = Math.min(Math.max(start, gutter), Math.max(room, gutter)) - start;
placed.value = true;
},
{ flush: "post" },
);
// Closing from inside (an item was chosen) hands focus back to the trigger:
// the content goes hidden, and a focused item would drop it to the body.
const close = () => {
if (content.value?.contains(document.activeElement)) {
triggerEl()?.focus();
}
open.value = false;
};
// Vue's cloneElement: the consumer hands its element and Popover wires it; the slot needs a
// single root.
const Trigger = () => {
const [node] = slots.trigger!() as VNode[];
const ownClick = node.props?.onClick as ((ev: MouseEvent) => void) | undefined;
// Cloned without onClick: mergeProps would pair both into an array, and a Button calls its
// onClick as a function.
const clone = cloneVNode(node, {
ref: triggerRef,
"aria-expanded": open.value,
"aria-controls": contentId,
});
clone.props!.onClick = (ev: MouseEvent) => {
ownClick?.(ev);
toggle();
};
return clone;
};
watchEffect(
(onCleanup) => {
if (!open.value) return;
const onOutside = (ev: Event) => {
if (popover.value && !popover.value.contains(ev.target as Node)) open.value = false;
};
const onEscape = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
open.value = false;
triggerEl()?.focus();
}
};
document.addEventListener("click", onOutside);
document.addEventListener("keydown", onEscape);
document.addEventListener("focusin", onOutside);
onCleanup(() => {
document.removeEventListener("click", onOutside);
document.removeEventListener("keydown", onEscape);
document.removeEventListener("focusin", onOutside);
});
},
{ flush: "post" },
);
</script>
<template>
<div
ref="popover"
class="popover"
:class="[`side_${flipSide}`, `align_${align}`, { open, measuring: open && !placed }]"
>
<Trigger />
<div
ref="content"
:id="contentId"
class="popover_content"
:style="{ '--popover-shift': `${shift}px` }"
>
<slot :close="close"></slot>
</div>
</div>
</template>
<style scoped>
.popover {
position: relative;
display: inline-block;
.popover_trigger {
display: inline-flex;
cursor: pointer;
}
.popover_content {
display: none;
flex-direction: column;
gap: var(--space-12);
position: absolute;
width: max-content;
max-width: calc(100vw - 2 * var(--page-gutter));
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
padding: var(--space-8);
/* Tight layer draws the edge, wide layer the lift; light-dark() takes
colours only, so the geometry is shared. */
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 10;
}
&.open .popover_content {
display: flex;
}
&.side_bottom .popover_content {
top: calc(100% + var(--space-12));
}
&.side_bottom.align_start .popover_content {
left: 0;
}
&.side_bottom.align_center .popover_content {
left: 50%;
transform: translateX(-50%);
}
&.side_bottom.align_end .popover_content {
right: 0;
}
&.side_top .popover_content {
bottom: calc(100% + var(--space-12));
}
&.side_top.align_start .popover_content {
left: 0;
}
&.side_top.align_center .popover_content {
left: 50%;
transform: translateX(-50%);
}
&.side_top.align_end .popover_content {
right: 0;
}
/* Never narrower than the trigger: the root is the trigger's size, so 100% is the trigger. */
&.side_bottom .popover_content,
&.side_top .popover_content {
min-width: 100%;
translate: var(--popover-shift) 0;
}
&.side_right .popover_content,
&.side_left .popover_content {
translate: 0 var(--popover-shift);
}
&.side_right .popover_content {
left: calc(100% + var(--space-12));
}
&.side_right.align_start .popover_content {
top: 0;
}
&.side_right.align_center .popover_content {
top: 50%;
transform: translateY(-50%);
}
&.side_right.align_end .popover_content {
bottom: 0;
}
&.side_left .popover_content {
right: calc(100% + var(--space-12));
}
&.side_left.align_start .popover_content {
top: 0;
}
&.side_left.align_center .popover_content {
top: 50%;
transform: translateY(-50%);
}
&.side_left.align_end .popover_content {
bottom: 0;
}
&.measuring .popover_content {
position: fixed;
min-width: 0;
translate: none;
visibility: hidden;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| trigger | React.ReactElement | required | Trigger element; the Popover injects click, ref and aria (expanded, controls) into it. |
| side | "top" | "bottom" | "left" | "right" | required | Opening side; it flips only when space runs out. |
| align | "start" | "center" | "end" | required | Panel alignment relative to the trigger; if it would leave the screen, it slides in to stay --page-gutter from the edge. |
| children | React.ReactNode | ((close: () => void) => React.ReactNode) | required | Panel content. As a function it receives close() to dismiss on select. |