2026.05.27 リリースしました

確認する

Popover

Popoverは、ユーザーのアクションに応じて一時的にコンテンツを表示するコンポーネントです。
アイコンやボタンなどの要素をトリガーとして使用し、追加情報や操作オプションを提供します。

プロパティ

Popoverは、以下のプロパティをサポートしています。

名前 型 デフォルト値 説明
placement string top 表示する位置を指定します。top, bottom, left, right のいずれかを指定できます。
align string start 吹き出しの位置を指定できます。start, center, end のいずれかを指定できます。
isOpen boolean false 表示するかどうかを制御します。
hideArrow boolean false 矢印を非表示にするかどうかを指定します。
offset {x: number; y: number} {x: 0, y: 0} 表示位置を任意の方向にずらすための値です。
duration number 380 表示・非表示の速さを指定できます。
noFollow boolean false 追従させるかどうかを制御できます。

インストールの手順

以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。

modules/Popover.svelte
        <!--
@component
## 概要
- ユーザーのアクションに応じて一時的にコンテンツを表示するコンポーネントです

## 機能
- 指定した方向にポップオーバーを表示できる
- 指定した位置に吹き出しを移動できる
- 表示したポップオーバーを追従させるさせないを指定できる

## Props
- isOpen: ポップオーバーを表示するかどうか
- placement: ポップオーバーの表示方向
- align: 吹き出しの表示位置
- offset: ポップオーバー表示位置のオフセット
- noFollow: ポップオーバーを追従させるかどうか
- hideArrow: 吹き出しの矢印を非表示にするかどうか
- trigger: ポップオーバーを表示させるためのコンテンツ

## Usage
```svelte
  <Popover placement="top">
    <div class="w-45">
      <div>ここにコンテンツが入ります</div>
      <div>コンテンツ説明が入ります</div>
    </div>

    {#snippet trigger(toggle)}
      <Button size="large" onclick={toggle}>表示</Button>
    {/snippet}
  </Popover>
```
-->

