HextaUI

Popover

トリガーにアンカーされたフローティングパネル。コンテンツに合わせてなめらかにサイズが変わり、トリガーの方向に従います。

import { IconAdjustmentsHorizontal } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

const field =
  "h-8 w-full min-w-0 rounded-md border bg-transparent px-2 text-sm outline-none focus-visible:outline-hidden focus-visible:ring-3 focus-visible:ring-focus-ring pointer-coarse:h-11 pointer-coarse:text-lg"

export function PopoverDemo() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline" />}>
        <IconAdjustmentsHorizontal />
        Dimensions
      </PopoverTrigger>
      <PopoverContent>
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>
            Set the dimensions for the layer.
          </PopoverDescription>
        </PopoverHeader>
        <div className="grid grid-cols-[5rem_minmax(0,1fr)] items-center gap-2">
          <label htmlFor="popover-width" className="text-sm">
            Width
          </label>
          <input id="popover-width" className={field} defaultValue="100%" />
          <label htmlFor="popover-height" className="text-sm">
            Height
          </label>
          <input id="popover-height" className={field} defaultValue="25px" />
        </div>
        <div className="flex justify-end gap-2">
          <PopoverClose render={<Button variant="ghost" size="sm" />}>
            Cancel
          </PopoverClose>
          <PopoverClose render={<Button size="sm" />}>Apply</PopoverClose>
        </div>
      </PopoverContent>
    </Popover>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/popover.json

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>
    Open
  </PopoverTrigger>
  <PopoverContent>
    <PopoverHeader>
      <PopoverTitle>Dimensions</PopoverTitle>
      <PopoverDescription>Set the dimensions for the layer.</PopoverDescription>
    </PopoverHeader>
  </PopoverContent>
</Popover>
Popover
├── PopoverTrigger
└── PopoverContent
    ├── PopoverHeader
    │   ├── PopoverTitle
    │   └── PopoverDescription
    └── PopoverClose

サイズが変わるコンテンツ

コンテンツが増減すると、ポップアップはジャンプせず高さをアニメーションさせます。入力のような連続的な変化は、遅れが出ないようコンテンツに直接追従します。

"use client"

import * as React from "react"
import { IconBell } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Skeleton } from "@/components/ui/skeleton"

export function PopoverResizing() {
  const [rows, setRows] = React.useState(1)
  const [loading, setLoading] = React.useState(false)
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)

  React.useEffect(() => () => clearTimeout(timer.current), [])

  return (
    <Popover
      onOpenChange={(open) => {
        if (open) {
          setRows(1)
          setLoading(true)
          clearTimeout(timer.current)
          timer.current = setTimeout(() => setLoading(false), 700)
        }
      }}
    >
      <PopoverTrigger render={<Button variant="outline" />}>
        <IconBell />
        Notifications
      </PopoverTrigger>
      <PopoverContent>
        <PopoverHeader>
          <PopoverTitle>Notifications</PopoverTitle>
          <PopoverDescription>
            The height animates as content loads and grows.
          </PopoverDescription>
        </PopoverHeader>
        {loading ? (
          <Skeleton className="h-10 w-full" />
        ) : (
          <ul className="flex flex-col gap-2">
            {Array.from({ length: rows }, (_, index) => (
              <li
                key={index}
                className="rounded-md bg-muted px-2.5 py-2 text-sm"
              >
                Deploy #{1200 + index} finished in {12 + index}s
              </li>
            ))}
          </ul>
        )}
        <div className="flex gap-2">
          <Button
            variant="outline"
            size="sm"
            disabled={loading}
            onClick={() => setRows(Math.min(rows + 2, 12))}
          >
            Load more
          </Button>
          <Button
            variant="ghost"
            size="sm"
            disabled={loading || rows === 1}
            onClick={() => setRows(1)}
          >
            Collapse
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  )
}

制御

自前の状態で制御するには、open と onOpenChange を渡します。第2引数は、trigger-press、outside-press、escape-key のように、変化した理由を伝えます。

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverControlled() {
  const [open, setOpen] = React.useState(false)
  const [reason, setReason] = React.useState("none")

  return (
    <div className="flex flex-col items-center gap-3">
      <div className="flex flex-wrap justify-center gap-2">
        <Popover
          open={open}
          onOpenChange={(next, details) => {
            setOpen(next)
            setReason(details.reason)
          }}
        >
          <PopoverTrigger render={<Button variant="outline" />}>
            Controlled
          </PopoverTrigger>
          <PopoverContent>
            <PopoverHeader>
              <PopoverTitle>Controlled</PopoverTitle>
              <PopoverDescription>
                The open state lives in the parent.
              </PopoverDescription>
            </PopoverHeader>
          </PopoverContent>
        </Popover>
        <Button variant="ghost" onClick={() => setOpen(!open)}>
          Toggle from outside
        </Button>
      </div>
      <p className="text-sm text-muted-foreground">
        Open: {String(open)} · Last reason: {reason}
      </p>
    </div>
  )
}

配置

side と align は希望する位置を設定します。空間がない場合、ポップアップは反対側に反転し、画面内に収まるようにずれて、端から8pxを保ちます。

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

const sides = ["top", "right", "bottom", "left"] as const
const aligns = ["start", "center", "end"] as const

export function PopoverPlacement() {
  return (
    <div className="flex flex-col items-center gap-3">
      <div className="flex flex-wrap justify-center gap-2">
        {sides.map((side) => (
          <Popover key={side}>
            <PopoverTrigger render={<Button variant="outline" size="sm" />}>
              {side}
            </PopoverTrigger>
            <PopoverContent side={side} className="w-48">
              <PopoverTitle>Side: {side}</PopoverTitle>
            </PopoverContent>
          </Popover>
        ))}
      </div>
      <div className="flex flex-wrap justify-center gap-2">
        {aligns.map((align) => (
          <Popover key={align}>
            <PopoverTrigger render={<Button variant="outline" size="sm" />}>
              Align {align}
            </PopoverTrigger>
            <PopoverContent align={align} className="w-64">
              <PopoverTitle>Align: {align}</PopoverTitle>
            </PopoverContent>
          </Popover>
        ))}
      </div>
    </div>
  )
}

ホバーで開く

プレビューカードにするには、トリガーに openOnHover を設定します。delay と closeDelay により、ポインターが通り過ぎるときのちらつきを防ぎます。

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverHover() {
  return (
    <Popover>
      <PopoverTrigger
        openOnHover
        delay={200}
        closeDelay={150}
        render={<Button variant="link" />}
      >
        @preetsuthar
      </PopoverTrigger>
      <PopoverContent align="start">
        <div className="flex items-start gap-3">
          <Avatar>
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
          <PopoverHeader>
            <PopoverTitle>Preet Suthar</PopoverTitle>
            <PopoverDescription>
              Building HextaUI. Opens on hover after 200ms and stays open while
              the pointer is inside.
            </PopoverDescription>
          </PopoverHeader>
        </div>
      </PopoverContent>
    </Popover>
  )
}

分離したトリガー

createPopoverHandle でハンドルを作ると、ツリー内のどこにある複数のトリガー間でも1つの popover を共有できます。各トリガーは payload を渡し、ポップアップは関数の子要素を通じてそれを描画します。

"use client"

import { Button } from "@/components/ui/button"
import {
  createPopoverHandle,
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

const people = createPopoverHandle<{ name: string; role: string }>()

export function PopoverDetached() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <PopoverTrigger
        handle={people}
        payload={{ name: "Ada Lovelace", role: "Analyst" }}
        render={<Button variant="outline" size="sm" />}
      >
        Ada
      </PopoverTrigger>
      <PopoverTrigger
        handle={people}
        payload={{
          name: "Grace Hopper",
          role: "Rear admiral and the person who popularised the term debugging",
        }}
        render={<Button variant="outline" size="sm" />}
      >
        Grace
      </PopoverTrigger>
      <PopoverTrigger
        handle={people}
        payload={{ name: "Alan Turing", role: "Mathematician" }}
        render={<Button variant="outline" size="sm" />}
      >
        Alan
      </PopoverTrigger>
      <Popover handle={people}>
        {({ payload }) => (
          <PopoverContent>
            <PopoverHeader>
              <PopoverTitle>{payload?.name}</PopoverTitle>
              <PopoverDescription>{payload?.role}</PopoverDescription>
            </PopoverHeader>
          </PopoverContent>
        )}
      </Popover>
    </div>
  )
}

カレンダー付き

独自のパディングを持つコンテンツに合わせるには、className="w-auto p-0" を使います。ポップアップは、月が変わるたびにカレンダーに追従します。

"use client"

import * as React from "react"
import { IconCalendar } from "@tabler/icons-react"
import { format } from "date-fns"

import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverCalendar() {
  const [date, setDate] = React.useState<Date>()
  const [open, setOpen] = React.useState(false)

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger render={<Button variant="outline" />}>
        <IconCalendar />
        {date ? format(date, "PPP") : "Pick a date"}
      </PopoverTrigger>
      <PopoverContent className="w-auto p-0" align="start">
        <Calendar
          mode="single"
          selected={date}
          onSelect={(next) => {
            setDate(next)
            setOpen(false)
          }}
          defaultMonth={date}
        />
      </PopoverContent>
    </Popover>
  )
}

入れ子

別の popover や sheet の内側にある popover は、親の上に重なります。子の内側でのクリックでは親は開いたままで、Escape は最上位のレイヤーだけを閉じます。

import { IconInfoCircle } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Sheet,
  SheetBody,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function PopoverNested() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          Popover in popover
        </PopoverTrigger>
        <PopoverContent>
          <PopoverHeader>
            <PopoverTitle>Parent</PopoverTitle>
            <PopoverDescription>
              Clicking inside the child keeps this one open.
            </PopoverDescription>
          </PopoverHeader>
          <Popover>
            <PopoverTrigger render={<Button variant="outline" size="sm" />}>
              <IconInfoCircle />
              More info
            </PopoverTrigger>
            <PopoverContent side="right" className="w-56">
              <PopoverTitle>Child</PopoverTitle>
              <PopoverClose render={<Button size="sm" variant="ghost" />}>
                Close child
              </PopoverClose>
            </PopoverContent>
          </Popover>
        </PopoverContent>
      </Popover>
      <Sheet>
        <SheetTrigger render={<Button variant="outline" />}>
          Popover in a sheet
        </SheetTrigger>
        <SheetContent>
          <SheetHeader>
            <SheetTitle>Sheet</SheetTitle>
            <SheetDescription>
              The popover layers above the sheet, and Escape closes only the
              popover.
            </SheetDescription>
          </SheetHeader>
          <SheetBody>
            <Popover>
              <PopoverTrigger render={<Button variant="outline" />}>
                Open popover
              </PopoverTrigger>
              <PopoverContent>
                <PopoverTitle>Inside a sheet</PopoverTitle>
              </PopoverContent>
            </Popover>
          </SheetBody>
        </SheetContent>
      </Sheet>
    </div>
  )
}

長いコンテンツ

