suiss/uim · React Native Component
Popover
A floating panel anchored to a trigger that shows rich content, such as details or a small form.
Popover is an open-source mobile component from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.


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>
)
}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))
}
}
}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
$ pnpm dlx @esuiss/uim@latest add popover$ pnpm dlx @esuiss/uim@latest add popover --platform swift$ pnpm dlx @esuiss/uim@latest add popover --platform kotlinThe CLI also adds what it builds on: Text.
Manual
- Install the dependencies:
npx expo install cn react-native-reanimated react-native-screens react-native-worklets-
Add the pieces it builds on: Text, and the primitives
components/primitives/popover.tsx,components/primitives/types.tsx,components/primitives/use-render.tsx. -
Copy the source into
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 =- Update the import paths to match your project.
- Install the dependencies:
npx expo install cn react-native-reanimated react-native-screens react-native-worklets-
Add the pieces it builds on: Text, and the primitives
components/primitives/popover.tsx,components/primitives/types.tsx,components/primitives/use-render.tsx. -
Copy the source into
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 =- Update the import paths to match your project.
- Install the dependencies:
npx expo install react-native-reanimated react-native-screens react-native-worklets-
Add the pieces it builds on: Text, and the primitives
components/primitives/popover.tsx,components/primitives/types.tsx,components/primitives/use-render.tsx. -
Copy the source into
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- Update the import paths to match your project.
-
Target iOS 17 or later.
-
Add the Swift package to your app:
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")- Copy the theme files every view uses:
import SwiftUI
import UIKit
struct RGBA: Sendable {
let red: CGFloat
let green: CGFloat
let blue: CGFloat
let alpha: CGFloat
init(_ hex: UInt32, _ alpha: CGFloat = 1) {
red = CGFloat((hex >> 16) & 0xFF) / 255
green = CGFloat((hex >> 8) & 0xFF) / 255
blue = CGFloat(hex & 0xFF) / 255
self.alpha = alpha
}import SwiftUI
extension Color {
public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))- Copy the source and every file it uses:
import SwiftUI
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
}
.font(TextStyle.base.font())import SwiftUI
public struct VariantButtonStyle: ButtonStyle {
public enum Variant: CaseIterable, Sendable {
case `default`, outline, secondary, tinted, ghost, plain, destructive, link
}
public enum Size: CaseIterable, Sendable {
case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl
var height: CGFloat {
switch self {
case .xs, .iconXs: 24
case .sm, .iconSm: 32
case .default, .icon: 36
case .lg, .iconLg: 44import SwiftUI
extension EnvironmentValues {
@Entry var buttonGroup: Axis? = nil
@Entry var buttonGroupSize: CGSize = .zero
}
enum ButtonGroupSpace: Hashable {
case group
}
struct ButtonGroupFrame {
let bounds: Anchor<CGRect>
let outlined: Bool
}import PhosphorSwift
import SwiftUI
extension Shadow {
static let control = Shadow(color: .black.opacity(0.08), radius: 0.5, y: 0.5)
static let thumb = Shadow(color: .black.opacity(0.12), radius: 1.5, y: 1)
}
struct SelectionIndicator<Mark: View>: View {
let checked: Bool
let pressed: Bool
let cornerRadius: CGFloat
let mark: Mark
@Environment(\.isEnabled) private var isEnabled
@Environment(\.isInvalid) private var invalidimport SwiftUI
extension View {
public func dialog<Content: View>(
isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> Content
) -> some View {
modifier(
CoverPresenter(isPresented: isPresented) { visible in
CenteredStage(visible: visible, dismissible: true, content: content)
}
)
}
}
struct CenteredStage<Content: View>: View {import SwiftUI
extension EnvironmentValues {
@Entry public var isInvalid = false
@Entry var fieldInset = false
@Entry var fieldGroupInset = false
@Entry var fieldSetInset = false
}
extension View {
public func invalid(_ isInvalid: Bool = true) -> some View {
environment(\.isInvalid, isInvalid)
}
}
public enum FieldOrientation: CaseIterable, Sendable {import SwiftUI
extension EnvironmentValues {
@Entry public var hapticsEnabled = true
}
extension View {
public func haptics(_ enabled: Bool) -> some View {
environment(\.hapticsEnabled, enabled)
}
func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
}
func hapticFeedback<Trigger: Equatable>(import PhosphorSwift
import Observation
import SwiftUI
public enum PopupAlignment: Sendable, CaseIterable {
case start, center, end
}
struct OverlayDismiss: Sendable {
let action: @MainActor @Sendable () -> Void
@MainActor func callAsFunction() {
action()
}
}import SwiftUI
public struct Separator: View {
let orientation: Axis
public init(_ orientation: Axis = .horizontal) {
self.orientation = orientation
}
public var body: some View {
Rectangle()
.fill(Color.separator)
.frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
.frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
.accessibilityHidden(true)
}import SwiftUI
public enum TextVariant: CaseIterable, Sendable {
case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted
var style: TextStyle {
switch self {
case .h1: .xl3
case .h2: .xl2
case .h3: .xl
case .h4, .lead, .large: .lg
case .code, .small, .muted: .sm
default: .base
}
}- Add the files to your app target.
- Set
minSdkto 24 or higher and add the dependencies:
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")- Copy the theme files every view uses:
package com.esuiss.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffsetpackage com.esuiss.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Immutable
data class Colors(
val surface: Color,
val surfaceSecondary: Color,
val surfaceTertiary: Color,
val surfaceRaised: Color,
val label: Color,
val labelSecondary: Color,
val labelTertiary: Color,
val labelQuaternary: Color,- Copy the source and every file it uses:
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.paneTitlepackage com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShapepackage com.esuiss.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutablepackage com.esuiss.ui.components
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
val LocalContentStyle = compositionLocalOf { TextStyle.Default }
val LocalContentColor = compositionLocalOf { Color.Unspecified }
val LocalIconSize = compositionLocalOf { 20.dp }package com.esuiss.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme
@Composable
fun Icon(
imageVector: ImageVector,package com.esuiss.ui.components
import android.os.Build
import android.view.Window
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideIn- Update the
com.esuiss.uipackage names to match your project.
Usage
import { PopoverContent, PopoverHeader, PopoverTitle, PopoverDescription } from "@/components/ui/popover"<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


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>
)
}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))
}
}
}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.


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>
)
}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)
}
}
}
}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.


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>
)
}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)
}
}
}
}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)
}
}
}Detached triggers
Several triggers sharing one popover through a handle, each passing its own payload.


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>
)
}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
PopoverContent
Animated panel rendered in a portal and positioned next to the trigger.
React.ComponentProps<typeof PopoverPrimitive.Popup>React.ComponentProps<typeof PopoverPrimitive.Positioner>- About
- Name of the portal host to render into.
- Type
string- Default
- —
PopoverHeader
Column for the title and description.
ViewPropsPopoverTitle
Heading that labels the panel.
React.ComponentProps<typeof PopoverPrimitive.Title>PopoverDescription
Secondary text under the title.
React.ComponentProps<typeof PopoverPrimitive.Description>Accessibility
- The panel has the
dialogrole and is labeled byPopoverTitlewhen one is present. PopoverTitlehas theheadingrole.- 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-surface-raised, a subtleborder-label/5border (border-label/10in dark mode) andshadow-lg. FullWindowOverlayis used only on iOS; on Android the content renders in the portal directly.- Light and dark colors follow the system appearance.