---
title: "Popover コンポーネント — zenit Zig UI"
description: "Tooltip より豊かな一時的コンテンツを表示します。click / hover trigger、位置計算、外側クリック、Escape、portal のレイヤーを組み合わせ…"
url: https://zenit.z.express/ja/components/popover
language: ja
alternate_en: https://zenit.z.express/components/popover.md
alternate_zh: https://zenit.z.express/zh/components/popover.md
alternate_es: https://zenit.z.express/es/components/popover.md
alternate_ko: https://zenit.z.express/ko/components/popover.md
alternate_fr: https://zenit.z.express/fr/components/popover.md
alternate_de: https://zenit.z.express/de/components/popover.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# Popover

`ui.widgets.Popover`

Tooltip より豊かな一時的コンテンツを表示します。

[Video](https://zenit.z.express/media/stories/popover.mp4?v=ca5f935b3a)

**BEHAVIOR CONTRACT**

click / hover trigger、位置計算、外側クリック、Escape、portal のレイヤーを組み合わせられます。

**RECORDED INTERACTION**

harness が Toggle Popover を開き、内容を表示します。

`examples/storybook/stories.zig:2549`

```zig
// ── Popover ──
pub fn buildPopover(scope: *ui.Scope, cx: *ui.Cx) anyerror!*ui.Node {
    const a = cx.allocator;
    const c = try col(cx, 12);
    try c.appendChild(a, try label(cx, "Click the button to toggle the popover"));
    const pop = try W.Popover(.{ .position = .bottom_start, .trigger = .click }).mount(scope, cx);
    try pop.trigger.appendChild(a, try W.Button(.{ .label = "Toggle Popover" }).mount(scope, cx));
    try pop.content.appendChild(a, try ui.text(cx, "Popover content!", .{ .font_size = 13, .color = light.color.fg_primary }));
    try c.appendChild(a, pop.wrapper);

    // ── 其它位置 + hover 触发 ──
    try c.appendChild(a, try label(cx, "Positions (top / right) + hover trigger"));
    const grid = try ui.box(cx, .{ .direction = .row, .gap = 24, .align_items = .center }, .{});

    const ptop = try W.Popover(.{ .position = .top, .trigger = .click }).mount(scope, cx);
    try popSetup(a, cx, scope, ptop, "Top", "Opens above");
    try grid.appendChild(a, ptop.wrapper);

    const pright = try W.Popover(.{ .position = .right, .trigger = .click }).mount(scope, cx);
    try popSetup(a, cx, scope, pright, "Right", "Opens to the right");
    try grid.appendChild(a, pright.wrapper);

    const phover = try W.Popover(.{ .position = .bottom, .trigger = .hover }).mount(scope, cx);
    try popSetup(a, cx, scope, phover, "Hover me", "Hover-triggered");
    try grid.appendChild(a, phover.wrapper);

    try c.appendChild(a, grid);

    // ── 块类型下拉（与编辑器选区工具条的 block type 菜单同构）──
    // 回归锚（e2e "popover: block dropdown shadow"）：白底 + 圆角 10 + 1px 描边 +
    // 双层阴影（contact 0/1/3 + ambient 0/8/28）+ fade_fast。阴影必须完整柔和地
    // 落在面板四周，圆角外不得出现被矩形裁出来的灰块。
    try c.appendChild(a, try label(cx, "Block dropdown (editor toolbar shape): shadow must stay soft around rounded corners"));
    const pblock = try W.Popover(.{
        .position = .bottom_start,
        .trigger = .click,
        .width = 168,
        .offset = .{ .static = 6 },
        .enter_transition = .fade_fast,
        .exit_transition = .fade_fast,
        .viewport_padding = 12,
        .shift_main_axis = true,
        .prewarm_hidden_layout = false,
        .detach_hidden_content = true,
    }).mount(scope, cx);
    pblock.trigger.meta.ownership.meta.test_id = "story.popover.block.trigger";
    pblock.chrome.meta.ownership.meta.test_id = "story.popover.block.content";
    try pblock.trigger.appendChild(a, try W.Button(.{ .label = "Block Menu", .variant = .secondary }).mount(scope, cx));
    {
        const panel = pblock.chrome;
        panel.style.direction = .column;
        panel.style.align_items = .stretch;
        panel.style.gap = 0;
        panel.style.padding = ui.Padding.symmetric(4, 4);
        panel.style.height = .{ .fit = .{} };
        panel.setBackground(ui.Color.rgba(255, 255, 255, 255));
        panel.style.border = .{ .radius = 10, .width = 1, .color = ui.Color.rgba(224, 224, 228, 255) };
        panel.style.overflow_hidden = false;
        const ext = panel.style.ensureExtPanic(cx.allocator);
        ext.z_index = 82; // 下游编辑器浮层同款：覆盖 popover 分配的层级
        ext.setShadows(
            .{ .color = ui.Color.rgba(0, 0, 0, 15), .blur = 3, .offset_y = 1 },
            .{ .color = ui.Color.rgba(0, 0, 0, 36), .blur = 28, .offset_y = 8 },
        );
        const rows = [_][]const u8{ "Text", "Heading 1", "Heading 2", "Heading 3", "Quote", "Bullet", "Task", "Code block" };
        for (rows) |row_label| {
            const menu_row = try ui.box(cx, .{
                .width = .{ .grow = .{} },
                .height = .{ .px = 28 },
                .direction = .row,
                .align_items = .center,
                .padding = ui.Padding.symmetric(0, 8),
                .border = .{ .radius = 6 },
            }, .{});
            try menu_row.appendChild(a, try ui.text(cx, row_label, .{ .font_size = 13, .color = light.color.fg_primary }));
            try panel.appendChild(a, menu_row);
        }
    }
    try c.appendChild(a, pblock.wrapper);

    // ── 对照：静态（非合成层）overflow_hidden 圆角卡片 + 阴影 + 溢出蓝块 ──
    // 与 tall popover 同一组视觉合同，但不经过 composited surface：
    // 阴影不被自身 clip 裁、蓝块被圆角内沿裁、描边不被内容盖住。
    try c.appendChild(a, try label(cx, "Static overflow_hidden card (reference): shadow outside, content clipped inside the border"));
    const card = try ui.box(cx, .{
        .direction = .column,
        .padding = ui.Padding.all(8),
        .width = .{ .px = 220 },
        .height = .{ .px = 80 },
        .background = light.color.bg_secondary,
        .border = .{ .radius = 10, .width = 1, .color = light.color.border },
        .overflow_hidden = true,
    }, .{});
    card.meta.ownership.meta.test_id = "story.popover.static.card";
    {
        const ext = card.style.ensureExtPanic(cx.allocator);
        ext.clip_shape = .{ .rounded_rect = 10 };
        ext.setShadows(
            .{ .color = ui.Color.rgba(0, 0, 0, 40), .blur = 12, .offset_y = 4 },
            .{ .color = ui.Color.rgba(0, 0, 0, 18), .blur = 40, .offset_y = 16 },
        );
    }
    // 竖向渐变：漏出卡片下沿的是哪一段一眼可辨（纯色看不出溢出/滚动位置）
    try card.appendChild(a, try storyGradientSlab(cx, 204, 400));
    try c.appendChild(a, card);

    // ── 超高内容：两侧都放不下 → autosize 把 max_height 收紧到可用高度 ──
    // 回归锚（e2e "popover: tall content"）：修前面板保持完整高度、best-fit 只挪
    // translate，下缘越过 trigger 把 reference element 盖住。
    try c.appendChild(a, try label(cx, "Tall content: panel must shrink to the viewport, never cover its trigger"));
    // fit_or_scroll：autosize 把 max_height 收到可用高度后，超出部分在面板内纵向滚动
    const ptall = try W.Popover(.{
        .position = .bottom_start,
        .trigger = .click,
        .size_policy = .fit_or_scroll,
        .max_width = 236,
        .max_height = 2000,
    }).mount(scope, cx);
    ptall.trigger.meta.ownership.meta.test_id = "story.popover.tall.trigger";
    // fit_or_scroll 下 content 是 ScrollArea 内容节点、chrome 才是面板外壳
    ptall.chrome.meta.ownership.meta.test_id = "story.popover.tall.content";
    ptall.content.meta.ownership.meta.test_id = "story.popover.tall.scroll_content";
    try ptall.trigger.appendChild(a, try W.Button(.{ .label = "Tall Popover", .variant = .secondary }).mount(scope, cx));
    const tall = try ui.box(cx, .{ .direction = .column, .gap = 6, .padding = ui.Padding.all(8), .width = .{ .px = 220 }, .height = .{ .fit = .{} } }, .{});
    try tall.appendChild(a, try ui.text(cx, "Top of tall content", .{ .font_size = 13, .color = light.color.fg_primary }));
    // 1400px 渐变块：任何合理窗口高度都放不下；渐变让滚动位置可见
    try tall.appendChild(a, try storyGradientSlab(cx, 204, 1400));
    try tall.appendChild(a, try ui.text(cx, "Bottom of tall content", .{ .font_size = 13, .color = light.color.fg_primary }));
    try ptall.content.appendChild(a, tall);
    try c.appendChild(a, ptall.wrapper);
    return c;
}
```

[GitHub でソースを見るexamples/storybook/stories.zig:2549](https://github.com/version-next/zenit/blob/HEAD/examples/storybook/stories.zig#L2549)

Storybook にあるこの story のコードそのままです（zenit 5f9add5+wip 2026-09-30）。`W` は `ui.widgets` のことで、`col` / `row` / `label` は Storybook 内の小さなレイアウトヘルパーです。

### PopoverProps

`ui.widgets.Popover`

[src/ui/components/popover/mod.zig:115](https://github.com/version-next/zenit/blob/HEAD/src/ui/components/popover/mod.zig#L115)

```zig
fn Popover(props: PopoverProps) PopoverBuilder
```

| フィールド | 型 | デフォルト値 | 説明 |
| --- | --- | --- | --- |
| `position` | `PopoverPosition` `.top` `.top_start` `.top_end` `.bottom` `.bottom_start` `.bottom_end` `.left` `.left_start` `.left_end` `.right` `.right_start` `.right_end` | `.bottom_start` | 位置 |
| `trigger` | `PopoverTrigger` `.click` `.hover` `.manual` | `.click` | 触发方式 |
| `offset` | `PopoverOffset` | `.{ .static = 4 }` | 与触发元素的 main-axis 间距 —— 支持静态 / derivable（按 placement 动态算）。 默认静态 4 px。Caller 写： \`.offset = .{ .static = 8 }\` 或 \`.offset = .{ .derive = .{ .ctx = ..., .compute = computeFn } }\` |
| `visible` | `?*Signal(bool)` | `null` | 外部控制显隐 Signal |
| `close_on_outside_click` | `bool` | `true` | 点击外部关闭 |
| `consume_outside_click` | `bool` | `false` | 点击外部关闭时吞掉这一下（不同时触发点中的东西）。需 close\_on\_outside\_click。 |
| `close_on_escape` | `bool` | `true` | Escape 关闭 |
| `width` | `?f32` | `null` | 内容宽度 |
| `max_width` | `?f32` | `null` | 内容最大宽度（auto-fit 时常用，超出后由内部内容自行 wrap） |
| `match_trigger_width` | `bool` | `false` | 打开时令浮层宽度跟随 trigger 宽度 |
| `constrain_width_to_viewport` | `bool` | `false` | 允许固定/匹配宽度在视口内收缩 |
| `max_height` | `?f32` | `null` | 内容最大高度 |
| `viewport_padding` | `f32` | `8` | 视口约束时的安全边距 |
| `flip` | `bool` | `true` | 自动翻转：当首选 placement 溢出时自动选择最佳位置（参考 floating-ui flip） |
| `shift_main_axis` | `bool` | `false` | 主轴方向也启用 shift 推回 viewport（默认 false 仅 cross-axis 推回）。 启用时即使 popover 没空间放下，也会被推回 viewport 内（可能 overlap anchor）， 比飞屏外更友好。典型场景：completion docs 在小窗口下放不进 fallback placements 时。 |
| `fallback_placements` | `[]const PopoverPosition` | `&.{}` | 可选的 fallback placement 列表；非空时按顺序尝试 \[position, ...fallback\_placements\]， 挑第一个不溢出的。空时走经典 2-way flip（position ↔ opposite）。 示例（docs aside panel）：\`.fallback\_placements = &.{.bottom\_start, .top\_start}\` 配合 \`.position = .right\_start\` → 右侧 → 下方 → 上方，\*\*永不 flip 到左\*\*。 |
| `fallback_placements_ptr` | `?*[]const PopoverPosition` | `null` | 可选：指向 caller 拥有的 mutable slice 的指针，\*\*每帧被 popover 读\*\*来决定 fallback 列表。 若非 null，覆盖 \`fallback\_placements\` 字段。用于"根据其他 popover 的 active\_position 动态切换自己的 fallback"场景（e.g. docs panel 要贴 main popup 所在的那一侧）。 |
| `open_delay_ms` | `f32` | `0` | 入场延迟（ms）：打开后先不可见持有这么久再播 enter\_transition； 期间关闭则直接消失。tooltip display delay 用。 |
| `enter_transition` | `overlay_stack_mod.Transition` | `.scale_fade` | 入场动画 |
| `tier` | `?overlay_stack_mod.StackTier` | `null` | 语义 z-index tier；null = overlay（Tooltip 传 .tooltip）。见 StackTier。 |
| `exit_transition` | `overlay_stack_mod.Transition` | `.scale_fade` | 退场动画 |
| `prewarm_hidden_layout` | `bool` | `false` | 关闭时是否保留可测量隐藏布局，用于首帧预热 |
| `detach_hidden_content` | `bool` | `true` | 关闭且退出动画完成后，把浮层内容从 wrapper 子树摘下。 内容节点本身会保留，重新打开时再挂回；这样未显示内容不出现在 root box tree。 |
| `a11y_role` | `?core.A11yRole` | `null` | 无障碍角色（null = 不设 role，由上层组件覆盖） |
| `anchor` | `?Anchor` | `null` | 自定义锚点：null 时锚点 = 内部 trigger\_node（默认行为） 提供 anchor 时定位读 anchor，trigger\_node 仍存在但不参与位置计算 （依然是 dismiss outside-click 判定的"trigger"——避免点击 trigger 触发 dismiss）。 trigger=.manual + visible signal 控制下，virtual anchor 是最常用的组合。 |
| `size_policy` | `SizePolicy` `.hard_clip` `.fit_content` `.fit_or_scroll` | `.hard_clip` | 尺寸策略（见 SizePolicy 文档）。默认 .hard\_clip 保持向后兼容； .fit\_or\_scroll 仍在调试（fit 父里 ScrollArea grow 退化导致内容塌缩）。 |
| `clip_subtree_to_chrome` | `bool` | `false` | visible 时是否保留 chrome.style.overflow\_hidden=true（让 chrome 边界裁切子节点 raster）。 默认 false（旧行为：visible 时无条件关 overflow\_hidden）。 配合 surface owner self-clip 路径（compositor\_plan apply\_clip op）+ 修复后的嵌套 surface offset 使用。 适用：caller 子节点带 background 且需要被 chrome 圆角真正裁切（如 completion popup hint\_bar）。 |
| `avoid_rects_provider` | `?*const fn (cx: *Cx, buf: []floating.Rect) []const floating.Rect` | `null` | 避让矩形提供者：popoverBeforeRender 每帧调用，返回的 rect 列表作为 excluded\_rects 喂给 floating-ui 的 flip/shift/autosize middleware，让该 popover 的 placement 选择 避开这些 rect（视为"墙"）。典型用法：signature popup 需要避让 completion chrome 和 docs chrome 的当前位置。null 表示不避让任何 rect（单 popover 独立计算）。 buf 由 caller 提供（栈上数组即可），provider 写入后返回 slice。 |

### PopoverResult

`ui.widgets.Popover`

[src/ui/components/popover/mod.zig:201](https://github.com/version-next/zenit/blob/HEAD/src/ui/components/popover/mod.zig#L201)

| フィールド | 型 | デフォルト値 | 説明 |
| --- | --- | --- | --- |
| `wrapper` | `*Node` | — | — |
| `trigger` | `*Node` | — | — |
| `content` | `*Node` | — | caller 把内容 appendChild 到这里。 - hard\_clip / fit\_content: == chrome - fit\_or\_scroll: == ScrollArea content（chrome 是另一节点，由 popover 自己管 overflow） |
| `chrome` | `*Node` | — | caller 用来配 popover panel 的 background / border / shadow / panel padding。 大多数模式下 == content；fit\_or\_scroll 模式下 = popover 外壳节点。 |
| `is_open` | `*Signal(bool)` | — | — |
| `active_position_ptr` | `*const PopoverPosition` | — | 指向内部 ctx.active\_position 的指针：可供 caller \*\*每帧读取\*\* popover 实际落位。 用于"根据另一 popover 的 active\_position 决定自己的 fallback\_placements"场景。 |
| `portaled` | `bool` | — | 向后兼容字段，现在恒为 false。wrapper 只承载 trigger，永远由 caller 挂到正常文档树；floating content 则独立挂到 window portal。 |