<script module lang="ts">
  import type { ClassValue } from 'svelte/elements';
  import { cva, type VariantProps } from 'class-variance-authority';
  import { type Snippet, tick, untrack } from 'svelte';

  export const popoverVariants = cva('relative z-10 bg-surface border border-border rounded-lg shadow-lg', {
    variants: {
      /** どの位置にpopoverを表示するか */
      placement: {
        top: '',
        bottom: '',
        left: '',
        right: '',
      },
      /** どの位置に吹き出しを表示するか */
      align: {
        start: '',
        center: '',
        end: '',
      },
    },
    compoundVariants: [
      {
        placement: 'top',
        align: 'start',
        class: 'origin-bottom-left',
      },
      {
        placement: 'top',
        align: 'center',
        class: 'origin-[bottom_center]',
      },
      {
        placement: 'top',
        align: 'end',
        class: 'origin-bottom-right',
      },
      {
        placement: 'bottom',
        align: 'start',
        class: 'origin-top-left',
      },
      {
        placement: 'bottom',
        align: 'center',
        class: 'origin-[top_center]',
      },
      {
        placement: 'bottom',
        align: 'end',
        class: 'origin-top-right',
      },
      {
        placement: 'left',
        align: 'start',
        class: 'origin-[right_top]',
      },
      {
        placement: 'left',
        align: 'center',
        class: 'origin-[right_center]',
      },
      {
        placement: 'left',
        align: 'end',
        class: 'origin-[right_bottom]',
      },
      {
        placement: 'right',
        align: 'start',
        class: 'origin-[left_top]',
      },
      {
        placement: 'right',
        align: 'center',
        class: 'origin-[left_center]',
      },
      {
        placement: 'right',
        align: 'end',
        class: 'origin-[left_bottom]',
      },
    ],
    defaultVariants: {
      placement: 'top',
      align: 'start',
    },
  });

  export const arrowContainerVariants = cva('absolute z-10 size-3', {
    variants: {
      placement: {
        top: '',
        bottom: '',
        left: '',
        right: '',
      },
      align: {
        start: '',
        center: '',
        end: '',
      },
    },
    compoundVariants: [
      {
        placement: 'top',
        align: 'start',
        class: 'left-6 -bottom-1.5',
      },
      {
        placement: 'top',
        align: 'center',
        class: 'left-1/2 -bottom-1.5 -translate-x-1/2',
      },
      {
        placement: 'top',
        align: 'end',
        class: 'right-6 -bottom-1.5',
      },
      {
        placement: 'bottom',
        align: 'start',
        class: 'left-6 -top-1.5',
      },
      {
        placement: 'bottom',
        align: 'center',
        class: 'left-1/2 -top-1.5 -translate-x-1/2',
      },
      {
        placement: 'bottom',
        align: 'end',
        class: 'right-6 -top-1.5',
      },
      {
        placement: 'left',
        align: 'start',
        class: 'top-6 -right-1.5',
      },
      {
        placement: 'left',
        align: 'center',
        class: 'top-1/2 -right-1.5 -translate-y-1/2',
      },
      {
        placement: 'left',
        align: 'end',
        class: 'bottom-6 -right-1.5',
      },
      {
        placement: 'right',
        align: 'start',
        class: 'top-6 -left-1.5',
      },
      {
        placement: 'right',
        align: 'center',
        class: 'top-1/2 -left-1.5 -translate-y-1/2',
      },
      {
        placement: 'right',
        align: 'end',
        class: 'bottom-6 -left-1.5',
      },
    ],
    defaultVariants: {
      placement: 'top',
      align: 'start',
    },
  });
  export const arrowVariants = cva('size-3 bg-surface border-border origin-center', {
    variants: {
      placement: {
        top: 'border-b border-r rotate-45',
        bottom: 'border-r border-t -rotate-45',
        left: 'border-r border-t rotate-45',
        right: 'border-l border-t -rotate-45',
      },
      align: {
        start: '',
        center: '',
        end: '',
      },
    },
    defaultVariants: {
      placement: 'top',
      align: 'start',
    },
  });

  export type PopoverVariants = VariantProps<typeof popoverVariants>;

  export interface PopoverProps extends PopoverVariants {
    /** popoverを表示するか */
    isOpen?: boolean;
    /** popoverの表示位置のオフセット距離 */
    offset?: { x?: number; y?: number };
    /** popover表示/非表示の速さ */
    duration?: number;
    /** popoverを追従させるかどうか */
    noFollow?: boolean;
    /** 吹き出しの矢印を非表示にするかどうか */
    hideArrow?: boolean;
    /** popover の中身 */
    children: Snippet<[]>;
    /**
     * popover を開くための snippet。引数に toggle 関数を受け取ります。
     */
    triggerContent: Snippet<[() => void]>;
    class?: ClassValue;
  }

  /** 吹き出しの大きさ */
  const ARROW_PX = 12;
  /** ポップオーバーの端ギリギリに矢印が来ないようにするマージン */
  const EDGE_PAD = 12;
  /** 6rem 相当の距離(6 * 4px = 24px と仮定) */
  const BASE_ARROW_POSITION = 24;
</script>

