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.
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
$ pnpm dlx shadcn@latest add @esuiss/popover
Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:
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.
Set asChild on PopoverTrigger to render another component, such as a Button, as the trigger.
The Button placed first inside Popover opens it, and isOpen controls the open state.
Examples
Sides
Four popovers that open on the top, right, bottom and left of their triggers.
Simple
A plain text popover opened from a ghost button, without a header.
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.
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 shadcn@latest add @esuiss-rn/popover
See Installation for the components.json setup. Or use the suiss/ui CLI:
$ pnpm dlx @esuiss/ui@latest add popover
$ pnpm dlx @esuiss/ui@latest add popover --platform swift
Add the pieces it builds on: 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.
Copy the animated view into components/ui/animated-view.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.Rootconst PopoverTrigger = PopoverPrimitive.Triggerconst FullWindowOverlay =
Add the pieces it builds on: 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.
Copy the animated view into components/ui/animated-view.tsx:
Add the pieces it builds on: 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.
Copy the animated view into components/ui/animated-view.tsx:
import SwiftUIimport UIKitstruct 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 }
swift/Theme/Tokens.swift
import SwiftUIpublic enum EsuissColor { public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000)) public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7)) public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F)) public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7)) public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F)) public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7)) public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF)) public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF)) public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1)) public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7)) public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617)) public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6)) public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
Copy the source and every file it uses:
swift/Components/Popover.swift
import SwiftUIimport UIKitpublic 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 }
swift/Components/Button.swift
import SwiftUIpublic 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: 44
swift/Components/ButtonGroup.swift
import SwiftUIextension 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}
package com.esuiss.ui.themeimport androidx.compose.runtime.Immutableimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.unit.dp@Immutabledata class Colors( val background: Color, val foreground: Color, val card: Color, val cardForeground: Color, val popover: Color, val popoverForeground: Color, val primary: Color, val primaryForeground: Color,
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.
import EsuissUIimport SwiftUIstruct 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.Columnimport androidx.compose.foundation.layout.widthimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.saveable.rememberSaveableimport androidx.compose.runtime.setValueimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport com.esuiss.ui.components.Buttonimport com.esuiss.ui.components.ButtonVariantimport com.esuiss.ui.components.Popoverimport com.esuiss.ui.components.PopupAlign@Composablefun PopoverOpenByDefaultExample() { PopoverDimensions(defaultOpen = true)}@Composablefun 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() } }}
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,
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> )}
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> )}
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.Rootconst PopoverTrigger = PopoverPrimitive.Triggerconst FullWindowOverlay =
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.Rootconst PopoverTrigger = PopoverPrimitive.Triggerconst FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment