# Popover

A floating panel anchored to a trigger, for supplementary content and lightweight controls.

Popover is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/popover

**React**

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export default function PopoverDemo() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline" />}>
        Open popover
      </PopoverTrigger>
      <PopoverContent>
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>
            Set the dimensions for the layer.
          </PopoverDescription>
        </PopoverHeader>
        <div className="grid gap-3">
          <div className="grid grid-cols-3 items-center gap-3">
            <Label htmlFor="popover-width">Width</Label>
            <Input
              id="popover-width"
              defaultValue="100%"
              className="col-span-2"
            />
          </div>
          <div className="grid grid-cols-3 items-center gap-3">
            <Label htmlFor="popover-height">Height</Label>
            <Input
              id="popover-height"
              defaultValue="25px"
              className="col-span-2"
            />
          </div>
        </div>
      </PopoverContent>
    </Popover>
  )
}
```

Popover wraps the popover primitive of the chosen base. `Popover` is the root that holds the open state, `PopoverTrigger` toggles it, and `PopoverContent` renders the positioned panel in a portal. Inside the content, `PopoverHeader` groups a `PopoverTitle` and `PopoverDescription` above any other content, such as form fields.

Use a popover for interactive content that relates to its trigger. Use Tooltip for short, non-interactive hints, and Dialog when the task needs the user's full attention.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/popover
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add popover
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react
```

2. Copy the source into `components/ui/popover.tsx`:

```tsx title="components/ui/popover.tsx"
"use client"

import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import type * as React from "react"
import { cn } from "@/lib/utils"

function Popover({ ...props }: PopoverPrimitive.Root.Props) {
  return <PopoverPrimitive.Root data-slot="popover" {...props} />
}

function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}

function PopoverContent({
  className,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  ...props
}: PopoverPrimitive.Popup.Props &
  Pick<
    PopoverPrimitive.Positioner.Props,
    "align" | "alignOffset" | "side" | "sideOffset"
  >) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        className="isolate z-50"
      >
        <PopoverPrimitive.Popup
          data-slot="popover-content"
          className={cn(
            "z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-2xl bg-popover p-4 text-base text-popover-foreground shadow-lg ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
            className
          )}
          {...props}
        />
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  )
}

function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="popover-header"
      className={cn("flex flex-col gap-1 text-sm", className)}
      {...props}
    />
  )
}

function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
  return (
    <PopoverPrimitive.Title
      data-slot="popover-title"
      className={cn("text-base font-semibold", className)}
      {...props}
    />
  )
}

function PopoverDescription({
  className,
  ...props
}: PopoverPrimitive.Description.Props) {
  return (
    <PopoverPrimitive.Description
      data-slot="popover-description"
      className={cn("text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
}
```

3. Update the import paths to match your project.

## Usage

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

```tsx
<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>
    Open popover
  </PopoverTrigger>
  <PopoverContent>
    <PopoverHeader>
      <PopoverTitle>Dimensions</PopoverTitle>
      <PopoverDescription>
        Set the dimensions for the layer.
      </PopoverDescription>
    </PopoverHeader>
    <div className="grid gap-3">
      <div className="grid grid-cols-3 items-center gap-3">
        <Label htmlFor="popover-width">Width</Label>
        <Input
          id="popover-width"
          defaultValue="100%"
          className="col-span-2"
        />
      </div>
      <div className="grid grid-cols-3 items-center gap-3">
        <Label htmlFor="popover-height">Height</Label>
        <Input
          id="popover-height"
          defaultValue="25px"
          className="col-span-2"
        />
      </div>
    </div>
  </PopoverContent>
</Popover>
```

The popover is uncontrolled by default; use `defaultOpen`, or the open state with `onOpenChange` to control it. `PopoverContent` accepts the positioning props `side`, `sideOffset`, `align` and `alignOffset`, defaulting to `bottom`, `4`, `center` and `0`.

Use the trigger's `render` prop to render it as another component, such as a `Button`.

## Examples

### Sides

Four popovers that open on the top, right, bottom and left of their triggers.

**Example**

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

export default function PopoverSides() {
  return (
    <div className="grid grid-cols-2 gap-3">
      {(["top", "right", "bottom", "left"] as const).map((side) => (
        <Popover key={side}>
          <PopoverTrigger render={<Button variant="outline" />}>
            {side.charAt(0).toUpperCase() + side.slice(1)}
          </PopoverTrigger>
          <PopoverContent side={side} className="w-56">
            <PopoverHeader>
              <PopoverTitle>Heads up</PopoverTitle>
              <PopoverDescription>
                This popover opens on the {side} side.
              </PopoverDescription>
            </PopoverHeader>
          </PopoverContent>
        </Popover>
      ))}
    </div>
  )
}
```

### Simple

A plain text popover opened from a ghost button, without a header.

**Example**

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

export default function PopoverSimple() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="ghost" />}>
        What is this?
      </PopoverTrigger>
      <PopoverContent className="w-64">
        <p>
          Shared links stay active for 30 days and can be revoked at any time
          from settings.
        </p>
      </PopoverContent>
    </Popover>
  )
}
```

## API Reference

- `Popover`
- `PopoverTrigger`
- `PopoverContent`
  - `PopoverHeader`
    - `PopoverTitle`
    - `PopoverDescription`

### Popover

The root that manages the popover's open state.

Also accepts the props of the primitive part it wraps.

### PopoverTrigger

The button that opens and closes the popover.

Also accepts the props of the primitive part it wraps.

### PopoverContent

The positioned panel that renders in a portal.

Also accepts the props of the primitive parts it wraps.

### PopoverHeader

Groups the title and description at the top of the panel.

Also accepts the props of `React.ComponentProps<"div">`.

### PopoverTitle

The heading that names the popover.

Also accepts the props of the primitive part it wraps.

### PopoverDescription

Secondary text that describes the popover.

Also accepts the props of the primitive part it wraps.

## Accessibility

- The panel has the `dialog` role. `PopoverTitle` provides its accessible name and `PopoverDescription` its accessible description.
- The trigger exposes `aria-expanded`.
- Escape closes the popover and returns focus to the trigger.
- Clicking outside the panel, or clicking the trigger again, closes it.

## Notes

- Styling hooks: `data-slot` values `popover`, `popover-trigger`, `popover-content`, `popover-header`, `popover-title` and `popover-description`.
- The panel is `w-72` by default; override it with `className`.
- The panel animates in from the side it opens on.
- Uses the `popover`, `popover-foreground` and `muted-foreground` tokens.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-popover--open-by-default.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { Text } from "@/components/ui/text"

const dimensions = [
  { id: "width", label: "Width", value: "100%" },
  { id: "max-width", label: "Max. width", value: "300px" },
  { id: "height", label: "Height", value: "25px" },
]

export function PopoverOpenByDefaultExample() {
  return (
    <View className="items-start">
      <Popover defaultOpen>
        <PopoverTrigger render={<Button variant="outline" />}>
          <Text>Open popover</Text>
        </PopoverTrigger>
        <PopoverContent align="start" className="w-80">
          <View className="gap-4">
            <PopoverHeader>
              <PopoverTitle>Dimensions</PopoverTitle>
              <PopoverDescription>
                Set the dimensions for the layer.
              </PopoverDescription>
            </PopoverHeader>
            <View className="gap-2">
              {dimensions.map((dimension) => (
                <View
                  key={dimension.id}
                  className="flex-row items-center gap-4"
                >
                  <Label nativeID={`popover-${dimension.id}`} className="w-24">
                    {dimension.label}
                  </Label>
                  <Input
                    accessibilityLabelledBy={`popover-${dimension.id}`}
                    defaultValue={dimension.value}
                    className="h-8 flex-1"
                  />
                </View>
              ))}
            </View>
          </View>
        </PopoverContent>
      </Popover>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PopoverOpenByDefaultExample: View {
  var body: some View {
    PopoverDimensions(open: true)
  }
}

struct PopoverDimensions: View {
  @State private var open: Bool

  init(open: Bool) {
    _open = State(initialValue: open)
  }

  var body: some View {
    Button("Open popover") { open = true }
      .buttonStyle(.variant(.outline))
      .popover(isPresented: $open) {
        PopoverContent(width: 320) {
          PopoverHeader {
            PopoverTitle("Dimensions")
            PopoverDescription("Set the dimensions for the layer.")
          }
          VStack(spacing: 8) {
            PopoverDimensionRow(label: "Width", value: "100%")
            PopoverDimensionRow(label: "Max. width", value: "300px")
            PopoverDimensionRow(label: "Height", value: "25px")
          }
        }
      }
  }
}

struct PopoverDimensionRow: View {
  let label: String

  @State private var value: String

  init(label: String, value: String) {
    self.label = label
    _value = State(initialValue: value)
  }

  var body: some View {
    HStack(spacing: 16) {
      FieldLabel(label)
        .frame(width: 96, alignment: .leading)
        .accessibilityHidden(true)
      TextField(label, text: $value)
        .textFieldStyle(.input(size: .sm))
        .accessibilityLabel(Text(label))
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Popover
import com.esuiss.ui.components.PopupAlign

@Composable
fun PopoverOpenByDefaultExample() {
    PopoverDimensions(defaultOpen = true)
}

@Composable
fun PopoverDimensions(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        Popover(
            open = open,
            onOpenChange = { open = it },
            trigger = { Button("Open popover", onClick = { open = true }, variant = ButtonVariant.Outline) },
            modifier = Modifier.width(320.dp),
            align = PopupAlign.Start,
        ) {
            PopoverDimensionsContent()
        }
    }
}
```

`Popover` positions a panel next to its `PopoverTrigger`. `PopoverContent` renders in a portal, is placed on any `side` with an `align` and offsets, and animates in with a short fade, scale and slide using Reanimated. On iOS the content is wrapped in a `FullWindowOverlay` from `react-native-screens`, so it draws above native modals.

Use `PopoverHeader`, `PopoverTitle` and `PopoverDescription` to label the panel. For short hints, use Tooltip instead.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/popover
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add popover
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add popover --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add popover --platform kotlin
```

The CLI also adds what it builds on: [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-screens react-native-worklets
```

2. Add the pieces it builds on: [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/popover.tsx`:

```tsx title="uniwind/components/ui/popover.tsx"
import { cn } from "cn"
import * as React from "react"
import { Platform, View, type ViewProps } from "react-native"
import { Keyframe, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PopoverPrimitive from "@/components/primitives/popover"
import type { Side } from "@/components/primitives/types"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { TextClassContext } from "@/components/ui/text"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const ENTER_FROM: Record<Side, { translateX: number; translateY: number }> = {
  top: { translateX: 0, translateY: 8 },
  bottom: { translateX: 0, translateY: -8 },
  left: { translateX: 8, translateY: 0 },
  right: { translateX: -8, translateY: 0 },
}

function createEntering(side: Side) {
  const from = ENTER_FROM[side]
  return new Keyframe({
    0: {
      opacity: 0,
      transform: [
        { translateX: from.translateX },
        { translateY: from.translateY },
        { scale: 0.95 },
      ],
    },
    100: {
      opacity: 1,
      transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }],
    },
  })
    .duration(150)
    .reduceMotion(ReduceMotion.System)
}

const ENTERING: Record<Side, ReturnType<typeof createEntering>> = {
  top: createEntering("top"),
  bottom: createEntering("bottom"),
  left: createEntering("left"),
  right: createEntering("right"),
}

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.95 }] },
})
  .duration(100)
  .reduceMotion(ReduceMotion.System)
function PopoverContent({
  className,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  portalHost,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Popup> &
  Pick<
    React.ComponentProps<typeof PopoverPrimitive.Positioner>,
    "align" | "alignOffset" | "side" | "sideOffset"
  > & {
    portalHost?: string
  }) {
  return (
    <PopoverPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <PopoverPrimitive.Positioner
          align={align}
          alignOffset={alignOffset}
          side={side}
          sideOffset={sideOffset}
          className="z-50"
        >
          <TextClassContext.Provider value="text-base text-foreground">
            <PopoverPrimitive.Popup
              render={
                <AnimatedView entering={ENTERING[side]} exiting={EXITING} />
              }
              className={(state) =>
                cn(
                  "z-50 w-72 flex-col gap-4 rounded-2xl border border-foreground/5 bg-popover p-4 shadow-lg dark:border-foreground/10",
                  resolveClassName(className, state)
                )
              }
              {...props}
            />
          </TextClassContext.Provider>
        </PopoverPrimitive.Positioner>
      </FullWindowOverlay>
    </PopoverPrimitive.Portal>
  )
}

function PopoverHeader({ className, ...props }: ViewProps) {
  return (
    <TextClassContext.Provider value="text-sm text-foreground">
      <View className={cn("flex-col gap-1", className)} {...props} />
    </TextClassContext.Provider>
  )
}

function PopoverTitle({
  className,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Title>) {
  return (
    <PopoverPrimitive.Title
      className={(state) =>
        cn(
          "text-base font-semibold text-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function PopoverDescription({
  className,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Description>) {
  return (
    <PopoverPrimitive.Description
      className={(state) =>
        cn("text-sm text-muted-foreground", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-screens react-native-worklets
```

2. Add the pieces it builds on: [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/popover.tsx`:

```tsx title="nativewind/components/ui/popover.tsx"
import { cn } from "cn"
import * as React from "react"
import { Platform, View, type ViewProps } from "react-native"
import { Keyframe, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PopoverPrimitive from "@/components/primitives/popover"
import type { Side } from "@/components/primitives/types"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { TextClassContext } from "@/components/ui/text"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const ENTER_FROM: Record<Side, { translateX: number; translateY: number }> = {
  top: { translateX: 0, translateY: 8 },
  bottom: { translateX: 0, translateY: -8 },
  left: { translateX: 8, translateY: 0 },
  right: { translateX: -8, translateY: 0 },
}

function createEntering(side: Side) {
  const from = ENTER_FROM[side]
  return new Keyframe({
    0: {
      opacity: 0,
      transform: [
        { translateX: from.translateX },
        { translateY: from.translateY },
        { scale: 0.95 },
      ],
    },
    100: {
      opacity: 1,
      transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }],
    },
  })
    .duration(150)
    .reduceMotion(ReduceMotion.System)
}

const ENTERING: Record<Side, ReturnType<typeof createEntering>> = {
  top: createEntering("top"),
  bottom: createEntering("bottom"),
  left: createEntering("left"),
  right: createEntering("right"),
}

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.95 }] },
})
  .duration(100)
  .reduceMotion(ReduceMotion.System)
