All patterns

Popover Anchor Flip

A popover opens below its trigger, or flips above when space runs out — the arrow follows.

navigationelegantsubtleinteraction · finite · intermediate · ~0.2s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

403 lines · react + motion only
import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Popover Anchor Flip
 *
 * A row menu opens below the control that summoned it — unless the frame
 * runs out of room, in which case it flips above and the arrow moves with
 * it. The panel always grows out of the corner nearest its trigger, so
 * the eye never loses the thread back to what was clicked.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color and the floating panel
 * uses the CSS system colors, so both read correctly on a light page and
 * on a dark one.
 * Works with zero props; tune via `variant`, `preferred`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PopoverAnchorFlipProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Side the popover uses when there is room for it. */
  preferred?: "below" | "above";
  /** Notified with the side the popover actually landed on. */
  onPlacement?: (placement: "below" | "above") => void;
};

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  /** px the panel travels out of its anchor edge. */
  travel: number;
  /** How much of full size the panel starts at. */
  scaleFrom: number;
  fadeSeconds: number;
};

// Quality rule: a popover lands under the pointer and is read
// immediately, so it may not still be moving when the eye arrives. Every
// spring sits at or above a 0.8 damping ratio (ζ = damping / 2√stiffness)
// and the scale never dips far enough to make the labels look resized.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.00 — arrives flat, and barely moves to get there. For dense
  // tables where this opens all day.
  subtle: {
    spring: { type: "spring", stiffness: 700, damping: 53 },
    travel: 2,
    scaleFrom: 0.99,
    fadeSeconds: 0.09,
  },
  // ζ ≈ 0.93 — a single soft settle. The all-purpose setting.
  default: {
    spring: { type: "spring", stiffness: 420, damping: 38 },
    travel: 7,
    scaleFrom: 0.97,
    fadeSeconds: 0.14,
  },
  // ζ ≈ 0.90 — well clear of the anchor and a longer glide into place.
  // Still one settle; the extra energy is all travel and time.
  playful: {
    spring: { type: "spring", stiffness: 240, damping: 28 },
    travel: 16,
    scaleFrom: 0.92,
    fadeSeconds: 0.24,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Space the panel needs on a side before it will commit to it. */
const PANEL_HEIGHT = 126;
const GAP = 8;

const DOCUMENTS = [
  { id: "roadmap", name: "Q3 roadmap", meta: "Edited 2h ago · Priya" },
  { id: "brand", name: "Brand guidelines", meta: "Edited yesterday · Marco" },
  { id: "release", name: "Release notes v4.2", meta: "Edited Monday · Dana" },
  { id: "pricing", name: "Pricing experiments", meta: "Edited Aug 4 · Aiko" },
] as const;

const ACTIONS = [
  {
    label: "Rename",
    path: "M3.5 12.8 12 4.3l3.2 3.2-8.5 8.5H3.5z",
  },
  {
    label: "Duplicate",
    path: "M7 3.5h9.5v9.5M3.5 7h9.5v9.5H3.5z",
  },
  {
    label: "Move to archive",
    path: "M3.2 5.5h13.6v3H3.2zM4.6 8.5h10.8v7H4.6zM8 11.5h4",
  },
] as const;

export default function PopoverAnchorFlip({
  variant = "default",
  preferred = "below",
  onPlacement,
}: PopoverAnchorFlipProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const uid = useId();
  const frameRef = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useState<{
    id: string;
    placement: "below" | "above";
  } | null>(null);

  useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(null);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  const toggle = (id: string, trigger: HTMLElement) => {
    if (open?.id === id) {
      setOpen(null);
      return;
    }
    // The flip decision, and the whole pattern: measure the room left on
    // the preferred side, and only cross over when the other side is
    // genuinely roomier. Measured against the frame below because this
    // popover is scoped to a card; for an app-level popover compare
    // against `window.innerHeight` (and render into a portal) instead.
    let placement = preferred;
    const frame = frameRef.current?.getBoundingClientRect();
    if (frame) {
      const anchor = trigger.getBoundingClientRect();
      const roomBelow = frame.bottom - anchor.bottom;
      const roomAbove = anchor.top - frame.top;
      const need = PANEL_HEIGHT + GAP;
      if (preferred === "below" && roomBelow < need && roomAbove > roomBelow) {
        placement = "above";
      } else if (preferred === "above" && roomAbove < need && roomBelow > roomAbove) {
        placement = "below";
      }
    }
    setOpen({ id, placement });
    onPlacement?.(placement);
  };

  return (
    <div
      ref={frameRef}
      style={{
        position: "relative",
        width: 320,
        height: 292,
        padding: "12px 10px",
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          padding: "0 6px 8px",
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>Documents</span>
        <span style={{ fontSize: 11, opacity: 0.45 }}>{DOCUMENTS.length} files</span>
      </div>

      {/* Click-away layer: mounted only while something is open, so the
          rows stay clickable the rest of the time. */}
      {open && (
        <div
          aria-hidden
          onClick={() => setOpen(null)}
          style={{ position: "absolute", inset: 0, zIndex: 1 }}
        />
      )}

      {DOCUMENTS.map((doc) => {
        const isOpen = open?.id === doc.id;
        const placement = open?.placement ?? preferred;
        const above = isOpen && placement === "above";

        return (
          <div
            key={doc.id}
            style={{
              position: "relative",
              // The open row rides above the click-away layer so its
              // trigger and panel stay live.
              zIndex: isOpen ? 2 : 0,
              display: "flex",
              alignItems: "center",
              gap: 10,
              padding: "9px 6px 9px 8px",
              borderRadius: 12,
              background: isOpen ? tone(8) : "transparent",
            }}
          >
            <span
              aria-hidden
              style={{
                flexShrink: 0,
                width: 30,
                height: 30,
                borderRadius: 9,
                // Stands in for a file thumbnail, not a UI surface, so it
                // keeps its own colors in both themes.
                background: "linear-gradient(140deg, #5C6BC8 0%, #6FA6B8 100%)",
              }}
            />
            <span style={{ flex: 1, minWidth: 0 }}>
              <span
                style={{
                  display: "block",
                  fontSize: 12.5,
                  fontWeight: 600,
                  whiteSpace: "nowrap",
                  overflow: "hidden",
                  textOverflow: "ellipsis",
                }}
              >
                {doc.name}
              </span>
              <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
                {doc.meta}
              </span>
            </span>

            <span style={{ position: "relative", flexShrink: 0 }}>
              <button
                type="button"
                aria-haspopup="menu"
                aria-expanded={isOpen}
                aria-label={`Actions for ${doc.name}`}
                onClick={(event) => toggle(doc.id, event.currentTarget)}
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 26,
                  height: 26,
                  borderRadius: 8,
                  border: 0,
                  background: isOpen ? tone(14) : "transparent",
                  color: "inherit",
                  cursor: "pointer",
                }}
              >
                <svg width="15" height="15" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
                  <circle cx="10" cy="4.5" r="1.5" />
                  <circle cx="10" cy="10" r="1.5" />
                  <circle cx="10" cy="15.5" r="1.5" />
                </svg>
              </button>

              <AnimatePresence>
                {isOpen && (
                  <motion.div
                    key="panel"
                    role="menu"
                    aria-label={`Actions for ${doc.name}`}
                    id={`${uid}-menu`}
                    // Reduced motion: the panel is placed rather than
                    // grown — the flip still happens, it just doesn't
                    // travel to announce itself.
                    initial={
                      reduceMotion
                        ? { opacity: 0 }
                        : {
                            opacity: 0,
                            scale: cfg.scaleFrom,
                            y: above ? cfg.travel : -cfg.travel,
                          }
                    }
                    animate={{ opacity: 1, scale: 1, y: 0 }}
                    exit={
                      reduceMotion
                        ? { opacity: 0, transition: { duration: 0.1 } }
                        : {
                            opacity: 0,
                            scale: cfg.scaleFrom,
                            y: above ? cfg.travel * 0.5 : -cfg.travel * 0.5,
                            transition: { duration: 0.12, ease: "easeIn" },
                          }
                    }
                    transition={{
                      default: cfg.spring,
                      opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
                    }}
                    style={{
                      position: "absolute",
                      zIndex: 3,
                      right: -2,
                      top: above ? "auto" : `calc(100% + ${GAP}px)`,
                      bottom: above ? `calc(100% + ${GAP}px)` : "auto",
                      // Growing out of the corner nearest the trigger is
                      // what ties the panel to the control that opened it
                      // — and it is the half of the flip people notice
                      // without being able to name.
                      transformOrigin: above ? "bottom right" : "top right",
                      width: 176,
                      padding: 5,
                      borderRadius: 13,
                      // A popover floats over the content it acts on, so
                      // it needs opaque ground rather than a tinted one.
                      // `Canvas`/`CanvasText` are the CSS system colors
                      // for page background and page text: they follow
                      // the host app's light or dark surface with no
                      // configuration, and every tone() inside the panel
                      // is then mixed from CanvasText.
                      background: "Canvas",
                      color: "CanvasText",
                      border: `1px solid ${tone(14)}`,
                      boxShadow: "0 16px 38px rgba(0,0,0,0.24)",
                    }}
                  >
                    {/* The arrow is part of the panel, so it flips with
                        it for free. Drawn as an open path: the fill
                        closes the triangle while the stroke covers only
                        the two slanted edges, hiding the seam where it
                        meets the panel border. */}
                    <svg
                      width="16"
                      height="8"
                      viewBox="0 0 16 8"
                      aria-hidden
                      style={{
                        position: "absolute",
                        right: 12,
                        top: above ? "auto" : -7,
                        bottom: above ? -7 : "auto",
                        transform: above ? "rotate(180deg)" : "none",
                      }}
                    >
                      <path
                        d="M0 7.6 8 1 16 7.6"
                        fill="Canvas"
                        stroke={tone(14)}
                        strokeWidth="1"
                        strokeLinejoin="round"
                      />
                    </svg>

                    {ACTIONS.map((action) => (
                      <button
                        key={action.label}
                        type="button"
                        role="menuitem"
                        onClick={() => setOpen(null)}
                        style={{
                          display: "flex",
                          alignItems: "center",
                          gap: 9,
                          width: "100%",
                          padding: "8px 9px",
                          borderRadius: 9,
                          border: 0,
                          background: "transparent",
                          color: "inherit",
                          fontSize: 12.5,
                          fontWeight: 550,
                          fontFamily: "inherit",
                          textAlign: "left",
                          cursor: "pointer",
                        }}
                      >
                        <svg
                          width="15"
                          height="15"
                          viewBox="0 0 20 20"
                          fill="none"
                          stroke={ACCENT}
                          strokeWidth="1.5"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          aria-hidden
                        >
                          <path d={action.path} />
                        </svg>
                        {action.label}
                      </button>
                    ))}
                  </motion.div>
                )}
              </AnimatePresence>
            </span>
          </div>
        );
      })}
    </div>
  );
}

About this pattern

Row actions, overflow menus and any panel that belongs to one control. The motion is two decisions made together: measure the room left below the trigger and cross to the other side only when it is genuinely roomier, then grow the panel out of the corner nearest that trigger so the tie back to what was clicked is never broken. The arrow is drawn inside the panel, so it flips with it rather than being repositioned separately. Springs stay near critical damping because a menu is read the instant it lands, and the scale starts high enough that the labels never look resized on the way in.

Row overflow menuAccount menuFilter panel on a toolbarInline actions in a long feed

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Sidebar navigation

    A menu grows from the corner nearest its button and changes side close to a screen edge.

Related patterns