suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Interactions15
  • Overview
  • Card Stack
  • Image Viewer
  • Island
  • OTP Autofill
  • Preview Menu
  • Pull to Refresh
  • Reorder List
  • Search Reveal
  • Section Index
  • Selection List
  • Stepper
  • Swipe Actions
  • Tip
  • Top Banner
  • Undo Bar

suiss/ui · Interaction

Preview Menu

A context menu that lifts the pressed item into an enlarged preview above its actions, like iOS context menus.

Preview Menu is an open-source interaction 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 {
  CopyIcon,
  HeartIcon,
  ShareNetworkIcon,
  TrashIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  PreviewMenu,
  PreviewMenuContent,
  PreviewMenuItem,
  PreviewMenuSeparator,
  PreviewMenuTrigger,
} from "@/components/interactions/preview-menu"
import { cn } from "@/lib/utils"

const photos = [
  "from-blue-500 to-purple-500",
  "from-orange-500 to-pink-500",
  "from-green-500 to-teal-500",
  "from-indigo-500 to-blue-500",
  "from-yellow-500 to-orange-500",
  "from-pink-500 to-red-500",
  "from-teal-500 to-cyan-500",
  "from-purple-500 to-indigo-500",
  "from-teal-400 to-green-500",
]

function Photo({ tone, label }: { tone: string; label: string }) {
  return (
    <div
      role="img"
      aria-label={label}
      className={cn("aspect-square size-full bg-linear-to-br", tone)}
    />
  )
}

function PhotoGrid({ withDisabled = false }: { withDisabled?: boolean }) {
  const [log, setLog] = React.useState(
    "Touch and hold a photo, or right-click it."
  )

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-3">
      <div className="grid grid-cols-3 gap-2">
        {photos.map((tone, index) => {
          const label = `Photo ${index + 1}`
          return (
            <PreviewMenu key={tone}>
              <PreviewMenuTrigger className="aspect-square overflow-hidden rounded-2xl">
                <Photo tone={tone} label={label} />
              </PreviewMenuTrigger>
              <PreviewMenuContent>
                <PreviewMenuItem onClick={() => setLog(`Copied ${label}`)}>
                  Copy
                  <CopyIcon />
                </PreviewMenuItem>
                <PreviewMenuItem onClick={() => setLog(`Shared ${label}`)}>
                  Share
                  <ShareNetworkIcon />
                </PreviewMenuItem>
                <PreviewMenuItem
                  disabled={withDisabled}
                  onClick={() => setLog(`Favorited ${label}`)}
                >
                  Favorite
                  <HeartIcon />
                </PreviewMenuItem>
                <PreviewMenuSeparator />
                <PreviewMenuItem
                  variant="destructive"
                  onClick={() => setLog(`Deleted ${label}`)}
                >
                  Delete
                  <TrashIcon />
                </PreviewMenuItem>
              </PreviewMenuContent>
            </PreviewMenu>
          )
        })}
      </div>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function PreviewMenuDemo() {
  return <PhotoGrid />
}

PreviewMenu builds on the menu primitives of the chosen base. Touching and holding the trigger, or right-clicking it, blurs the page and animates the trigger from its position into a centered preview up to 360px wide, morphing its corner radius along the way. The menu of actions opens below the preview and the trigger stays hidden until the menu finishes closing, so the preview appears to return to its place.

By default, the preview shows the trigger's own content. Pass preview to PreviewMenuContent to show richer content instead; the trigger's snapshot crossfades into it. Use it for items that benefit from a quick look before acting, such as photos, documents, or messages.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/preview-menu

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add interactions preview-menu

Manual

  1. Install the dependencies and add the cn helper:
$ pnpm add @base-ui/react
  1. Copy the source into components/interactions/preview-menu.tsx:
  1. Update the import paths to match your project.

Usage

import { PreviewMenu, PreviewMenuTrigger, PreviewMenuContent, PreviewMenuItem, PreviewMenuSeparator } 
<PhotoGrid
  • Compose PreviewMenuTrigger and PreviewMenuContent inside PreviewMenu, with PreviewMenuItem and PreviewMenuSeparator inside the content.
  • The menu is uncontrolled by default; pass open and onOpenChange to control it.
  • Set aspectRatio when a custom preview has a different shape than the trigger; otherwise the preview keeps the trigger's proportions.
  • Handle actions with onClick on each item. Items close the menu when chosen.

Examples

Disabled item

A disabled item is announced as unavailable and does not act, while the other items still work.

Custom preview

A compact album row opens a larger custom preview through the preview prop and aspectRatio.

API Reference

Anatomy4 parts
<PreviewMenu>
<PreviewMenuTrigger />
<PreviewMenuContent>
<PreviewMenuItem />
</PreviewMenuContent>
</PreviewMenu>
01

PreviewMenu

The root that manages open state and measures the preview frame.

PropTypeDefaultDescription
openboolean-Whether the menu is open, for controlled use.
defaultOpenbooleanfalseWhether the menu starts open when uncontrolled.
onOpenChange(open: boolean) => void-Called when the menu opens or closes.
aspectRationumber-The width-to-height ratio of the preview; defaults to the trigger's ratio.
childrenReact.ReactNode-The trigger and content.
02

PreviewMenuTrigger

The element that opens the menu on touch and hold or right-click.

Also accepts the props of the primitive part it wraps.

03

PreviewMenuContent

The backdrop, lifted preview, and popup that holds the menu items.

PropTypeDefaultDescription
previewReact.ReactNode-Custom content to show in the preview instead of the trigger's content.
previewClassNamestring-Classes for the clipped preview container.

Also accepts the props of the primitive part it wraps.

04

PreviewMenuItem

An action in the menu.

PropTypeDefaultDescription
variant"default" | "destructive""default"The item style; destructive renders the label in the destructive color.

Also accepts the props of the primitive part it wraps.

05

PreviewMenuSeparator

A thick divider between groups of items.

Also accepts the props of the primitive part it wraps.

Accessibility

  • The popup has role="menu" and items have role="menuitem", with keyboard navigation from the menu primitive.
  • Escape closes the menu without running an action.
  • Disabled items set aria-disabled and cannot be chosen.
  • The lifted preview is aria-hidden, so assistive technologies read the trigger and menu only.

Notes

  • Parts use data-slot values preview-menu-trigger, preview-menu-backdrop, preview-menu-preview, preview-menu-content, preview-menu-item, and preview-menu-separator. Items set data-variant.
  • The trigger carries data-previewing while open and data-lifted until the closing animation ends, and is invisible during that time.
  • The trigger disables text selection and the iOS touch callout so long presses open the menu.

Long pressing a PreviewMenuTrigger measures it and the menu, then animates its content from its place on screen into a larger preview, up to 360 points wide. The preview and the menu below it are centered together between the top and bottom safe areas. The rest of the screen fades behind a scrim, and the menu grows out of the trigger into place.

The original trigger is hidden while lifted, so the preview appears to rise out of it. With custom preview content, the trigger's content cross-fades into it as it grows. Pressing outside closes the menu and the preview and menu shrink back into the trigger. Use it for photos, messages or cards that benefit from a closer look before acting.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/preview-menu

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add preview-menu
$ pnpm dlx @esuiss/ui@latest add preview-menu --platform swift
NextPull to RefreshPreviousOTP Autofill

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Disabled item
    • Custom preview
  4. 04API Reference
    • PreviewMenu
    • PreviewMenuTrigger
    • PreviewMenuContent
    • PreviewMenuItem
    • PreviewMenuSeparator
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Default
    • Disabled item
    • Custom preview
    • Custom preview open
  10. 10API Reference
    • PreviewMenu
    • PreviewMenuTrigger
    • PreviewMenuContent
    • PreviewMenuItem
    • PreviewMenuSeparator
  11. 11Accessibility
  12. 12Notes
from
"@/components/interactions/preview-menu"
/>
$ pnpm dlx @esuiss/ui@latest add preview-menu --platform kotlin

The CLI also adds what it builds on: Icon, Text. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn react-native-reanimated react-native-safe-area-context react-native-screens react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/preview-menu.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/preview-menu.tsx:
uniwind/components/interactions/preview-menu.tsx
import { cn } from "cn"
import * as React from "react"
import { Platform, View } from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PreviewMenuPrimitive from "@/components/primitives/preview-menu"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { IconClassContext } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

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

function usePreviewMenu() {
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn react-native-reanimated react-native-safe-area-context react-native-screens react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/preview-menu.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

  1. Copy the source into components/interactions/preview-menu.tsx:
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install react-native-reanimated react-native-safe-area-context react-native-screens react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/preview-menu.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

  1. Copy the source into components/interactions/preview-menu.tsx:
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
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
  }
swift/Theme/Tokens.swift
import SwiftUI

public 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))
  1. Copy the source and every file it uses:
swift/Interactions/PreviewMenu.swift
import SwiftUI

public enum PreviewMenuItemVariant: CaseIterable, Sendable {
  case `default`, destructive
}

let previewMenuEnter = Animation.timingCurve(0.32, 0.72, 0, 1, duration: 0.4)
let previewMenuExit = Animation.timingCurve(0.32, 0.72, 0, 1, duration: 0.3)

enum PreviewMenuMath {
  static let edge: CGFloat = 16
  static let menuSpace: CGFloat = 260
  static let menuMinHeight: CGFloat = 88
  static let maxWidth: CGFloat = 360
  static let menuWidth: CGFloat = 250
  static let menuGap: CGFloat = 12
swift/Components/Button.swift
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: 44
swift/Components/ButtonGroup.swift
import 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
}
swift/Components/Haptics.swift
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>(
swift/Components/Overlay.swift
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()
  }
}
swift/Components/TextVariant.swift
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
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
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")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
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.DpOffset
kotlin/theme/Tokens.kt
package 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 background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/interactions/PreviewMenu.kt
package com.esuiss.ui.interactions

import android.os.Build
import android.view.WindowManager
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
kotlin/components/Button.kt
package 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.RoundedCornerShape
kotlin/components/ButtonGroup.kt
package 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.Immutable
kotlin/components/Checkbox.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.triStateToggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
kotlin/components/Content.kt
package 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 }
kotlin/components/DropdownMenu.kt
package com.esuiss.ui.components

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
kotlin/components/Field.kt
package 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.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.LayoutScopeMarker
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Haptics.kt
package com.esuiss.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

val LocalHapticsEnabled = staticCompositionLocalOf { true }

