Popover
A floating panel anchored to a trigger, flipping when short on room.
A floating panel anchored to a trigger. Opening reveals the panel below the trigger — flipping above when there isn’t room below — and it dismisses on an outside tap or Escape while trapping focus. Behaviour is composed from the shared overlay primitive and the same anchored-position engine the Select reuses, so there is no bespoke positioning. iOS renders an opaque surface-container card with a soft shadow; Android renders an MD3 menu-surface with elevation.
Installation
npx touchcn add popover
Usage
<tcn-popover>
<span popover-trigger class="tcn-btn tcn-btn-secondary ...">Options</span>
<div class="flex flex-col">
<button type="button">Share</button>
<button type="button">Rename</button>
</div>
</tcn-popover>import { TcnPopover } from '@/components/ui/popover';
<TcnPopover trigger={<span className="tcn-btn tcn-btn-secondary ...">Options</span>}>
<div className="flex flex-col">
<button type="button">Share</button>
<button type="button">Rename</button>
</div>
</TcnPopover><script setup lang="ts">
import { TcnPopover } from '@/components/ui/popover';
</script>
<template>
<TcnPopover>
<template #trigger>
<span class="tcn-btn tcn-btn-secondary ...">Options</span>
</template>
<div class="flex flex-col">
<button type="button">Share</button>
<button type="button">Rename</button>
</div>
</TcnPopover>
</template>Placement is limited to below/above with an automatic flip — no full placement matrix. On compact widths a bottom Action Sheet is often the better pattern than a popover; reach for that when the panel is large or the viewport is narrow.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Open state (two-way in Angular; controlled in React). |
disabled |
boolean |
false |
Disable the trigger. |
trigger (React) |
ReactNode |
— | Content rendered inside the trigger button. |
[popover-trigger] (Angular) |
slot | — | Content projected into the trigger button. |
Source · Angular
export * from './tcn-popover';import { booleanAttribute, Component, computed, input, model } from '@angular/core';
import { TcnOverlayDirective } from '@touchcn/angular/overlay';
import { TcnAnchoredPositionDirective } from '@touchcn/angular/positioning';
/**
* Popover — a floating panel anchored to a trigger. Opening reveals the panel
* below the trigger (flipping above when short on room); it dismisses on an
* outside tap or Escape and traps focus while open. Behaviour is composed from
* the shared overlay primitive (focus trap, scroll lock, Escape, backdrop
* dismissal) and the shared anchored-position primitive (the below-vs-above
* flip the Select reuses too) — no bespoke positioning.
*
* The trigger content projects into `[popover-trigger]`; the default content is
* the panel. On compact widths a bottom ActionSheet is often the better pattern
* than a popover — reach for that when the panel is large or the viewport is
* narrow. iOS renders an opaque surface-container card with a soft shadow; MD3
* renders a menu-surface with elevation.
*/
@Component({
selector: 'tcn-popover',
imports: [TcnOverlayDirective, TcnAnchoredPositionDirective],
template: `
<button
#trigger
type="button"
class="tcn-popover-trigger"
[attr.aria-expanded]="open()"
aria-haspopup="dialog"
[disabled]="disabled()"
(click)="toggle()"
>
<ng-content select="[popover-trigger]" />
</button>
<div class="tcn-popover-overlay" [class.pointer-events-none]="!open()" [attr.data-state]="state()">
<div class="tcn-overlay-backdrop tcn-popover-backdrop fixed inset-0 z-40" (click)="overlay.dismiss()"></div>
<div
#overlay="tcnOverlay"
tcnOverlay
tcnAnchoredPosition
[(open)]="open"
[trigger]="trigger"
class="tcn-overlay-panel tcn-popover-panel z-50"
>
<ng-content />
</div>
</div>
`,
host: { class: 'tcn-popover relative inline-block' },
})
export class TcnPopover {
readonly open = model(false);
readonly disabled = input(false, { transform: booleanAttribute });
protected readonly state = computed(() => (this.open() ? 'open' : 'closed'));
protected toggle(): void {
if (!this.disabled()) {
this.open.update((open) => !open);
}
}
}Source · React
export * from './tcn-popover';import { useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useMenuPosition, useOverlayPresence, usePopoverDismiss } from '@touchcn/react';
export interface TcnPopoverProps {
/** The trigger content (rendered inside the popover's trigger button). */
trigger: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
children?: ReactNode;
}
/**
* Popover — a floating panel anchored to a trigger. Opening reveals the panel
* below the trigger (flipping above when short on room); it dismisses on an
* outside tap or Escape and moves focus into the panel while open. Composed
* from the engine overlay/presence primitives plus the shared `useMenuPosition`
* flip (which the Select reuses too) — no bespoke positioning — keeping the
* emitted `tcn-*` classes and `data-*` attributes identical to the Angular
* component.
*
* Uncontrolled by default; pass `open` + `onOpenChange` to control it. On
* compact widths a bottom ActionSheet is often the better pattern than a
* popover. iOS renders an opaque surface-container card with a soft shadow; MD3
* renders a menu-surface with elevation.
*/
export function TcnPopover({ trigger, open: openProp, onOpenChange, disabled, children }: TcnPopoverProps) {
const [uncontrolled, setUncontrolled] = useState(false);
const open = openProp ?? uncontrolled;
const setOpen = (next: boolean) => {
setUncontrolled(next);
onOpenChange?.(next);
};
const triggerRef = useRef<HTMLButtonElement>(null);
const { present, active, panelRef } = useOverlayPresence(open);
const placement = useMenuPosition(triggerRef, active);
const close = () => setOpen(false);
usePopoverDismiss(panelRef, active, close);
return (
<div className="tcn-popover relative inline-block">
<button
ref={triggerRef}
type="button"
className="tcn-popover-trigger"
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={() => setOpen(!open)}
>
{trigger}
</button>
{present && (
<div className="tcn-popover-overlay" data-state={active ? 'open' : 'closed'}>
<div className="tcn-overlay-backdrop tcn-popover-backdrop fixed inset-0 z-40" onClick={close} />
<div
ref={panelRef}
role="dialog"
tabIndex={-1}
data-placement={placement}
className="tcn-overlay-panel tcn-popover-panel z-50"
>
{children}
</div>
</div>
)}
</div>
);
}Source · Vue
<script setup lang="ts">
import { ref } from 'vue';
import { useMenuPosition, useOverlayPresence, usePopoverDismiss } from '@touchcn/vue';
/**
* Popover — a floating panel anchored to a trigger. Opening reveals the panel
* below the trigger (flipping above when short on room); it dismisses on an
* outside tap or Escape and moves focus into the panel while open. Composed
* from the engine overlay/presence primitives plus the shared `useMenuPosition`
* flip (which the Select reuses too) — no bespoke positioning — keeping the
* emitted `tcn-*` classes and `data-*` attributes identical across frameworks.
*
* Uncontrolled by default; bind `v-model:open` to control it. On compact widths
* a bottom ActionSheet is often the better pattern than a popover. iOS renders
* an opaque surface-container card with a soft shadow; MD3 renders a
* menu-surface with elevation. The `trigger` slot fills the trigger button; the
* default slot is the panel content.
*/
defineProps<{ disabled?: boolean }>();
const open = defineModel<boolean>('open', { default: false });
const triggerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const { present, active, setPanel } = useOverlayPresence(open);
const placement = useMenuPosition(triggerRef, active);
const close = (): void => {
open.value = false;
};
usePopoverDismiss(panelRef, active, close);
const setPanelBoth = (el: unknown): void => {
const node = el instanceof HTMLElement ? el : null;
setPanel(node);
panelRef.value = node;
};
</script>
<template>
<div class="tcn-popover relative inline-block">
<button
ref="triggerRef"
type="button"
class="tcn-popover-trigger"
:aria-expanded="open"
aria-haspopup="dialog"
:disabled="disabled"
@click="open = !open"
>
<slot name="trigger" />
</button>
<div v-if="present" class="tcn-popover-overlay" :data-state="active ? 'open' : 'closed'">
<div class="tcn-overlay-backdrop tcn-popover-backdrop fixed inset-0 z-40" @click="close" />
<div
:ref="setPanelBoth"
role="dialog"
:tabindex="-1"
:data-placement="placement"
class="tcn-overlay-panel tcn-popover-panel z-50"
>
<slot />
</div>
</div>
</div>
</template>export { default as TcnPopover } from './TcnPopover.vue';