<script lang="ts">
  import { scale } from 'svelte/transition';

  let { isOpen = $bindable(false), placement = 'top', align = 'start', offset = { x: 0, y: 0 }, duration = 300, noFollow = false, hideArrow = false, children, triggerContent, class: className }: PopoverProps = $props();

  /** popoverとtrigger要素のベースのオフセット距離 */
  const BASE_OFFSET = $derived(hideArrow === false ? 9 : 0);

  /** viewportサイズ */
  let vw = $state(0);
  let vh = $state(0);

  /** popover 座標 */
  let top = $state(0);
  let left = $state(0);

  /** popover 見切れ解消補正値 */
  let popoverAdjustmentTop = $state(0);
  let popoverAdjustmentLeft = $state(0);

  let displayPlacement = $derived(placement);

  let triggerElement = $state<HTMLElement>();
  let popoverElement = $state<HTMLElement>();

  let popoverWidth = $state(0);
  let popoverHeight = $state(0);

  let triggerWidth = $state(0);
  let triggerHeight = $state(0);

  let popoverVariantsClass = $derived(popoverVariants({ placement: displayPlacement, align }));
  let arrowContainerVariantsClass = $derived(arrowContainerVariants({ placement: displayPlacement, align }));
  let arrowVariantsClass = $derived(arrowVariants({ placement: displayPlacement, align }));

  let arrowAdjustmentX = $derived.by(() => {
    // 上下のときだけ横方向を調整
    if (displayPlacement !== 'top' && displayPlacement !== 'bottom') return 0;
    if (!popoverWidth) return 0;

    const base_left =
      align === 'start'
        ? BASE_ARROW_POSITION
        : align === 'center'
          ? popoverWidth / 2 - ARROW_PX / 2
          : popoverWidth - BASE_ARROW_POSITION - ARROW_PX;

    const lo = EDGE_PAD;
    const hi = popoverWidth - EDGE_PAD - ARROW_PX;

    const ideal_left = base_left - popoverAdjustmentLeft;

    const clamped_left = clamp(ideal_left, lo, hi);

    return Math.round(clamped_left - base_left);
  });

  let arrowAdjustmentY = $derived.by(() => {
    // 左右のときだけ縦方向を調整
    if (displayPlacement !== 'left' && displayPlacement !== 'right') return 0;
    if (!popoverHeight) return 0;

    const base_top =
      align === 'start'
        ? BASE_ARROW_POSITION
        : align === 'center'
          ? popoverHeight / 2 - ARROW_PX / 2
          : popoverHeight - BASE_ARROW_POSITION - ARROW_PX;

    const lo = EDGE_PAD;
    const hi = popoverHeight - EDGE_PAD - ARROW_PX;

    const ideal_top = base_top - popoverAdjustmentTop;
    const clamped_top = clamp(ideal_top, lo, hi);

    return Math.round(clamped_top - base_top);
  });

  // 座標更新
  $effect(() => {
    updatePopoverPosition();
  });

  // pinch でのリサイズ
  $effect(() => {
    window.visualViewport?.addEventListener('resize', updatePopoverPosition);
    return () => {
      window.visualViewport?.removeEventListener('resize', updatePopoverPosition);
    };
  });

  $effect(() => {
    if (noFollow) return;
    let raf_id: number | null = null;
    const update = () => {
      updatePopoverPosition();
      raf_id = requestAnimationFrame(update);
    };

    update();

    return () => {
      if (raf_id !== null) {
        cancelAnimationFrame(raf_id);
        raf_id = null;
      }
    };
  });

  /** popover を非表示にした時の初期化 */
  $effect(() => {
    if (!isOpen) {
      displayPlacement = placement;
      top = 0;
      left = 0;
      popoverAdjustmentLeft = 0;
      popoverAdjustmentTop = 0;
    }
  });

  function clamp(v: number, lo: number, hi: number) {
    return Math.min(Math.max(v, lo), hi);
  }

  function toggle() {
    isOpen = !isOpen;
  }

  /** ポップオーバーの座標を更新する */
  async function updatePopoverPosition() {
    if (!isOpen || !triggerElement || !popoverElement) return;
    const trigger_rect = triggerElement.getBoundingClientRect();

    // 本来の位置に表示する
    updatePopoverPositionByRect(placement, trigger_rect);

    await tick();
    requestAnimationFrame(() => {
      if (!popoverElement) return;
      // 反転のチェック
      adjustDisplayPlacement(popoverElement.getBoundingClientRect());
      if (displayPlacement !== placement) {
        updatePopoverPositionByRect(displayPlacement, trigger_rect);
      }
    });
    // 別軸のずらしのチェック
    adjustPopoverPosition(trigger_rect);
  }

  /** rect に応じてポップオーバーの座標を更新する関数 */
  function updatePopoverPositionByRect(placement: PopoverProps['placement'], rect: DOMRect) {
    if (!triggerElement || !popoverElement) return;

    // placement に基づく座標計算
    switch (placement) {
      case 'top':
        top = rect.top - popoverHeight - BASE_OFFSET + (offset.y ?? 0);
        break;
      case 'bottom':
        top = rect.bottom + BASE_OFFSET + (offset.y ?? 0);
        break;
      case 'left':
        left = rect.left - popoverWidth - BASE_OFFSET + (offset.x ?? 0);
        break;
      case 'right':
        left = rect.right + BASE_OFFSET + (offset.x ?? 0);
        break;
    }

    // align に基づく座標計算
    switch (align) {
      case 'start':
        if (placement === 'top' || placement === 'bottom') {
          left = rect.left + (offset.x ?? 0);
        }
        else {
          top = rect.top + (offset.y ?? 0);
        }
        break;
      case 'center':
        if (placement === 'top' || placement === 'bottom') {
          left = rect.left + rect.width / 2 - popoverWidth / 2 + (offset.x ?? 0);
        }
        else {
          top = rect.top + rect.height / 2 - popoverHeight / 2 + (offset.y ?? 0);
        }
        break;
      case 'end':
        if (placement === 'top' || placement === 'bottom') {
          left = rect.right - popoverWidth + (offset.x ?? 0);
        }
        else {
          top = rect.bottom - popoverHeight + (offset.y ?? 0);
        }
        break;
    }

    untrack(() => {
      // Safari 対応
      if (Math.abs((window.visualViewport?.width ?? 0) - window.innerWidth) < 1) {
        top += window.visualViewport?.offsetTop ?? 0;
        left += window.visualViewport?.offsetLeft ?? 0;
      }
    });
  }

  /**
   * 必要であれば displayPlacement を調整する
   * @param rect PopoverElement の DOMRect
   */
  function adjustDisplayPlacement(rect: DOMRect) {
    let display_placement = placement;

    switch (placement) {
      case 'top':
        // 上にはみ出ている
        if (rect.top < 0) {
          const bottom_space = vh - (rect.bottom + triggerHeight + BASE_OFFSET);
          // 下の方が領域が広い場合
          if (Math.abs(rect.top) < bottom_space) {
            display_placement = 'bottom';
          }
        }
        break;
      case 'bottom':
        if (rect.bottom > vh) {
          const top_space = rect.top - triggerHeight - BASE_OFFSET;
          if (Math.abs(rect.bottom - vh) < top_space) {
            display_placement = 'top';
          }
        }
        break;
      case 'left':
        if (rect.left < 0) {
          const right_space = vw - (rect.right + triggerWidth + BASE_OFFSET);
          if (Math.abs(rect.left) < right_space) {
            display_placement = 'right';
          }
        }
        break;
      case 'right':
        if (rect.right > vw) {
          const left_space = rect.left - triggerWidth - BASE_OFFSET;
          if (Math.abs(rect.right - vw) < left_space) {
            display_placement = 'left';
          }
        }
        break;
    }

    displayPlacement = display_placement;
  }

  /** popoverの別軸での見切れを補正する値を求める関数 */
  function adjustPopoverPosition(rect: DOMRect) {
    // trigger が画面内に見えているかどうか
    const is_trigger_visible = rect.bottom > 0 && rect.top < vh && rect.right > 0 && rect.left < vw;

    // trigger が画面内でかつ popover がplacementの別軸でみきれている場合に位置を補正する
    if (is_trigger_visible) {
      if (displayPlacement === 'top' || displayPlacement === 'bottom') {
        const overflow_left = -left;
        const overflow_right = left + popoverWidth - vw;
        popoverAdjustmentLeft = overflow_left > 0 ? overflow_left : overflow_right > 0 ? -overflow_right : 0;
        popoverAdjustmentTop = 0;
      }
      else {
        const overflow_top = -top;
        const overflow_bottom = top + popoverHeight - vh;
        popoverAdjustmentTop = overflow_top > 0 ? overflow_top : overflow_bottom > 0 ? -overflow_bottom : 0;
        popoverAdjustmentLeft = 0;
      }
    }
  }

  function close(e) {
    if (triggerElement?.contains(e.target)) return;
    isOpen = false;
  }