@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}
kotlin/components/Icon.kt
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,
kotlin/components/Label.kt
package com.esuiss.ui.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
kotlin/components/Overlay.kt
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
kotlin/components/Separator.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Separator(
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { PreviewMenu, PreviewMenuTrigger, PreviewMenuContent, PreviewMenuItem, PreviewMenuSeparator } from "@/components/interactions/preview-menu"
<PhotoGrid openIndex={1} />

Put a PreviewMenuTrigger with the element and a PreviewMenuContent with PreviewMenuItems inside PreviewMenu. By default the preview repeats the trigger's children; pass preview to show different content, such as a larger image.

Set aspectRatio to size the preview, for example 4 / 3. The menu can be uncontrolled or controlled with open, defaultOpen and onOpenChange. usePreviewMenu returns open, lifted and frame.

Examples

Default

iOS
Preview Menu on iOS in light modePreview Menu on iOS in dark mode
import { CopyIcon, HeartIcon, ShareNetworkIcon, TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { PreviewMenu, PreviewMenuContent, PreviewMenuItem, PreviewMenuSeparator, PreviewMenuTrigger } from "@/components/interactions/preview-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const photos = [
  "bg-blue-500",
  "bg-orange-500",
  "bg-green-500",
  "bg-indigo-500",
  "bg-yellow-500",
  "bg-pink-500",
  "bg-teal-500",
  "bg-purple-500",
  "bg-teal-400",
]

function PhotoGrid({
  withDisabled = false,
  openIndex,
}: {
  withDisabled?: boolean
  openIndex?: number
}) {
  const [log, setLog] = React.useState("Touch and hold a photo.")
  return (
    <View className="gap-3">
      <View className="flex-row flex-wrap gap-2">
        {photos.map((tone, index) => {
          const label = `Photo ${index + 1}`
          return (
            <PreviewMenu key={tone} defaultOpen={index === openIndex}>
              <PreviewMenuTrigger
                accessibilityLabel={label}
                className="aspect-square w-[31%] overflow-hidden rounded-2xl"
              >
                <View className={`h-full w-full ${tone}`} />
              </PreviewMenuTrigger>
              <PreviewMenuContent>
                <PreviewMenuItem onPress={() => setLog(`Copied ${label}`)}>
                  Copy
                  <Icon as={CopyIcon} />
                </PreviewMenuItem>
                <PreviewMenuItem onPress={() => setLog(`Shared ${label}`)}>
                  Share
                  <Icon as={ShareNetworkIcon} />
                </PreviewMenuItem>
                <PreviewMenuItem
                  disabled={withDisabled}
                  onPress={() => setLog(`Favorited ${label}`)}
                >
                  Favorite
                  <Icon as={HeartIcon} />
                </PreviewMenuItem>
                <PreviewMenuSeparator />
                <PreviewMenuItem
                  variant="destructive"
                  onPress={() => setLog(`Deleted ${label}`)}
                >
                  Delete
                  <Icon as={TrashIcon} />
                </PreviewMenuItem>
              </PreviewMenuContent>
            </PreviewMenu>
          )
        })}
      </View>
      <Text variant="muted">{log}</Text>
    </View>
  )
}

export function PreviewMenuExample() {
  return (
    <PhotoGrid />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

let previewMenuTones: [Color] = [
  EsuissColor.primary,
  Palette.amber500,
  Palette.green500,
  Palette.cyan500,
  EsuissColor.destructive,
  Palette.indigo500,
  EsuissColor.mutedForeground,
  EsuissColor.input,
  EsuissColor.secondary,
]

struct PreviewMenuPhotoGrid: View {
  var withDisabled = false
  var openIndex: Int?

  @State private var log: LocalizedStringKey = "Touch and hold a photo."

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      GeometryReader { proxy in
        let side = ((proxy.size.width - 16) / 3).rounded(.down)
        FlowRow(spacing: 8) {
          ForEach(previewMenuTones.indices, id: \.self) { index in
            PreviewMenuPhoto(
              index: index,
              side: side,
              withDisabled: withDisabled,
              open: index == openIndex,
              log: $log
            )
          }
        }
      }
      .aspectRatio(1, contentMode: .fit)
      Text(log)
        .textVariant(.muted)
    }
  }
}

struct PreviewMenuPhoto: View {
  let index: Int
  let side: CGFloat
  let withDisabled: Bool
  @Binding var log: LocalizedStringKey

  @State private var open: Bool

  init(index: Int, side: CGFloat, withDisabled: Bool, open: Bool, log: Binding<LocalizedStringKey>) {
    self.index = index
    self.side = side
    self.withDisabled = withDisabled
    _log = log
    _open = State(initialValue: open)
  }

  var body: some View {
    let label = "Photo \(index + 1)"
    previewMenuTones[index]
      .frame(width: side, height: side)
      .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
      .accessibilityElement()
      .accessibilityLabel(Text(verbatim: label))
      .previewMenu(isPresented: $open) {
        PreviewMenuItem("Copy", icon: Ph.copy.regular) { log = "Copied \(label)" }
        PreviewMenuItem("Share", icon: Ph.shareNetwork.regular) { log = "Shared \(label)" }
        PreviewMenuItem("Favorite", icon: Ph.heart.regular) { log = "Favorited \(label)" }
          .disabled(withDisabled)
        PreviewMenuSeparator()
        PreviewMenuItem("Delete", icon: Ph.trash.regular, variant: .destructive) { log = "Deleted \(label)" }
      }
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Copy
import com.adamglin.phosphoricons.regular.Heart
import com.adamglin.phosphoricons.regular.ShareNetwork
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.PreviewMenu
import com.esuiss.ui.interactions.PreviewMenuItem
import com.esuiss.ui.interactions.PreviewMenuSeparator
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius

@Composable
fun previewMenuTones(): List<Color> {
    val colors = EsuissTheme.colors
    return listOf(
        Palette.blue500,
        Palette.orange500,
        Palette.green500,
        Palette.indigo500,
        Palette.yellow500,
        Palette.pink500,
        Palette.teal500,
        Palette.purple500,
        Palette.teal400,
    )
}

@Composable
fun PreviewMenuPhotoGrid(withDisabled: Boolean = false, openIndex: Int? = null) {
    var log by rememberSaveable { mutableStateOf("Touch and hold a photo.") }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        FlowRow(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            previewMenuTones().forEachIndexed { index, tone ->
                val label = "Photo ${index + 1}"
                var open by rememberSaveable(index) { mutableStateOf(index == openIndex) }
                PreviewMenu(
                    open = open,
                    onOpenChange = { open = it },
                    trigger = {
                        Box(
                            Modifier
                                .fillMaxSize()
                                .clip(shape)
                                .background(tone, shape)
                                .semantics { contentDescription = label },
                        )
                    },
                    modifier = Modifier.fillMaxWidth(0.31f).aspectRatio(1f),
                ) {
                    PreviewMenuItem("Copy", onClick = { log = "Copied $label" }, icon = PhosphorIcons.Regular.Copy)
                    PreviewMenuItem("Share", onClick = { log = "Shared $label" }, icon = PhosphorIcons.Regular.ShareNetwork)
                    PreviewMenuItem(
                        "Favorite",
                        onClick = { log = "Favorited $label" },
                        icon = PhosphorIcons.Regular.Heart,
                        enabled = !withDisabled,
                    )
                    PreviewMenuSeparator()
                    PreviewMenuItem(
                        "Delete",
                        onClick = { log = "Deleted $label" },
                        icon = PhosphorIcons.Regular.Trash,
                        variant = MenuItemVariant.Destructive,
                    )
                }
            }
        }
        Text(log, variant = TextVariant.Muted)
    }
}

Disabled item

Includes a disabled menu item.

iOS
Preview Menu (disabled item) on iOS in light modePreview Menu (disabled item) on iOS in dark mode
import { CopyIcon, HeartIcon, ShareNetworkIcon, TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { PreviewMenu, PreviewMenuContent, PreviewMenuItem, PreviewMenuSeparator, PreviewMenuTrigger } from "@/components/interactions/preview-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const photos = [
  "bg-blue-500",
  "bg-orange-500",
  "bg-green-500",
  "bg-indigo-500",
  "bg-yellow-500",
  "bg-pink-500",
  "bg-teal-500",
  "bg-purple-500",
  "bg-teal-400",
]

function PhotoGrid({
  withDisabled = false,
  openIndex,
}: {
  withDisabled?: boolean
  openIndex?: number
}) {
  const [log, setLog] = React.useState("Touch and hold a photo.")
  return (
    <View className="gap-3">
      <View className="flex-row flex-wrap gap-2">
        {photos.map((tone, index) => {
          const label = `Photo ${index + 1}`
          return (
            <PreviewMenu key={tone} defaultOpen={index === openIndex}>
              <PreviewMenuTrigger
                accessibilityLabel={label}
                className="aspect-square w-[31%] overflow-hidden rounded-2xl"
              >
                <View className={`h-full w-full ${tone}`} />
              </PreviewMenuTrigger>
              <PreviewMenuContent>
                <PreviewMenuItem onPress={() => setLog(`Copied ${label}`)}>
                  Copy
                  <Icon as={CopyIcon} />
                </PreviewMenuItem>
                <PreviewMenuItem onPress={() => setLog(`Shared ${label}`)}>
                  Share
                  <Icon as={ShareNetworkIcon} />
                </PreviewMenuItem>
                <PreviewMenuItem
                  disabled={withDisabled}
                  onPress={() => setLog(`Favorited ${label}`)}
                >
                  Favorite
                  <Icon as={HeartIcon} />
                </PreviewMenuItem>
                <PreviewMenuSeparator />
                <PreviewMenuItem
                  variant="destructive"
                  onPress={() => setLog(`Deleted ${label}`)}
                >
                  Delete
                  <Icon as={TrashIcon} />
                </PreviewMenuItem>
              </PreviewMenuContent>
            </PreviewMenu>
          )
        })}
      </View>
      <Text variant="muted">{log}</Text>
    </View>
  )
}

export function PreviewMenuDisabledItemExample() {
  return (
    <PhotoGrid withDisabled openIndex={0} />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PreviewMenuDisabledItemExample: View {
  var body: some View {
    PreviewMenuPhotoGrid(withDisabled: true, openIndex: 0)
  }
}

let previewMenuTones: [Color] = [
  EsuissColor.primary,
  Palette.amber500,
  Palette.green500,
  Palette.cyan500,
  EsuissColor.destructive,
  Palette.indigo500,
  EsuissColor.mutedForeground,
  EsuissColor.input,
  EsuissColor.secondary,
]

struct PreviewMenuPhotoGrid: View {
  var withDisabled = false
  var openIndex: Int?

  @State private var log: LocalizedStringKey = "Touch and hold a photo."

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      GeometryReader { proxy in
        let side = ((proxy.size.width - 16) / 3).rounded(.down)
        FlowRow(spacing: 8) {
          ForEach(previewMenuTones.indices, id: \.self) { index in
            PreviewMenuPhoto(
              index: index,
              side: side,
              withDisabled: withDisabled,
              open: index == openIndex,
              log: $log
            )
          }
        }
      }
      .aspectRatio(1, contentMode: .fit)
      Text(log)
        .textVariant(.muted)
    }
  }
}

struct PreviewMenuPhoto: View {
  let index: Int
  let side: CGFloat
  let withDisabled: Bool
  @Binding var log: LocalizedStringKey

  @State private var open: Bool

  init(index: Int, side: CGFloat, withDisabled: Bool, open: Bool, log: Binding<LocalizedStringKey>) {
    self.index = index
    self.side = side
    self.withDisabled = withDisabled
    _log = log
    _open = State(initialValue: open)
  }

  var body: some View {
    let label = "Photo \(index + 1)"
    previewMenuTones[index]
      .frame(width: side, height: side)
      .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
      .accessibilityElement()
      .accessibilityLabel(Text(verbatim: label))
      .previewMenu(isPresented: $open) {
        PreviewMenuItem("Copy", icon: Ph.copy.regular) { log = "Copied \(label)" }
        PreviewMenuItem("Share", icon: Ph.shareNetwork.regular) { log = "Shared \(label)" }
        PreviewMenuItem("Favorite", icon: Ph.heart.regular) { log = "Favorited \(label)" }
          .disabled(withDisabled)
        PreviewMenuSeparator()
        PreviewMenuItem("Delete", icon: Ph.trash.regular, variant: .destructive) { log = "Deleted \(label)" }
      }
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Copy
import com.adamglin.phosphoricons.regular.Heart
import com.adamglin.phosphoricons.regular.ShareNetwork
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.PreviewMenu
import com.esuiss.ui.interactions.PreviewMenuItem
import com.esuiss.ui.interactions.PreviewMenuSeparator
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius

@Composable
fun PreviewMenuDisabledItemExample() {
    PreviewMenuPhotoGrid(withDisabled = true, openIndex = 0)
}

@Composable
fun previewMenuTones(): List<Color> {
    val colors = EsuissTheme.colors
    return listOf(
        Palette.blue500,
        Palette.orange500,
        Palette.green500,
        Palette.indigo500,
        Palette.yellow500,
        Palette.pink500,
        Palette.teal500,
        Palette.purple500,
        Palette.teal400,
    )
}

@Composable
fun PreviewMenuPhotoGrid(withDisabled: Boolean = false, openIndex: Int? = null) {
    var log by rememberSaveable { mutableStateOf("Touch and hold a photo.") }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        FlowRow(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            previewMenuTones().forEachIndexed { index, tone ->
                val label = "Photo ${index + 1}"
                var open by rememberSaveable(index) { mutableStateOf(index == openIndex) }
                PreviewMenu(
                    open = open,
                    onOpenChange = { open = it },
                    trigger = {
                        Box(
                            Modifier
                                .fillMaxSize()
                                .clip(shape)
                                .background(tone, shape)
                                .semantics { contentDescription = label },
                        )
                    },
                    modifier = Modifier.fillMaxWidth(0.31f).aspectRatio(1f),
                ) {
                    PreviewMenuItem("Copy", onClick = { log = "Copied $label" }, icon = PhosphorIcons.Regular.Copy)
                    PreviewMenuItem("Share", onClick = { log = "Shared $label" }, icon = PhosphorIcons.Regular.ShareNetwork)
                    PreviewMenuItem(
                        "Favorite",
                        onClick = { log = "Favorited $label" },
                        icon = PhosphorIcons.Regular.Heart,
                        enabled = !withDisabled,
                    )
                    PreviewMenuSeparator()
                    PreviewMenuItem(
                        "Delete",
                        onClick = { log = "Deleted $label" },
                        icon = PhosphorIcons.Regular.Trash,
                        variant = MenuItemVariant.Destructive,
                    )
                }
            }
        }
        Text(log, variant = TextVariant.Muted)
    }
}

Custom preview

Shows custom preview content with a 4 / 3 aspect ratio.

iOS
Preview Menu (custom preview) on iOS in light modePreview Menu (custom preview) on iOS in dark mode
import { ShareNetworkIcon, TrashIcon } from "phosphor-react-native"
import { View } from "react-native"
import { PreviewMenu, PreviewMenuContent, PreviewMenuItem, PreviewMenuTrigger } from "@/components/interactions/preview-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const photos = [
  "bg-blue-500",
  "bg-orange-500",
  "bg-green-500",
  "bg-indigo-500",
  "bg-yellow-500",
  "bg-pink-500",
  "bg-teal-500",
  "bg-purple-500",
  "bg-teal-400",
]

function AlbumPreview({ defaultOpen }: { defaultOpen?: boolean }) {
  return (
    <PreviewMenu aspectRatio={4 / 3} defaultOpen={defaultOpen}>
      <PreviewMenuTrigger className="flex-row items-center gap-3 rounded-xl bg-muted p-3">
        <View className="size-12 rounded-lg bg-blue-500" />
        <View>
          <Text className="font-semibold">Trip to the coast</Text>
          <Text variant="muted">48 photos</Text>
        </View>
      </PreviewMenuTrigger>
      <PreviewMenuContent
        preview={
          <View className="h-full w-full justify-end bg-blue-500 p-4">
            <Text className="text-lg font-semibold text-primary-foreground">
              Trip to the coast
            </Text>
            <Text className="text-sm text-primary-foreground">
              48 photos · Updated today
            </Text>
          </View>
        }
      >
        <PreviewMenuItem>
          Share album
          <Icon as={ShareNetworkIcon} />
        </PreviewMenuItem>
        <PreviewMenuItem variant="destructive">
          Delete album
          <Icon as={TrashIcon} />
        </PreviewMenuItem>
      </PreviewMenuContent>
    </PreviewMenu>
  )
}

export function PreviewMenuCustomPreviewExample() {
  return (
    <AlbumPreview />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

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

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

  var body: some View {
    HStack(spacing: 12) {
      RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
        .fill(EsuissColor.primary)
        .frame(width: 48, height: 48)
      VStack(alignment: .leading, spacing: 0) {
        Text("Trip to the coast")
          .textStyle(.base, weight: .semibold)
          .foregroundStyle(EsuissColor.foreground)
        Text("48 photos")
          .textVariant(.muted)
      }
    }
    .padding(12)
    .frame(maxWidth: .infinity, alignment: .leading)
    .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
    .previewMenu(isPresented: $open, aspectRatio: 4 / 3) {
      PreviewMenuItem("Share album", icon: Ph.shareNetwork.regular) {}
      PreviewMenuItem("Delete album", icon: Ph.trash.regular, variant: .destructive) {}
    } preview: {
      VStack(alignment: .leading, spacing: 0) {
        Text("Trip to the coast")
          .textStyle(.lg, weight: .semibold)
        Text("48 photos · Updated today")
          .textStyle(.sm)
      }
      .foregroundStyle(EsuissColor.primaryForeground)
      .padding(16)
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
      .background(EsuissColor.primary)
    }
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ShareNetwork
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.PreviewMenu
import com.esuiss.ui.interactions.PreviewMenuItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun PreviewMenuCustomPreviewExample() {
    PreviewMenuAlbum(defaultOpen = false)
}

@Composable
fun PreviewMenuAlbum(defaultOpen: Boolean) {
    val colors = EsuissTheme.colors
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    PreviewMenu(
        open = open,
        onOpenChange = { open = it },
        aspectRatio = 4f / 3f,
        trigger = {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(colors.muted, RoundedCornerShape(Radius.xl))
                    .padding(12.dp),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Box(Modifier.size(48.dp).background(Palette.blue500, RoundedCornerShape(Radius.lg)))
                Column {
                    Text("Trip to the coast", style = Typography.base.copy(fontWeight = FontWeight.SemiBold))
                    Text("48 photos", variant = TextVariant.Muted)
                }
            }
        },
        preview = {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Palette.blue500)
                    .padding(16.dp),
                verticalArrangement = Arrangement.Bottom,
            ) {
                Text("Trip to the coast", style = Typography.lg.copy(color = colors.primaryForeground, fontWeight = FontWeight.SemiBold))
                Text("48 photos · Updated today", style = Typography.sm.copy(color = colors.primaryForeground))
            }
        },
    ) {
        PreviewMenuItem("Share album", onClick = {}, icon = PhosphorIcons.Regular.ShareNetwork)
        PreviewMenuItem("Delete album", onClick = {}, icon = PhosphorIcons.Regular.Trash, variant = MenuItemVariant.Destructive)
    }
}

Custom preview open

The custom preview, open on first render.

iOS
Preview Menu (custom preview open) on iOS in light modePreview Menu (custom preview open) on iOS in dark mode
import { ShareNetworkIcon, TrashIcon } from "phosphor-react-native"
import { View } from "react-native"
import { PreviewMenu, PreviewMenuContent, PreviewMenuItem, PreviewMenuTrigger } from "@/components/interactions/preview-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const photos = [
  "bg-blue-500",
  "bg-orange-500",
  "bg-green-500",
  "bg-indigo-500",
  "bg-yellow-500",
  "bg-pink-500",
  "bg-teal-500",
  "bg-purple-500",
  "bg-teal-400",
]

function AlbumPreview({ defaultOpen }: { defaultOpen?: boolean }) {
  return (
    <PreviewMenu aspectRatio={4 / 3} defaultOpen={defaultOpen}>
      <PreviewMenuTrigger className="flex-row items-center gap-3 rounded-xl bg-muted p-3">
        <View className="size-12 rounded-lg bg-blue-500" />
        <View>
          <Text className="font-semibold">Trip to the coast</Text>
          <Text variant="muted">48 photos</Text>
        </View>
      </PreviewMenuTrigger>
      <PreviewMenuContent
        preview={
          <View className="h-full w-full justify-end bg-blue-500 p-4">
            <Text className="text-lg font-semibold text-primary-foreground">
              Trip to the coast
            </Text>
            <Text className="text-sm text-primary-foreground">
              48 photos · Updated today
            </Text>
          </View>
        }
      >
        <PreviewMenuItem>
          Share album
          <Icon as={ShareNetworkIcon} />
        </PreviewMenuItem>
        <PreviewMenuItem variant="destructive">
          Delete album
          <Icon as={TrashIcon} />
        </PreviewMenuItem>
      </PreviewMenuContent>
    </PreviewMenu>
  )
}

export function PreviewMenuCustomPreviewOpenExample() {
  return (
    <AlbumPreview defaultOpen />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PreviewMenuCustomPreviewOpenExample: View {
  var body: some View {
    PreviewMenuAlbum(open: true)
  }
}

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

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

  var body: some View {
    HStack(spacing: 12) {
      RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
        .fill(EsuissColor.primary)
        .frame(width: 48, height: 48)
      VStack(alignment: .leading, spacing: 0) {
        Text("Trip to the coast")
          .textStyle(.base, weight: .semibold)
          .foregroundStyle(EsuissColor.foreground)
        Text("48 photos")
          .textVariant(.muted)
      }
    }
    .padding(12)
    .frame(maxWidth: .infinity, alignment: .leading)
    .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
    .previewMenu(isPresented: $open, aspectRatio: 4 / 3) {
      PreviewMenuItem("Share album", icon: Ph.shareNetwork.regular) {}
      PreviewMenuItem("Delete album", icon: Ph.trash.regular, variant: .destructive) {}
    } preview: {
      VStack(alignment: .leading, spacing: 0) {
        Text("Trip to the coast")
          .textStyle(.lg, weight: .semibold)
        Text("48 photos · Updated today")
          .textStyle(.sm)
      }
      .foregroundStyle(EsuissColor.primaryForeground)
      .padding(16)
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
      .background(EsuissColor.primary)
    }
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ShareNetwork
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.PreviewMenu
import com.esuiss.ui.interactions.PreviewMenuItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun PreviewMenuCustomPreviewOpenExample() {
    PreviewMenuAlbum(defaultOpen = true)
}

@Composable
fun PreviewMenuAlbum(defaultOpen: Boolean) {
    val colors = EsuissTheme.colors
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    PreviewMenu(
        open = open,
        onOpenChange = { open = it },
        aspectRatio = 4f / 3f,
        trigger = {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(colors.muted, RoundedCornerShape(Radius.xl))
                    .padding(12.dp),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Box(Modifier.size(48.dp).background(Palette.blue500, RoundedCornerShape(Radius.lg)))
                Column {
                    Text("Trip to the coast", style = Typography.base.copy(fontWeight = FontWeight.SemiBold))
                    Text("48 photos", variant = TextVariant.Muted)
                }
            }
        },
        preview = {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Palette.blue500)
                    .padding(16.dp),
                verticalArrangement = Arrangement.Bottom,
            ) {
                Text("Trip to the coast", style = Typography.lg.copy(color = colors.primaryForeground, fontWeight = FontWeight.SemiBold))
                Text("48 photos · Updated today", style = Typography.sm.copy(color = colors.primaryForeground))
            }
        },
    ) {
        PreviewMenuItem("Share album", onClick = {}, icon = PhosphorIcons.Regular.ShareNetwork)
        PreviewMenuItem("Delete album", onClick = {}, icon = PhosphorIcons.Regular.Trash, variant = MenuItemVariant.Destructive)
    }
}

API Reference

01

PreviewMenu

The root that owns the open state and preview size.

PropTypeDefaultDescription
openboolean-Controls whether the preview is open.
defaultOpenboolean-Whether the preview starts open when uncontrolled.
onOpenChange(open: boolean) => void-Called when the preview opens or closes.
aspectRationumber-Width-to-height ratio of the lifted preview.
childrenReact.ReactNode-
02

PreviewMenuTrigger

The element that opens the preview on long press.

Also accepts the props of React.ComponentProps<typeof PreviewMenuPrimitive.Trigger>.

03

PreviewMenuContent

The scrim, the lifted preview and the menu.

PropTypeDefaultDescription
previewReact.ReactNode-Content to show in the preview instead of the trigger's children.
previewClassNamestring-Classes for the preview container.
portalHoststring-Name of a portal host to render into.

Also accepts the props of React.ComponentProps<typeof PreviewMenuPrimitive.Popup>.

04

PreviewMenuItem

A menu action.

PropTypeDefaultDescription
variant"default" | "destructive""default"default or destructive, which uses text-destructive.
childrenReact.ReactNode-

Also accepts the props of React.ComponentProps<typeof PreviewMenuPrimitive.Item>.

05

PreviewMenuSeparator

A thick divider between groups of items.

Also accepts the props of React.ComponentProps<typeof PreviewMenuPrimitive.Separator>.

Accessibility

  • The trigger has a longpress accessibility action, so screen reader users can open the menu without holding.
  • The trigger exposes expanded and disabled states.
  • The preview is hidden from screen readers; focus goes to the menu.

Notes

  • A medium impact haptic plays when the menu opens, if the app provides an adapter through HapticsProvider.
  • The menu uses bg-popover with a border that is stronger in dark mode, and the backdrop uses bg-black/50.
  • The preview stays within the top and bottom safe areas and leaves room for the measured height of the menu.
  • Animations respect the system reduce motion setting.
  • On iOS the content renders in a full-window overlay so it appears above native screens.
iOS
Preview Menu (open by default) on iOS in light modePreview Menu (open by default) on iOS in dark mode
import { CopyIcon, HeartIcon, ShareNetworkIcon, TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { PreviewMenu, PreviewMenuContent, PreviewMenuItem, PreviewMenuSeparator, PreviewMenuTrigger } from "@/components/interactions/preview-menu"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const photos = [
  "bg-blue-500",
  "bg-orange-500",
  "bg-green-500",
  "bg-indigo-500",
  "bg-yellow-500",
  "bg-pink-500",
  "bg-teal-500",
  "bg-purple-500",
  "bg-teal-400",
]

function PhotoGrid({
  withDisabled = false,
  openIndex,
}: {
  withDisabled?: boolean
  openIndex?: number
}) {
  const [log, setLog] = React.useState("Touch and hold a photo.")
  return (
    <View className="gap-3">
      <View className="flex-row flex-wrap gap-2">
        {photos.map((tone, index) => {
          const label = `Photo ${index + 1}`
          return (
            <PreviewMenu key={tone} defaultOpen={index === openIndex}>
              <PreviewMenuTrigger
                accessibilityLabel={label}
                className="aspect-square w-[31%] overflow-hidden rounded-2xl"
              >
                <View className={`h-full w-full ${tone}`} />
              </PreviewMenuTrigger>
              <PreviewMenuContent>
                <PreviewMenuItem onPress={() => setLog(`Copied ${label}`)}>
                  Copy
                  <Icon as={CopyIcon} />
                </PreviewMenuItem>
                <PreviewMenuItem onPress={() => setLog(`Shared ${label}`)}>
                  Share
                  <Icon as={ShareNetworkIcon} />
                </PreviewMenuItem>
                <PreviewMenuItem
                  disabled={withDisabled}
                  onPress={() => setLog(`Favorited ${label}`)}
                >
                  Favorite
                  <Icon as={HeartIcon} />
                </PreviewMenuItem>
                <PreviewMenuSeparator />
                <PreviewMenuItem
                  variant="destructive"
                  onPress={() => setLog(`Deleted ${label}`)}
                >
                  Delete
                  <Icon as={TrashIcon} />
                </PreviewMenuItem>
              </PreviewMenuContent>
            </PreviewMenu>
          )
        })}
      </View>
      <Text variant="muted">{log}</Text>
    </View>
  )
}

export function PreviewMenuOpenByDefaultExample() {
  return (
    <PhotoGrid openIndex={1} />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct PreviewMenuOpenByDefaultExample: View {
  var body: some View {
    PreviewMenuPhotoGrid(openIndex: 1)
  }
}

let previewMenuTones: [Color] = [
  EsuissColor.primary,
  Palette.amber500,
  Palette.green500,
  Palette.cyan500,
  EsuissColor.destructive,
  Palette.indigo500,
  EsuissColor.mutedForeground,
  EsuissColor.input,
  EsuissColor.secondary,
]

struct PreviewMenuPhotoGrid: View {
  var withDisabled = false
  var openIndex: Int?

  @State private var log: LocalizedStringKey = "Touch and hold a photo."

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      GeometryReader { proxy in
        let side = ((proxy.size.width - 16) / 3).rounded(.down)
        FlowRow(spacing: 8) {
          ForEach(previewMenuTones.indices, id: \.self) { index in
            PreviewMenuPhoto(
              index: index,
              side: side,
              withDisabled: withDisabled,
              open: index == openIndex,
              log: $log
            )
          }
        }
      }
      .aspectRatio(1, contentMode: .fit)
      Text(log)
        .textVariant(.muted)
    }
  }
}

struct PreviewMenuPhoto: View {
  let index: Int
  let side: CGFloat
  let withDisabled: Bool
  @Binding var log: LocalizedStringKey

  @State private var open: Bool

  init(index: Int, side: CGFloat, withDisabled: Bool, open: Bool, log: Binding<LocalizedStringKey>) {
    self.index = index
    self.side = side
    self.withDisabled = withDisabled
    _log = log
    _open = State(initialValue: open)
  }

  var body: some View {
    let label = "Photo \(index + 1)"
    previewMenuTones[index]
      .frame(width: side, height: side)
      .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
      .accessibilityElement()
      .accessibilityLabel(Text(verbatim: label))
      .previewMenu(isPresented: $open) {
        PreviewMenuItem("Copy", icon: Ph.copy.regular) { log = "Copied \(label)" }
        PreviewMenuItem("Share", icon: Ph.shareNetwork.regular) { log = "Shared \(label)" }
        PreviewMenuItem("Favorite", icon: Ph.heart.regular) { log = "Favorited \(label)" }
          .disabled(withDisabled)
        PreviewMenuSeparator()
        PreviewMenuItem("Delete", icon: Ph.trash.regular, variant: .destructive) { log = "Deleted \(label)" }
      }
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Copy
import com.adamglin.phosphoricons.regular.Heart
import com.adamglin.phosphoricons.regular.ShareNetwork
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.PreviewMenu
import com.esuiss.ui.interactions.PreviewMenuItem
import com.esuiss.ui.interactions.PreviewMenuSeparator
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius

@Composable
fun PreviewMenuOpenByDefaultExample() {
    PreviewMenuPhotoGrid(openIndex = 1)
}

@Composable
fun previewMenuTones(): List<Color> {
    val colors = EsuissTheme.colors
    return listOf(
        Palette.blue500,
        Palette.orange500,
        Palette.green500,
        Palette.indigo500,
        Palette.yellow500,
        Palette.pink500,
        Palette.teal500,
        Palette.purple500,
        Palette.teal400,
    )
}

@Composable
fun PreviewMenuPhotoGrid(withDisabled: Boolean = false, openIndex: Int? = null) {
    var log by rememberSaveable { mutableStateOf("Touch and hold a photo.") }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        FlowRow(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            previewMenuTones().forEachIndexed { index, tone ->
                val label = "Photo ${index + 1}"
                var open by rememberSaveable(index) { mutableStateOf(index == openIndex) }
                PreviewMenu(
                    open = open,
                    onOpenChange = { open = it },
                    trigger = {
                        Box(
                            Modifier
                                .fillMaxSize()
                                .clip(shape)
                                .background(tone, shape)
                                .semantics { contentDescription = label },
                        )
                    },
                    modifier = Modifier.fillMaxWidth(0.31f).aspectRatio(1f),
                ) {
                    PreviewMenuItem("Copy", onClick = { log = "Copied $label" }, icon = PhosphorIcons.Regular.Copy)
                    PreviewMenuItem("Share", onClick = { log = "Shared $label" }, icon = PhosphorIcons.Regular.ShareNetwork)
                    PreviewMenuItem(
                        "Favorite",
                        onClick = { log = "Favorited $label" },
                        icon = PhosphorIcons.Regular.Heart,
                        enabled = !withDisabled,
                    )
                    PreviewMenuSeparator()
                    PreviewMenuItem(
                        "Delete",
                        onClick = { log = "Deleted $label" },
                        icon = PhosphorIcons.Regular.Trash,
                        variant = MenuItemVariant.Destructive,
                    )
                }
            }
        }
        Text(log, variant = TextVariant.Muted)
    }
}
components/interactions/preview-menu.tsx
"use client"

import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import * as React from "react"
import { cn } from "@/lib/utils"

type PreviewMenuFrame = {
  top: number
  left: number
  width: number
  height: number
  from: string
  clip: string
  source: { width: number; height: number; scale: number }
}
import {
  CopyIcon,
  HeartIcon,
  ShareNetworkIcon,
  TrashIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  PreviewMenu,
  PreviewMenuContent,
  PreviewMenuItem,
  PreviewMenuSeparator,
  PreviewMenuTrigger,
} from "@/components/interactions/preview-menu"
import { cn } from "@/lib/utils"

const photos = [
  "from-blue-500 to-purple-500",
  "from-orange-500 to-pink-500",
  "from-green-500 to-teal-500",
  "from-indigo-500 to-blue-500",
  "from-yellow-500 to-orange-500",
  "from-pink-500 to-red-500",
  "from-teal-500 to-cyan-500",
  "from-purple-500 to-indigo-500",
  "from-teal-400 to-green-500",
]

function Photo({ tone, label }: { tone: string; label: string }) {
  return (
    <div
      role="img"
      aria-label={label}
      className={cn("aspect-square size-full bg-linear-to-br", tone)}
    />
  )
}

function PhotoGrid({ withDisabled = false }: { withDisabled?: boolean }) {
  const [log, setLog] = React.useState(
    "Touch and hold a photo, or right-click it."
  )

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-3">
      <div className="grid grid-cols-3 gap-2">
        {photos.map((tone, index) => {
          const label = `Photo ${index + 1}`
          return (
            <PreviewMenu key={tone}>
              <PreviewMenuTrigger className="aspect-square overflow-hidden rounded-2xl">
                <Photo tone={tone} label={label} />
              </PreviewMenuTrigger>
              <PreviewMenuContent>
                <PreviewMenuItem onClick={() => setLog(`Copied ${label}`)}>
                  Copy
                  <CopyIcon />
                </PreviewMenuItem>
                <PreviewMenuItem onClick={() => setLog(`Shared ${label}`)}>
                  Share
                  <ShareNetworkIcon />
                </PreviewMenuItem>
                <PreviewMenuItem
                  disabled={withDisabled}
                  onClick={() => setLog(`Favorited ${label}`)}
                >
                  Favorite
                  <HeartIcon />
                </PreviewMenuItem>
                <PreviewMenuSeparator />
                <PreviewMenuItem
                  variant="destructive"
                  onClick={() => setLog(`Deleted ${label}`)}
                >
                  Delete
                  <TrashIcon />
                </PreviewMenuItem>
              </PreviewMenuContent>
            </PreviewMenu>
          )
        })}
      </div>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function PreviewMenuDisabledItem() {
  return <PhotoGrid withDisabled />
}
import { ShareNetworkIcon, TrashIcon } from "@phosphor-icons/react"
import {
  PreviewMenu,
  PreviewMenuContent,
  PreviewMenuItem,
  PreviewMenuTrigger,
} from "@/components/interactions/preview-menu"

export default function PreviewMenuCustomPreview() {
  return (
    <div className="mx-auto w-full max-w-md">
      <PreviewMenu aspectRatio={4 / 3}>
        <PreviewMenuTrigger className="flex items-center gap-3 rounded-xl bg-muted p-3">
          <div className="size-12 shrink-0 rounded-lg bg-linear-to-br from-blue-500 to-purple-500" />
          <div className="flex flex-col">
            <span className="font-semibold">Trip to the coast</span>
            <span className="text-sm text-muted-foreground">48 photos</span>
          </div>
        </PreviewMenuTrigger>
        <PreviewMenuContent
          preview={
            <div className="flex flex-col justify-end bg-linear-to-br from-blue-500 to-purple-500 p-4 text-primary-foreground">
              <span className="text-lg font-semibold">Trip to the coast</span>
              <span className="text-sm">48 photos · Updated today</span>
            </div>
          }
        >
          <PreviewMenuItem>
            Share album
            <ShareNetworkIcon />
          </PreviewMenuItem>
          <PreviewMenuItem variant="destructive">
            Delete album
            <TrashIcon />
          </PreviewMenuItem>
        </PreviewMenuContent>
      </PreviewMenu>
    </div>
  )
}
nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
nativewind/components/interactions/preview-menu.tsx
import { cn } from "cn"
import * as React from "react"
import { Platform, View } from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PreviewMenuPrimitive from "@/components/primitives/preview-menu"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { IconClassContext } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

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

function usePreviewMenu() {
stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
stylesheet/components/interactions/preview-menu.tsx
import * as React from "react"
import { Platform, StyleSheet, View, type ViewStyle } from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PreviewMenuPrimitive from "@/components/primitives/preview-menu"
import { resolveStyle } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { IconSizeContext } from "@/components/ui/icon"
import { Text, TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

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

const styles = StyleSheet.create({