区切りのないテキストはポップアップ内で折り返されます。コンテンツが利用可能な空間より高い場合は、画面からはみ出さず、ポップアップ内でスクロールします。

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverLongContent() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          Unbroken text
        </PopoverTrigger>
        <PopoverContent>
          <PopoverHeader>
            <PopoverTitle>
              Supercalifragilisticexpialidocious-project-archive-2026-final-v3
            </PopoverTitle>
            <PopoverDescription>
              https://example.com/a/really/long/url/without/any/spaces/at/all/in/it
              — مرحبا بالعالم — 日本語のテキスト 👩‍👩‍👧‍👦
            </PopoverDescription>
          </PopoverHeader>
        </PopoverContent>
      </Popover>
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          Taller than the screen
        </PopoverTrigger>
        <PopoverContent>
          <PopoverTitle>Changelog</PopoverTitle>
          {Array.from({ length: 40 }, (_, index) => (
            <p key={index} className="text-sm text-muted-foreground">
              v1.{40 - index}.0 — fixes and improvements
            </p>
          ))}
        </PopoverContent>
      </Popover>
    </div>
  )
}

modal を指定すると、ページのスクロールがロックされ、外側のクリックは popover を閉じるだけになります。フォーカスをトラップでき、タッチ操作のスクリーンリーダーが抜け出せるよう、内側に <PopoverClose /> を描画してください。

import { IconX } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverModal() {
  return (
    <Popover modal>
      <PopoverTrigger render={<Button variant="outline" />}>
        Modal
      </PopoverTrigger>
      <PopoverContent>
        <div className="flex items-start justify-between gap-2">
          <PopoverHeader>
            <PopoverTitle>Modal popover</PopoverTitle>
            <PopoverDescription>
              Page scroll is locked and outside clicks only dismiss.
            </PopoverDescription>
          </PopoverHeader>
          <PopoverClose
            aria-label="Close"
            render={<Button variant="ghost" size="icon-sm" />}
          >
            <IconX />
          </PopoverClose>
        </div>
      </PopoverContent>
    </Popover>
  )
}

無効

disabled のトリガーは popover を開きません。

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverDisabled() {
  return (
    <Popover>
      <PopoverTrigger disabled render={<Button variant="outline" />}>
        Disabled
      </PopoverTrigger>
      <PopoverContent>
        <PopoverTitle>Never shown</PopoverTitle>
      </PopoverContent>
    </Popover>
  )
}

右から左

ポップアップはポータルに描画されても、開いたトリガーの方向を引き継ぎます。inline-end のような論理的な側も一緒に反転します。

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverClose,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function PopoverRtl() {
  return (
    <div dir="rtl" className="flex flex-wrap justify-center gap-2">
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          الأبعاد
        </PopoverTrigger>
        <PopoverContent align="start">
          <PopoverHeader>
            <PopoverTitle>الأبعاد</PopoverTitle>
            <PopoverDescription>اضبط أبعاد الطبقة.</PopoverDescription>
          </PopoverHeader>
          <div className="flex justify-end">
            <PopoverClose render={<Button size="sm" />}>تطبيق</PopoverClose>
          </div>
        </PopoverContent>
      </Popover>
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          inline-end
        </PopoverTrigger>
        <PopoverContent side="inline-end" className="w-48">
          <PopoverTitle>يفتح نحو النهاية</PopoverTitle>
        </PopoverContent>
      </Popover>
    </div>
  )
}
キーアクション
EnterSpaceトリガー上では popover を開閉します。フォーカスはポップアップ内に移ります。
Tabポップアップの内容の中を移動します。モーダルでない popover からタブで外へ出ると閉じます。
Escpopover を閉じ、トリガーにフォーカスを戻します。
  • <PopoverTitle /> と <PopoverDescription /> は、スクリーンリーダー向けにポップアップのラベルと説明を提供します。ポップアップに1文より長い内容が入る場合は、必ずタイトルを含めてください。
  • 開くと最初のフォーカス可能な要素にフォーカスが移り、閉じるとトリガーに戻ります。これは initialFocus と finalFocus で変更できます。
  • 視差効果の軽減が有効な場合、ポップアップは拡大せずにフェードします。

Base UI の popover 上に構築されています。すべてのパーツは、ラップしているプリミティブの props を受け付けます。

