Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

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';

Last updated on July 24, 2026

Was this page helpful?