function PopoverContent({
  className,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  portalHost,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Popup> &
  Pick<
    React.ComponentProps<typeof PopoverPrimitive.Positioner>,
    "align" | "alignOffset" | "side" | "sideOffset"
  > & {
    portalHost?: string
  }) {
  return (
    <PopoverPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <PopoverPrimitive.Positioner
          align={align}
          alignOffset={alignOffset}
          side={side}
          sideOffset={sideOffset}
          className="z-50"
        >
          <TextClassContext.Provider value="text-base text-foreground">
            <PopoverPrimitive.Popup
              render={
                <AnimatedView entering={ENTERING[side]} exiting={EXITING} />
              }
              className={(state) =>
                cn(
                  "z-50 w-72 flex-col gap-4 rounded-2xl border border-foreground-5 bg-popover p-4 shadow-lg dark:border-foreground-10",
                  resolveClassName(className, state)
                )
              }
              {...props}
            />
          </TextClassContext.Provider>
        </PopoverPrimitive.Positioner>
      </FullWindowOverlay>
    </PopoverPrimitive.Portal>
  )
}

function PopoverHeader({ className, ...props }: ViewProps) {
  return (
    <TextClassContext.Provider value="text-sm text-foreground">
      <View className={cn("flex-col gap-1", className)} {...props} />
    </TextClassContext.Provider>
  )
}

function PopoverTitle({
  className,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Title>) {
  return (
    <PopoverPrimitive.Title
      className={(state) =>
        cn(
          "text-base font-semibold text-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function PopoverDescription({
  className,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Description>) {
  return (
    <PopoverPrimitive.Description
      className={(state) =>
        cn("text-sm text-muted-foreground", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-screens react-native-worklets
```

2. Add the pieces it builds on: [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/popover.tsx`:

```tsx title="stylesheet/components/ui/popover.tsx"
import * as React from "react"
import { Platform, StyleSheet, View, type ViewProps } from "react-native"
import { Keyframe, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PopoverPrimitive from "@/components/primitives/popover"
import type { Side } from "@/components/primitives/types"
import { AnimatedView } from "@/components/ui/animated-view"
import { TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const ENTER_FROM: Record<Side, { translateX: number; translateY: number }> = {
  top: { translateX: 0, translateY: 8 },
  bottom: { translateX: 0, translateY: -8 },
  left: { translateX: 8, translateY: 0 },
  right: { translateX: -8, translateY: 0 },
}

function createEntering(side: Side) {
  const from = ENTER_FROM[side]
  return new Keyframe({
    0: {
      opacity: 0,
      transform: [
        { translateX: from.translateX },
        { translateY: from.translateY },
        { scale: 0.95 },
      ],
    },
    100: {
      opacity: 1,
      transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }],
    },
  })
    .duration(150)
    .reduceMotion(ReduceMotion.System)
}

const ENTERING: Record<Side, ReturnType<typeof createEntering>> = {
  top: createEntering("top"),
  bottom: createEntering("bottom"),
  left: createEntering("left"),
  right: createEntering("right"),
}

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.95 }] },
})
  .duration(100)
  .reduceMotion(ReduceMotion.System)
const styles = StyleSheet.create({
  positioner: { zIndex: 50 },
  content: {
    zIndex: 50,
    width: 288,
    flexDirection: "column",
    gap: 16,
    borderRadius: radius["2xl"],
    borderWidth: 1,
    padding: 16,
    ...shadow.lg,
  },
  header: { flexDirection: "column", gap: 4 },
  title: { ...fontSize.base, fontWeight: "600" },
  description: { ...fontSize.sm },
})

function withAlpha(hex: string, alpha: number) {
  const value = Number.parseInt(hex.slice(1), 16)
  return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`
}

function PopoverContent({
  style,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  portalHost,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Popup> &
  Pick<
    React.ComponentProps<typeof PopoverPrimitive.Positioner>,
    "align" | "alignOffset" | "side" | "sideOffset"
  > & {
    portalHost?: string
  }) {
  const { colors, isDark } = useTheme()
  return (
    <PopoverPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <PopoverPrimitive.Positioner
          align={align}
          alignOffset={alignOffset}
          side={side}
          sideOffset={sideOffset}
          style={styles.positioner}
        >
          <TextStyleContext.Provider
            value={{ ...fontSize.base, color: colors.foreground }}
          >
            <PopoverPrimitive.Popup
              render={
                <AnimatedView entering={ENTERING[side]} exiting={EXITING} />
              }
              style={(state) => [
                styles.content,
                {
                  borderColor: withAlpha(
                    colors.foreground,
                    isDark ? 0.1 : 0.05
                  ),
                  backgroundColor: colors.popover,
                },
                typeof style === "function" ? style(state) : style,
              ]}
              {...props}
            />
          </TextStyleContext.Provider>
        </PopoverPrimitive.Positioner>
      </FullWindowOverlay>
    </PopoverPrimitive.Portal>
  )
}

function PopoverHeader({ style, ...props }: ViewProps) {
  const { colors } = useTheme()
  return (
    <TextStyleContext.Provider
      value={{ ...fontSize.sm, color: colors.foreground }}
    >
      <View style={[styles.header, style]} {...props} />
    </TextStyleContext.Provider>
  )
}

function PopoverTitle({
  style,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Title>) {
  const { colors } = useTheme()
  return (
    <PopoverPrimitive.Title
      style={(state) => [
        styles.title,
        { color: colors.foreground },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

function PopoverDescription({
  style,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Description>) {
  const { colors } = useTheme()
  return (
    <PopoverPrimitive.Description
      style={(state) => [
        styles.description,
        { color: colors.mutedForeground },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    />
  )
}

export {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/Popover.swift"
import SwiftUI
import UIKit

public struct PopoverContent<Content: View>: View {
  let width: CGFloat?
  let content: Content

  public init(width: CGFloat? = 288, @ViewBuilder content: () -> Content) {
    self.width = width
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      content
    }
    .scaledFont(TextStyle.base)
    .foregroundStyle(EsuissColor.foreground)
    .padding(16)
    .frame(width: width, alignment: .leading)
    .fixedSize(horizontal: width == nil, vertical: true)
    .presentationCompactAdaptation(.popover)
    .presentationBackground(EsuissColor.popover)
    .background(PopoverChrome())
  }
}

private struct PopoverChrome: UIViewRepresentable {
  func makeUIView(context: Context) -> PopoverChromeView { PopoverChromeView() }
  func updateUIView(_ view: PopoverChromeView, context: Context) { view.apply() }
}

final class PopoverChromeView: UIView {
  override init(frame: CGRect) {
    super.init(frame: frame)
    isUserInteractionEnabled = false
    registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: PopoverChromeView, _) in view.apply() }
  }

  required init?(coder: NSCoder) { nil }

  override func didMoveToWindow() {
    super.didMoveToWindow()
    apply()
    DispatchQueue.main.async { self.apply() }
  }

  func apply() {
    let dark = traitCollection.userInterfaceStyle == .dark
    var view = superview
    while let current = view, !(current is UIWindow) {
      for sibling in current.subviews where String(describing: type(of: sibling)).contains("ShadowView") {
        sibling.isHidden = dark
      }
      view = current.superview
    }
  }
}

public struct PopoverHeader<Content: View>: View {
  let content: Content

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 4) {
      content
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

public typealias PopoverTitle = DialogTitle
public typealias PopoverDescription = DialogDescription
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Dialog.swift`: see [Dialog](https://ui.suiss.app/docs/components/dialog.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Popover.kt"
package com.esuiss.ui.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun Popover(
    open: Boolean,
    onOpenChange: (Boolean) -> Unit,
    trigger: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    side: PopupSide = PopupSide.Bottom,
    align: PopupAlign = PopupAlign.Center,
    sideOffset: Dp = 4.dp,
    alignOffset: Dp = 0.dp,
    content: @Composable ColumnScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val paneTitle = remember { PaneTitleState() }
    Box {
        trigger()
        AnchoredPopup(
            expanded = open,
            onDismissRequest = { onOpenChange(false) },
            side = side,
            align = align,
            sideOffset = sideOffset,
            alignOffset = alignOffset,
        ) {
            CompositionLocalProvider(
                LocalPaneTitle provides paneTitle,
                LocalContentStyle provides Typography.base.copy(color = colors.foreground),
                LocalContentColor provides colors.foreground,
            ) {
                Column(
                    modifier = modifier
                        .width(288.dp)
                        .raisedSurface(colors, RoundedCornerShape(Radius.xl2))
                        .semantics { paneTitle.value?.let { this.paneTitle = it } }
                        .padding(16.dp),
                    verticalArrangement = Arrangement.spacedBy(16.dp),
                    content = content,
                )
            }
        }
    }
}

@Composable
fun PopoverHeader(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    CompositionLocalProvider(LocalContentStyle provides Typography.sm.copy(color = colors.foreground)) {
        Column(
            modifier = modifier.fillMaxWidth(),
            verticalArrangement = Arrangement.spacedBy(4.dp),
            content = content,
        )
    }
}

@Composable
fun PopoverTitle(text: String, modifier: Modifier = Modifier) {
    OverlayTitle(text, modifier, titleStyle(EsuissTheme.colors))
}

@Composable
fun PopoverDescription(text: String, modifier: Modifier = Modifier) {
    BasicText(text, modifier = modifier, style = descriptionStyle(EsuissTheme.colors))
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription } from "@/components/ui/popover"
```

```tsx
<View className="items-start">
  <Popover defaultOpen>
    <PopoverTrigger render={<Button variant="outline" />}>
      <Text>Open popover</Text>
    </PopoverTrigger>
    <PopoverContent align="start" className="w-80">
      <View className="gap-4">
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>
            Set the dimensions for the layer.
          </PopoverDescription>
        </PopoverHeader>
        <View className="gap-2">
          {dimensions.map((dimension) => (
            <View
              key={dimension.id}
              className="flex-row items-center gap-4"
            >
              <Label nativeID={`popover-${dimension.id}`} className="w-24">
                {dimension.label}
              </Label>
              <Input
                accessibilityLabelledBy={`popover-${dimension.id}`}
                defaultValue={dimension.value}
                className="h-8 flex-1"
              />
            </View>
          ))}
        </View>
      </View>
    </PopoverContent>
  </Popover>
</View>
```

The popover is uncontrolled by default. Use `defaultOpen` to start open, or `open` with `onOpenChange` to control it. `onOpenChangeComplete` runs after the open or close animation.

Pass a `render` element to `PopoverTrigger`, such as a `Button`, to use your own pressable.

To share one popover between several triggers, create a handle with `createHandle` from the popover primitive, pass it as `handle` to the `Popover` and each `PopoverTrigger`, and give each trigger a `payload`. The `Popover` children can be a function that receives the active `payload`.

Use `portalHost` on `PopoverContent` to render into a named portal host.

## Examples

### Default

![Preview](https://ui.suiss.app/previews/components-popover--default.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { Text } from "@/components/ui/text"

const dimensions = [
  { id: "width", label: "Width", value: "100%" },
  { id: "max-width", label: "Max. width", value: "300px" },
  { id: "height", label: "Height", value: "25px" },
]

export function PopoverExample() {
  return (
    <View className="items-start">
      <Popover>
        <PopoverTrigger render={<Button variant="outline" />}>
          <Text>Open popover</Text>
        </PopoverTrigger>
        <PopoverContent align="start" className="w-80">
          <View className="gap-4">
            <PopoverHeader>
              <PopoverTitle>Dimensions</PopoverTitle>
              <PopoverDescription>
                Set the dimensions for the layer.
              </PopoverDescription>
            </PopoverHeader>
            <View className="gap-2">
              {dimensions.map((dimension) => (
                <View
                  key={dimension.id}
                  className="flex-row items-center gap-4"
                >
                  <Label nativeID={`popover-${dimension.id}`} className="w-24">
                    {dimension.label}
                  </Label>
                  <Input
                    accessibilityLabelledBy={`popover-${dimension.id}`}
                    defaultValue={dimension.value}
                    className="h-8 flex-1"
                  />
                </View>
              ))}
            </View>
          </View>
        </PopoverContent>
      </Popover>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PopoverExample: View {
  var body: some View {
    PopoverDimensions(open: false)
  }
}

struct PopoverDimensions: View {
  @State private var open: Bool

  init(open: Bool) {
    _open = State(initialValue: open)
  }

  var body: some View {
    Button("Open popover") { open = true }
      .buttonStyle(.variant(.outline))
      .popover(isPresented: $open) {
        PopoverContent(width: 320) {
          PopoverHeader {
            PopoverTitle("Dimensions")
            PopoverDescription("Set the dimensions for the layer.")
          }
          VStack(spacing: 8) {
            PopoverDimensionRow(label: "Width", value: "100%")
            PopoverDimensionRow(label: "Max. width", value: "300px")
            PopoverDimensionRow(label: "Height", value: "25px")
          }
        }
      }
  }
}

struct PopoverDimensionRow: View {
  let label: String

  @State private var value: String

  init(label: String, value: String) {
    self.label = label
    _value = State(initialValue: value)
  }

  var body: some View {
    HStack(spacing: 16) {
      FieldLabel(label)
        .frame(width: 96, alignment: .leading)
        .accessibilityHidden(true)
      TextField(label, text: $value)
        .textFieldStyle(.input(size: .sm))
        .accessibilityLabel(Text(label))
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Popover
import com.esuiss.ui.components.PopupAlign

@Composable
fun PopoverExample() {
    PopoverDimensions(defaultOpen = false)
}

@Composable
fun PopoverDimensions(defaultOpen: Boolean) {
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    Column {
        Popover(
            open = open,
            onOpenChange = { open = it },
            trigger = { Button("Open popover", onClick = { open = true }, variant = ButtonVariant.Outline) },
            modifier = Modifier.width(320.dp),
            align = PopupAlign.Start,
        ) {
            PopoverDimensionsContent()
        }
    }
}
```

### Sides

Popovers opened on each of the four sides.

![Preview](https://ui.suiss.app/previews/components-popover--sides.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Text } from "@/components/ui/text"

export function PopoverSidesExample() {
  return (
    <View className="items-center gap-4 py-24">
      {(["top", "right", "bottom", "left"] as const).map((side) => (
        <Popover key={side}>
          <PopoverTrigger render={<Button variant="outline" />}>
            <Text className="capitalize">{side}</Text>
          </PopoverTrigger>
          <PopoverContent side={side} className="w-56">
            <Text className="text-sm">Popover on the {side}.</Text>
          </PopoverContent>
        </Popover>
      ))}
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PopoverSidesExample: View {
  var body: some View {
    VStack(spacing: 16) {
      PopoverSide(title: "Top", edge: .top)
      PopoverSide(title: "Right", edge: .trailing)
      PopoverSide(title: "Bottom", edge: .bottom)
      PopoverSide(title: "Left", edge: .leading)
    }
    .padding(.vertical, 96)
    .frame(maxWidth: .infinity)
  }
}

struct PopoverSide: View {
  let title: String
  let edge: Edge

  @State private var open = false

  var body: some View {
    Button(title) { open = true }
      .buttonStyle(.variant(.outline))
      .popover(isPresented: $open, arrowEdge: edge) {
        PopoverContent(width: 224) {
          Text(verbatim: "Popover on the \(title.lowercased()).")
            .textStyle(.sm)
        }
      }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Popover
import com.esuiss.ui.components.PopupSide
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.Typography

@Composable
fun PopoverSides() {
    Column(
        modifier = Modifier.fillMaxWidth().padding(vertical = 96.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        listOf(
            PopupSide.Top to "top",
            PopupSide.Right to "right",
            PopupSide.Bottom to "bottom",
            PopupSide.Left to "left",
        ).forEach { (side, name) ->
            key(name) {
                var open by rememberSaveable { mutableStateOf(false) }
                Popover(
                    open = open,
                    onOpenChange = { open = it },
                    trigger = {
                        Button(name.replaceFirstChar { it.uppercaseChar() }, onClick = { open = true }, variant = ButtonVariant.Outline)
                    },
                    modifier = Modifier.width(224.dp),
                    side = side,
                ) {
                    Text("Popover on the $name.", style = Typography.sm)
                }
            }
        }
    }
}
```

### Simple

A short text popover aligned to the start of its trigger.

![Preview](https://ui.suiss.app/previews/components-popover--simple.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Text } from "@/components/ui/text"

export function PopoverSimpleExample() {
  return (
    <View className="items-start">
      <Popover>
        <PopoverTrigger render={<Button />}>
          <Text>Info</Text>
        </PopoverTrigger>
        <PopoverContent align="start" className="w-64">
          <Text className="text-sm">
            Popovers display rich content next to a trigger.
          </Text>
        </PopoverContent>
      </Popover>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PopoverSimple: View {
  @State private var open = false

  var body: some View {
    Button("Info") { open = true }
      .buttonStyle(.variant())
      .popover(isPresented: $open) {
        PopoverContent(width: 256) {
          Text("Popovers display rich content next to a trigger.")
            .textStyle(.sm)
        }
      }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.Popover
import com.esuiss.ui.components.PopupAlign
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.Typography

@Composable
fun PopoverSimple() {
    var open by rememberSaveable { mutableStateOf(false) }
    Column {
        Popover(
            open = open,
            onOpenChange = { open = it },
            trigger = { Button("Info", onClick = { open = true }) },
            modifier = Modifier.width(256.dp),
            align = PopupAlign.Start,
        ) {
            Text("Popovers display rich content next to a trigger.", style = Typography.sm)
        }
    }
}
```

**React Native, SwiftUI**

### Detached triggers

Several triggers sharing one popover through a handle, each passing its own payload.

![Preview](https://ui.suiss.app/previews/components-popover--detached-triggers.light.webp)

```tsx
import { View } from "react-native"
import { createHandle } from "@/components/primitives/popover"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { Text } from "@/components/ui/text"

const members = [
  { id: "ada", name: "Ada", role: "Design lead" },
  { id: "linus", name: "Linus", role: "Platform engineer" },
  { id: "grace", name: "Grace", role: "Product manager" },
]

const memberPopover = createHandle<(typeof members)[number]>()

export function PopoverDetachedTriggersExample() {
  return (
    <View className="flex-row items-start gap-2">
      {members.map((member) => (
        <PopoverTrigger
          key={member.id}
          id={member.id}
          handle={memberPopover}
          payload={member}
          render={<Button variant="outline" />}
        >
          <Text>{member.name}</Text>
        </PopoverTrigger>
      ))}
      <Popover handle={memberPopover}>
        {({ payload }) => (
          <PopoverContent align="start">
            <PopoverHeader>
              <PopoverTitle>{payload?.name}</PopoverTitle>
              <PopoverDescription>{payload?.role}</PopoverDescription>
            </PopoverHeader>
          </PopoverContent>
        )}
      </Popover>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PopoverMember: Identifiable {
  let id: String
  let name: String
  let role: String
}

struct PopoverDetached: View {
  private static let members = [
    PopoverMember(id: "ada", name: "Ada", role: "Design lead"),
    PopoverMember(id: "linus", name: "Linus", role: "Platform engineer"),
    PopoverMember(id: "grace", name: "Grace", role: "Product manager"),
  ]

  @State private var openID: String?

  var body: some View {
    HStack(alignment: .top, spacing: 8) {
      ForEach(Self.members) { member in
        Button(member.name) { openID = member.id }
          .buttonStyle(.variant(.outline))
          .popover(isPresented: binding(member.id)) {
            PopoverContent(width: nil) {
              PopoverHeader {
                PopoverTitle(LocalizedStringKey(member.name))
                PopoverDescription(LocalizedStringKey(member.role))
              }
            }
          }
      }
    }
  }

  func binding(_ id: String) -> Binding<Bool> {
    Binding {
      openID == id
    } set: { open in
      if !open, openID == id { openID = nil }
    }
  }
}

let members = [
  PopoverMember(id: "ada", name: "Ada", role: "Design lead"),
  PopoverMember(id: "linus", name: "Linus", role: "Platform engineer"),
  PopoverMember(id: "grace", name: "Grace", role: "Product manager"),
]
```

## API Reference

### Popover

The root that manages the open state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Whether the popover is open, for controlled use. |
| `defaultOpen` | `boolean` | `false` | Whether the popover starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean, details) => void` | - | Called when the popover opens or closes. |
| `modal` | `boolean` | `false` | Blocks interaction with the rest of the screen while open. |

### PopoverContent

Animated panel rendered in a portal and positioned next to the trigger.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `portalHost` | `string` | - | Name of the portal host to render into. |

Also accepts the props of `React.ComponentProps<typeof PopoverPrimitive.Popup>`, `React.ComponentProps<typeof PopoverPrimitive.Positioner>`.

### PopoverHeader

Column for the title and description.

Also accepts the props of `ViewProps`.

### PopoverTitle

Heading that labels the panel.

Also accepts the props of `React.ComponentProps<typeof PopoverPrimitive.Title>`.

### PopoverDescription

Secondary text under the title.

Also accepts the props of `React.ComponentProps<typeof PopoverPrimitive.Description>`.

## Accessibility

- The panel has the `dialog` role and is labeled by `PopoverTitle` when one is present.
- `PopoverTitle` has the `heading` role.
- When the popover opens, focus moves to the title by default.
- The popover can close from an outside press, the Android back button, or the screen reader escape gesture.
- Animations follow the system reduce motion setting.

## Notes

- The panel uses `bg-popover`, a subtle `border-foreground/5` border (`border-foreground/10` in dark mode) and `shadow-lg`.
- `FullWindowOverlay` is used only on iOS; on Android the content renders in the portal directly.
- Light and dark colors follow the system appearance.