プロパティ型デフォルト
defaultOpen
booleanfalse
open
boolean–
onOpenChangedetails.reason は、変更の原因を示します。
(open: boolean, details) => void–
onOpenChangeComplete開閉アニメーションの終了後に呼ばれます。
(open: boolean) => void–
modaltrue はページのスクロールと外部操作をロックします。trap-focus はフォーカスのトラップだけを行います。
boolean | "trap-focus"false
handle分離したトリガーを接続します。
PopoverHandle<Payload>–
children
ReactNode | ({ payload }) => ReactNode–
プロパティ型デフォルト
openOnHover
booleanfalse
delayホバーしてから開くまでのミリ秒。
number300
closeDelayホバーが終わってから閉じるまでのミリ秒。
number0
handle
PopoverHandle<Payload>–
payloadこのトリガーが開いたときに、ポップアップへ渡されます。
Payload–
disabled
booleanfalse
render
ReactElement | (props, state) => ReactElement<button>
属性説明
data-slot="popover-trigger"CSSでトリガーを指定します。
data-popup-openその popover が開いている間付きます。
data-pressedトリガーが押されている間存在します。
data-disabledトリガーが無効なときに付与されます。

ポータル、ポジショナー、ポップアップを1つのパーツで描画します。

プロパティ型デフォルト
side
"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end""bottom"
align
"start" | "center" | "end""center"
sideOffsetトリガーとポップアップの間隔。
number | (data) => number6
alignOffset
number | (data) => number0
collisionPaddingビューポートの端から保つ余白。
number | Rect8
collisionAvoidance空間が足りないときに、反転するか、ずらすか、どちらもしないか。
CollisionAvoidance–
collisionBoundary
Boundary–
anchorトリガー以外のものを基準に配置します。
Element | RefObject | VirtualElement | () => Element–
sticky
booleanfalse
positionMethod
"absolute" | "fixed""absolute"
initialFocuspopover が開いたときにフォーカスが移る先。
boolean | RefObject | (type) => HTMLElement | boolean–
finalFocuspopover が閉じたときにフォーカスが移る先。
boolean | RefObject | (type) => HTMLElement | boolean–
portalPropscontainer など、ポータル用の props。
PortalProps–
classNameポップアップはデフォルトで w-72 です。
string | (state) => string–
render
ReactElement | (props, state) => ReactElement<div>
属性説明
data-slot="popover-content"ポップアップ。
data-slot="popover-positioner"ポップアップの位置を決める要素。
data-openpopover が開いている間付きます。
data-starting-styleポップアップが表示アニメーション中に付きます。
data-ending-styleポップアップが非表示アニメーション中に付きます。
data-sideポップアップが最終的に配置された側。
data-alignポップアップが最終的に取ったアラインメント。
data-instant変化をアニメーションさせないときに付きます。
--transform-originポップアップがトリガーの位置から拡大する基点。
--available-widthトリガーとビューポートの端との間隔。
--available-heightトリガーとビューポートの端との間隔。ポップアップの最大の高さ。
--anchor-widthトリガーの幅。
--anchor-heightトリガーの高さ。

タイトルと説明を縦に並べるだけの <div>。

属性説明
data-slot="popover-header"CSSでヘッダーを指定します。
プロパティ型デフォルト
render
ReactElement | (props, state) => ReactElement<h2>
属性説明
data-slot="popover-title"ポップアップにラベルを付けます。
プロパティ型デフォルト
render
ReactElement | (props, state) => ReactElement<p>
属性説明
data-slot="popover-description"ポップアップを説明します。
プロパティ型デフォルト
render
ReactElement | (props, state) => ReactElement<button>
属性説明
data-slot="popover-close"押すと popover を閉じます。

createPopoverHandle<Payload>() は、別の場所にレンダリングされたトリガーと <Popover /> をつなぐハンドルを返します。コンポーネントの外で一度だけ作成してください。

使用しているブロック

Popover の上に構築されるブロック。