</script>

<svelte:window onclickcapture={close} bind:innerWidth={vw} bind:innerHeight={vh} />

<div class={className} data-rabee-ui="popover">
  <div bind:this={triggerElement} bind:clientHeight={triggerHeight} bind:clientWidth={triggerWidth}>
    {@render triggerContent(toggle)}
  </div>

  {#if isOpen}
    <div class="fixed top-0 left-0 z-50" style="transform: translate({left}px, {top}px);" bind:this={popoverElement} bind:clientHeight={popoverHeight} bind:clientWidth={popoverWidth}>
      <div style="transform: translate({popoverAdjustmentLeft}px, {popoverAdjustmentTop}px);">
        <div class={[popoverVariantsClass]} in:scale={{ duration, opacity: 0, start: 0.9 }} out:scale={{ duration, opacity: 0, start: 0.9 }}>
          {@render children?.()}
        </div>
        <!-- 吹き出し -->
        {#if hideArrow === false}
          <div class={arrowContainerVariantsClass} style="transform: translate({arrowAdjustmentX}px, {arrowAdjustmentY}px);">
            <div class={arrowVariantsClass} in:scale|global={{ duration, opacity: 0, start: 0.9 }} out:scale|global={{ duration, opacity: 0, start: 1 }}></div>
          </div>
        {/if}

      </div>
    </div>
  {/if}
</div>

      

使い方


サンプル

Default

Defaultでの表示です。

Placement / Align

PlacementとAlignの組み合わせによる表示例です。

Hide Arrow

矢印を非表示にすることができます。

No Follow

noFollow を指定すると、一度表示されたポップオーバーが対象要素に追従しなくなります。 そのため、スクロールしてもポップオーバーの表示位置は固定され、対象要素と一緒に移動しません。

スクロール追従が不要なケースや、パフォーマンスを重視したい場合に活用できます。

Offset

Offsetを指定することで、表示位置を調整することができます。

With DropdownMenu

DropdownMenuと組み合わせた例です。