# 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.

Source: https://ui.suiss.app/docs/interactions/preview-menu

**React**

**Example**

```tsx
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

```bash title="Terminal"
npx shadcn@latest add @esuiss/preview-menu
```

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

```bash title="Terminal"
npx @esuiss/ui@latest add interactions preview-menu
```

### Manual

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

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

2. Copy the source into `components/interactions/preview-menu.tsx`:

```tsx title="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 }
}

type PreviewMenuContextProps = {
  open: boolean
  lifted: boolean
  frame: PreviewMenuFrame | null
  triggerRef: React.RefObject<HTMLElement | null>
  contentRef: React.RefObject<React.ReactNode>
}

const PreviewMenuContext = React.createContext<PreviewMenuContextProps | null>(
  null
)

function usePreviewMenu() {
  const context = React.useContext(PreviewMenuContext)

  if (!context) {
    throw new Error("usePreviewMenu must be used within a <PreviewMenu />")
  }

  return context
}

const EDGE = 16
const MENU_SPACE = 260
const MAX_WIDTH = 360

function cornerRadius(trigger: HTMLElement) {
  for (const element of [trigger, trigger.firstElementChild]) {
    if (!element) continue
    const radius = Number.parseFloat(
      getComputedStyle(element).borderTopLeftRadius
    )
    if (radius > 0) return radius
  }
  return 0
}

function measureFrame(
  trigger: HTMLElement,
  aspectRatio: number | undefined
): PreviewMenuFrame {
  const rect = trigger.getBoundingClientRect()
  const viewportWidth = document.documentElement.clientWidth
  const viewportHeight = window.innerHeight
  const ratio = aspectRatio
    ? 1 / aspectRatio
    : rect.height / Math.max(rect.width, 1)
  let width = Math.min(viewportWidth - EDGE * 2, MAX_WIDTH)
  let height = width * ratio
  const maxHeight = viewportHeight - MENU_SPACE - EDGE * 2
  if (height > maxHeight) {
    height = Math.max(maxHeight, rect.height)
    width = height / Math.max(ratio, 0.01)
  }
  const left = (viewportWidth - width) / 2
  const centered = rect.top + rect.height / 2 - height / 2
  const top = Math.min(
    Math.max(centered, EDGE),
    Math.max(EDGE, viewportHeight - MENU_SPACE - height)
  )
  const scale = Math.max(rect.width / width, rect.height / height)
  const dx = rect.left + rect.width / 2 - (left + (width * scale) / 2)
  const dy = rect.top + rect.height / 2 - (top + (height * scale) / 2)
  const insetX = (width - rect.width / scale) / 2
  const insetY = (height - rect.height / scale) / 2
  const radius = cornerRadius(trigger) / scale
  return {
    top,
    left,
    width,
    height,
    from: `translate(${dx}px, ${dy}px) scale(${scale})`,
    clip: `inset(${insetY}px ${insetX}px round ${radius}px)`,
    source: { width: rect.width, height: rect.height, scale: 1 / scale },
  }
}

function PreviewMenu({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  aspectRatio,
  children,
}: {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  aspectRatio?: number
  children?: React.ReactNode
}) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const open = openProp ?? uncontrolledOpen
  const [frame, setFrame] = React.useState<PreviewMenuFrame | null>(null)
  const triggerRef = React.useRef<HTMLElement | null>(null)
  const contentRef = React.useRef<React.ReactNode>(null)

  const [lifted, setLifted] = React.useState(open)

  const setOpen = (next: boolean) => {
    if (next) setLifted(true)
    if (next && triggerRef.current)
      setFrame(measureFrame(triggerRef.current, aspectRatio))
    if (openProp === undefined) setUncontrolledOpen(next)
    onOpenChange?.(next)
  }

  React.useLayoutEffect(() => {
    if (open && !frame && triggerRef.current) {
      setFrame(measureFrame(triggerRef.current, aspectRatio))
    }
  }, [open, frame, aspectRatio])

  return (
    <PreviewMenuContext.Provider
      value={{ open, lifted, frame, triggerRef, contentRef }}
    >
      <ContextMenuPrimitive.Root
        open={open}
        onOpenChange={(next) => setOpen(next)}
        onOpenChangeComplete={(next) => setLifted(next)}
      >
        {children}
      </ContextMenuPrimitive.Root>
    </PreviewMenuContext.Provider>
  )
}

function PreviewMenuTrigger({
  className,
  children,
  ref,
  ...props
}: ContextMenuPrimitive.Trigger.Props) {
  const { open, lifted, triggerRef, contentRef } = usePreviewMenu()
  contentRef.current = children as React.ReactNode

  return (
    <ContextMenuPrimitive.Trigger
      ref={(node: HTMLDivElement | null) => {
        triggerRef.current = node
        if (typeof ref === "function") return ref(node)
        if (ref) ref.current = node
      }}
      data-slot="preview-menu-trigger"
      className={cn(
        "touch-manipulation select-none [-webkit-touch-callout:none] data-lifted:invisible",
        className
      )}
      data-previewing={open ? "" : undefined}
      data-lifted={open || lifted ? "" : undefined}
      {...props}
    >
      {children}
    </ContextMenuPrimitive.Trigger>
  )
}

function PreviewMenuContent({
  preview,
  previewClassName,
  className,
  children,
  ...props
}: ContextMenuPrimitive.Popup.Props & {
  preview?: React.ReactNode
  previewClassName?: string
}) {
  const { open, frame, contentRef, triggerRef } = usePreviewMenu()
  const previewRef = React.useRef<HTMLDivElement>(null)
  const custom = preview !== undefined

  const attachSnapshot = React.useCallback(
    (holder: HTMLDivElement | null) => {
      const trigger = triggerRef.current
      if (!holder || !trigger) return
      const snapshot = trigger.cloneNode(true) as HTMLElement
      for (const name of [
        "data-lifted",
        "data-previewing",
        "data-popup-open",
        "data-pressed",
        "data-slot",
        "id",
      ])
        snapshot.removeAttribute(name)
      snapshot.style.margin = "0"
      snapshot.style.width = "100%"
      snapshot.style.height = "100%"
      holder.replaceChildren(snapshot)
    },
    [triggerRef]
  )

  return (
    <ContextMenuPrimitive.Portal>
      <ContextMenuPrimitive.Backdrop
        data-slot="preview-menu-backdrop"
        className="fixed inset-0 z-50 bg-black/20 backdrop-blur-xl transition-opacity duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none"
      />
      {frame && (
        <div
          ref={previewRef}
          data-slot="preview-menu-preview"
          data-closed={open ? undefined : ""}
          aria-hidden
          style={
            {
              top: frame.top,
              left: frame.left,
              width: frame.width,
              height: frame.height,
              "--preview-menu-from": frame.from,
              "--preview-menu-clip": frame.clip,
            } as React.CSSProperties
          }
          className="pointer-events-none fixed z-50 origin-top-left drop-shadow-2xl transition-[transform,filter] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] starting:transform-(--preview-menu-from) starting:drop-shadow-none data-closed:transform-(--preview-menu-from) data-closed:drop-shadow-none data-closed:duration-300 motion-reduce:transition-none"
        >
          <div
            className={cn(
              "relative size-full overflow-hidden transition-[clip-path] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] [clip-path:inset(0_round_var(--radius-2xl))] starting:[clip-path:var(--preview-menu-clip)] in-data-closed:duration-300 in-data-closed:[clip-path:var(--preview-menu-clip)] motion-reduce:transition-none *:size-full",
              previewClassName
            )}
          >
            {preview ?? contentRef.current}
          </div>
          {custom && (
            <div
              ref={attachSnapshot}
              inert
              style={{
                width: frame.source.width,
                height: frame.source.height,
                transform: `translate(-50%, -50%) scale(${frame.source.scale})`,
              }}
              className="absolute top-1/2 left-1/2 opacity-0 transition-opacity duration-150 ease-out starting:opacity-100 in-data-closed:opacity-100 in-data-closed:delay-100 in-data-closed:duration-200 motion-reduce:transition-none"
            />
          )}
        </div>
      )}
      <ContextMenuPrimitive.Positioner
        anchor={previewRef}
        side="bottom"
        align="center"
        sideOffset={12}
        collisionPadding={EDGE}
        className="isolate z-50 outline-none"
      >
        <ContextMenuPrimitive.Popup
          data-slot="preview-menu-content"
          className={cn(
            "max-h-(--available-height) min-w-60 origin-(--transform-origin) overflow-y-auto rounded-2xl bg-popover text-popover-foreground shadow-xl ring-1 ring-foreground/5 transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-300 data-starting-style:scale-90 data-starting-style:opacity-0 motion-reduce:transition-none dark:ring-foreground/10",
            className
          )}
          {...props}
        >
          {children}
        </ContextMenuPrimitive.Popup>
      </ContextMenuPrimitive.Positioner>
    </ContextMenuPrimitive.Portal>
  )
}

function PreviewMenuItem({
  className,
  variant = "default",
  ...props
}: ContextMenuPrimitive.Item.Props & {
  variant?: "default" | "destructive"
}) {
  return (
    <ContextMenuPrimitive.Item
      data-slot="preview-menu-item"
      data-variant={variant}
      className={cn(
        "flex min-h-11 cursor-default items-center justify-between gap-6 px-4 py-2.75 text-base outline-none select-none not-first:border-t not-first:border-border data-disabled:pointer-events-none data-highlighted:bg-accent data-[variant=destructive]:text-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5 data-highlighted:text-accent-foreground data-disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
}

function PreviewMenuSeparator({
  className,
  ...props
}: ContextMenuPrimitive.Separator.Props) {
  return (
    <ContextMenuPrimitive.Separator
      data-slot="preview-menu-separator"
      className={cn("h-2 bg-border/40", className)}
      {...props}
    />
  )
}

export {
  PreviewMenu,
  PreviewMenuContent,
  PreviewMenuItem,
  PreviewMenuSeparator,
  PreviewMenuTrigger,
  usePreviewMenu,
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { PreviewMenu, PreviewMenuTrigger, PreviewMenuContent, PreviewMenuItem, PreviewMenuSeparator } from "@/components/interactions/preview-menu"
```

```tsx
<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.

**Example**

```tsx
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 />
}
```

### Custom preview

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

**Example**

```tsx
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>
  )
}
```

## API Reference

- `PreviewMenu`
- `PreviewMenuTrigger`
- `PreviewMenuContent`
  - `PreviewMenuItem`

### PreviewMenu

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Whether the menu is open, for controlled use. |
| `defaultOpen` | `boolean` | `false` | Whether the menu starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the menu opens or closes. |
| `aspectRatio` | `number` | - | The width-to-height ratio of the preview; defaults to the trigger's ratio. |
| `children` | `React.ReactNode` | - | The trigger and content. |

### PreviewMenuTrigger

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

Also accepts the props of the primitive part it wraps.

### PreviewMenuContent

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `preview` | `React.ReactNode` | - | Custom content to show in the preview instead of the trigger's content. |
| `previewClassName` | `string` | - | Classes for the clipped preview container. |

Also accepts the props of the primitive part it wraps.

### PreviewMenuItem

An action in the menu.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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.

### 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.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx
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} />
  )
}
```

```swift
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)" }
      }
  }
}
```

```kotlin
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)
    }
}
```

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

**React Native**

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

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

```bash title="Terminal"
npx @esuiss/ui@latest add preview-menu
```

**SwiftUI**

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

**Jetpack Compose**

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

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

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/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.

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

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

4. Copy the source into `components/interactions/preview-menu.tsx`:

```tsx title="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() {
  const context = PreviewMenuPrimitive.useRootContext("usePreviewMenu")
  return { open: context.open, lifted: context.lifted, frame: context.frame }
}

function PreviewMenu({
  open,
  defaultOpen,
  onOpenChange,
  aspectRatio,
  children,
}: {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  aspectRatio?: number
  children?: React.ReactNode
}) {
  const insets = useSafeAreaInsets()
  const safeArea = React.useMemo(
    () => ({ top: insets.top, bottom: insets.bottom }),
    [insets.top, insets.bottom]
  )
  return (
    <PreviewMenuPrimitive.Root
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={(next) => onOpenChange?.(next)}
      aspectRatio={aspectRatio}
      insets={safeArea}
    >
      {children}
    </PreviewMenuPrimitive.Root>
  )
}

function PreviewMenuTrigger({
  className,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Trigger>) {
  return (
    <PreviewMenuPrimitive.Trigger
      className={(state) =>
        cn(state.lifted && "opacity-0", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function PreviewMenuLayer({
  layer,
  children,
}: {
  layer: PreviewMenuPrimitive.PreviewLayer
  children?: React.ReactNode
}) {
  const motion = PreviewMenuPrimitive.useLayer(layer)
  if (!motion) return null
  return (
    <AnimatedView
      entering={motion.entering}
      exiting={motion.exiting}
      style={motion.style}
    >
      {children ?? motion.content}
    </AnimatedView>
  )
}

function PreviewMenuPreviewBody({
  className,
  children,
}: {
  className?: string
  children?: React.ReactNode
}) {
  const { contentRef } =
    PreviewMenuPrimitive.useRootContext("PreviewMenuContent")
  return (
    <View
      className={cn(
        "h-full w-full overflow-hidden rounded-2xl bg-background",
        className
      )}
    >
      {children ? (
        <>
          <PreviewMenuLayer layer="source" />
          <PreviewMenuLayer layer="preview">{children}</PreviewMenuLayer>
        </>
      ) : (
        contentRef.current
      )}
    </View>
  )
}

function PreviewMenuBackdrop() {
  const { ready } = PreviewMenuPrimitive.useRootContext("PreviewMenuContent")
  if (!ready) return null
  return (
    <AnimatedView
      entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(300).reduceMotion(ReduceMotion.System)}
      className="absolute top-0 right-0 bottom-0 left-0"
      style={{ pointerEvents: "none" }}
    >
      <PreviewMenuPrimitive.Backdrop className="bg-black/50" />
    </AnimatedView>
  )
}

function PreviewMenuContent({
  preview,
  previewClassName,
  portalHost,
  className,
  children,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Popup> & {
  preview?: React.ReactNode
  previewClassName?: string
  portalHost?: string
}) {
  return (
    <PreviewMenuPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <PreviewMenuBackdrop />
        <PreviewMenuPrimitive.Preview className="rounded-2xl shadow-xl">
          <PreviewMenuPreviewBody className={previewClassName}>
            {preview}
          </PreviewMenuPreviewBody>
        </PreviewMenuPrimitive.Preview>
        <PreviewMenuPrimitive.Positioner>
          <View className="rounded-2xl shadow-xl">
            <PreviewMenuPrimitive.Popup
              className={(state) =>
                cn(
                  "min-w-60 overflow-hidden rounded-2xl border border-foreground/5 bg-popover dark:border-foreground/10",
                  resolveClassName(className, state)
                )
              }
              {...props}
            >
              <View className="-mt-px">{children}</View>
            </PreviewMenuPrimitive.Popup>
          </View>
        </PreviewMenuPrimitive.Positioner>
      </FullWindowOverlay>
    </PreviewMenuPrimitive.Portal>
  )
}

function PreviewMenuItem({
  className,
  variant = "default",
  children,
  ...props
}: Omit<React.ComponentProps<typeof PreviewMenuPrimitive.Item>, "children"> & {
  variant?: "default" | "destructive"
  children?: React.ReactNode
}) {
  return (
    <TextClassContext.Provider
      value={cn(
        "text-base text-foreground",
        variant === "destructive" && "text-destructive",
        props.disabled && "opacity-50"
      )}
    >
      <IconClassContext.Provider value="size-5">
        <PreviewMenuPrimitive.Item
          className={(state) =>
            cn(
              "min-h-11 flex-row items-center justify-between gap-6 border-t border-border px-4 py-2.75",
              state.highlighted && "opacity-80",
              resolveClassName(className, state)
            )
          }
          {...props}
        >
          {React.Children.map(children, (child) =>
            typeof child === "string" || typeof child === "number" ? (
              <Text className="shrink">{child}</Text>
            ) : (
              child
            )
          )}
        </PreviewMenuPrimitive.Item>
      </IconClassContext.Provider>
    </TextClassContext.Provider>
  )
}

function PreviewMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Separator>) {
  return (
    <PreviewMenuPrimitive.Separator
      className={(state) =>
        cn("h-2 bg-border/40", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  PreviewMenu,
  PreviewMenuContent,
  PreviewMenuItem,
  PreviewMenuSeparator,
  PreviewMenuTrigger,
  usePreviewMenu,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/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.

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

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

4. Copy the source into `components/interactions/preview-menu.tsx`:

```tsx title="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() {
  const context = PreviewMenuPrimitive.useRootContext("usePreviewMenu")
  return { open: context.open, lifted: context.lifted, frame: context.frame }
}

function PreviewMenu({
  open,
  defaultOpen,
  onOpenChange,
  aspectRatio,
  children,
}: {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  aspectRatio?: number
  children?: React.ReactNode
}) {
  const insets = useSafeAreaInsets()
  const safeArea = React.useMemo(
    () => ({ top: insets.top, bottom: insets.bottom }),
    [insets.top, insets.bottom]
  )
  return (
    <PreviewMenuPrimitive.Root
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={(next) => onOpenChange?.(next)}
      aspectRatio={aspectRatio}
      insets={safeArea}
    >
      {children}
    </PreviewMenuPrimitive.Root>
  )
}

function PreviewMenuTrigger({
  className,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Trigger>) {
  return (
    <PreviewMenuPrimitive.Trigger
      className={(state) =>
        cn(state.lifted && "opacity-0", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function PreviewMenuLayer({
  layer,
  children,
}: {
  layer: PreviewMenuPrimitive.PreviewLayer
  children?: React.ReactNode
}) {
  const motion = PreviewMenuPrimitive.useLayer(layer)
  if (!motion) return null
  return (
    <AnimatedView
      entering={motion.entering}
      exiting={motion.exiting}
      style={motion.style}
    >
      {children ?? motion.content}
    </AnimatedView>
  )
}

function PreviewMenuPreviewBody({
  className,
  children,
}: {
  className?: string
  children?: React.ReactNode
}) {
  const { contentRef } =
    PreviewMenuPrimitive.useRootContext("PreviewMenuContent")
  return (
    <View
      className={cn(
        "h-full w-full overflow-hidden rounded-2xl bg-background",
        className
      )}
    >
      {children ? (
        <>
          <PreviewMenuLayer layer="source" />
          <PreviewMenuLayer layer="preview">{children}</PreviewMenuLayer>
        </>
      ) : (
        contentRef.current
      )}
    </View>
  )
}

function PreviewMenuBackdrop() {
  const { ready } = PreviewMenuPrimitive.useRootContext("PreviewMenuContent")
  if (!ready) return null
  return (
    <AnimatedView
      entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(300).reduceMotion(ReduceMotion.System)}
      className="absolute top-0 right-0 bottom-0 left-0"
      style={{ pointerEvents: "none" }}
    >
      <PreviewMenuPrimitive.Backdrop className="bg-black/50" />
    </AnimatedView>
  )
}

function PreviewMenuContent({
  preview,
  previewClassName,
  portalHost,
  className,
  children,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Popup> & {
  preview?: React.ReactNode
  previewClassName?: string
  portalHost?: string
}) {
  return (
    <PreviewMenuPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <PreviewMenuBackdrop />
        <PreviewMenuPrimitive.Preview className="rounded-2xl shadow-xl">
          <PreviewMenuPreviewBody className={previewClassName}>
            {preview}
          </PreviewMenuPreviewBody>
        </PreviewMenuPrimitive.Preview>
        <PreviewMenuPrimitive.Positioner>
          <View className="rounded-2xl shadow-xl">
            <PreviewMenuPrimitive.Popup
              className={(state) =>
                cn(
                  "min-w-60 overflow-hidden rounded-2xl border border-foreground-5 bg-popover dark:border-foreground-10",
                  resolveClassName(className, state)
                )
              }
              {...props}
            >
              <View className="-mt-px">{children}</View>
            </PreviewMenuPrimitive.Popup>
          </View>
        </PreviewMenuPrimitive.Positioner>
      </FullWindowOverlay>
    </PreviewMenuPrimitive.Portal>
  )
}

function PreviewMenuItem({
  className,
  variant = "default",
  children,
  ...props
}: Omit<React.ComponentProps<typeof PreviewMenuPrimitive.Item>, "children"> & {
  variant?: "default" | "destructive"
  children?: React.ReactNode
}) {
  return (
    <TextClassContext.Provider
      value={cn(
        "text-base text-foreground",
        variant === "destructive" && "text-destructive",
        props.disabled && "opacity-50"
      )}
    >
      <IconClassContext.Provider value="size-5">
        <PreviewMenuPrimitive.Item
          className={(state) =>
            cn(
              "min-h-11 flex-row items-center justify-between gap-6 border-t border-border px-4 py-[0.6875rem]",
              state.highlighted && "opacity-80",
              resolveClassName(className, state)
            )
          }
          {...props}
        >
          {React.Children.map(children, (child) =>
            typeof child === "string" || typeof child === "number" ? (
              <Text className="shrink">{child}</Text>
            ) : (
              child
            )
          )}
        </PreviewMenuPrimitive.Item>
      </IconClassContext.Provider>
    </TextClassContext.Provider>
  )
}

function PreviewMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Separator>) {
  return (
    <PreviewMenuPrimitive.Separator
      className={(state) =>
        cn("h-2 bg-border-40", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

export {
  PreviewMenu,
  PreviewMenuContent,
  PreviewMenuItem,
  PreviewMenuSeparator,
  PreviewMenuTrigger,
  usePreviewMenu,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/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.

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

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

4. Copy the source into `components/interactions/preview-menu.tsx`:

```tsx title="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({
  backdropFrame: {
    position: "absolute",
    top: 0,
    right: 0,
    bottom: 0,
    left: 0,
    pointerEvents: "none",
  },
  preview: { borderRadius: radius["2xl"], ...shadow.xl },
  previewBody: {
    width: "100%",
    height: "100%",
    overflow: "hidden",
    borderRadius: radius["2xl"],
  },
  popupFrame: { borderRadius: radius["2xl"], ...shadow.xl },
  popup: {
    minWidth: 240,
    overflow: "hidden",
    borderRadius: radius["2xl"],
    borderWidth: 1,
  },
  list: { marginTop: -1 },
  item: {
    minHeight: 44,
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    gap: 24,
    borderTopWidth: 1,
    paddingHorizontal: 16,
    paddingVertical: 11,
  },
  itemText: { flexShrink: 1 },
  separator: { height: 8 },
})

function withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

function usePreviewMenu() {
  const context = PreviewMenuPrimitive.useRootContext("usePreviewMenu")
  return { open: context.open, lifted: context.lifted, frame: context.frame }
}

function PreviewMenu({
  open,
  defaultOpen,
  onOpenChange,
  aspectRatio,
  children,
}: {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  aspectRatio?: number
  children?: React.ReactNode
}) {
  const insets = useSafeAreaInsets()
  const safeArea = React.useMemo(
    () => ({ top: insets.top, bottom: insets.bottom }),
    [insets.top, insets.bottom]
  )
  return (
    <PreviewMenuPrimitive.Root
      open={open}
      defaultOpen={defaultOpen}
      onOpenChange={(next) => onOpenChange?.(next)}
      aspectRatio={aspectRatio}
      insets={safeArea}
    >
      {children}
    </PreviewMenuPrimitive.Root>
  )
}

function PreviewMenuTrigger({
  style,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Trigger>) {
  return (
    <PreviewMenuPrimitive.Trigger
      style={(state) => [
        state.lifted && { opacity: 0 },
        resolveStyle(style, state) as ViewStyle,
      ]}
      {...props}
    />
  )
}

function PreviewMenuLayer({
  layer,
  children,
}: {
  layer: PreviewMenuPrimitive.PreviewLayer
  children?: React.ReactNode
}) {
  const motion = PreviewMenuPrimitive.useLayer(layer)
  if (!motion) return null
  return (
    <AnimatedView
      entering={motion.entering}
      exiting={motion.exiting}
      style={motion.style}
    >
      {children ?? motion.content}
    </AnimatedView>
  )
}

function PreviewMenuPreviewBody({
  style,
  children,
}: {
  style?: ViewStyle
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  const { contentRef } =
    PreviewMenuPrimitive.useRootContext("PreviewMenuContent")
  return (
    <View
      style={[
        styles.previewBody,
        { backgroundColor: colors.background },
        style,
      ]}
    >
      {children ? (
        <>
          <PreviewMenuLayer layer="source" />
          <PreviewMenuLayer layer="preview">{children}</PreviewMenuLayer>
        </>
      ) : (
        contentRef.current
      )}
    </View>
  )
}

function PreviewMenuBackdrop() {
  const { ready } = PreviewMenuPrimitive.useRootContext("PreviewMenuContent")
  if (!ready) return null
  return (
    <AnimatedView
      entering={FadeIn.duration(300).reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(300).reduceMotion(ReduceMotion.System)}
      style={styles.backdropFrame}
    >
      <PreviewMenuPrimitive.Backdrop
        style={{ backgroundColor: "rgba(0, 0, 0, 0.5)" }}
      />
    </AnimatedView>
  )
}

function PreviewMenuContent({
  preview,
  previewStyle,
  portalHost,
  style,
  children,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Popup> & {
  preview?: React.ReactNode
  previewStyle?: ViewStyle
  portalHost?: string
}) {
  const { colors, isDark } = useTheme()
  return (
    <PreviewMenuPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <PreviewMenuBackdrop />
        <PreviewMenuPrimitive.Preview style={styles.preview}>
          <PreviewMenuPreviewBody style={previewStyle}>
            {preview}
          </PreviewMenuPreviewBody>
        </PreviewMenuPrimitive.Preview>
        <PreviewMenuPrimitive.Positioner>
          <View style={styles.popupFrame}>
            <PreviewMenuPrimitive.Popup
              style={(state) => [
                styles.popup,
                {
                  backgroundColor: colors.popover,
                  borderColor: withAlpha(
                    colors.foreground,
                    isDark ? 0.1 : 0.05
                  ),
                },
                resolveStyle(style, state) as ViewStyle,
              ]}
              {...props}
            >
              <View style={styles.list}>{children}</View>
            </PreviewMenuPrimitive.Popup>
          </View>
        </PreviewMenuPrimitive.Positioner>
      </FullWindowOverlay>
    </PreviewMenuPrimitive.Portal>
  )
}

function PreviewMenuItem({
  style,
  variant = "default",
  children,
  ...props
}: Omit<React.ComponentProps<typeof PreviewMenuPrimitive.Item>, "children"> & {
  variant?: "default" | "destructive"
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  const color =
    variant === "destructive" ? colors.destructive : colors.foreground
  return (
    <TextStyleContext.Provider value={{ ...fontSize.base, color }}>
      <IconSizeContext.Provider value={20}>
        <PreviewMenuPrimitive.Item
          style={(state) => [
            styles.item,
            { borderColor: colors.border },
            state.highlighted && { opacity: 0.8 },
            props.disabled && { opacity: 0.5 },
            resolveStyle(style, state) as ViewStyle,
          ]}
          {...props}
        >
          {React.Children.map(children, (child) =>
            typeof child === "string" || typeof child === "number" ? (
              <Text style={styles.itemText}>{child}</Text>
            ) : (
              child
            )
          )}
        </PreviewMenuPrimitive.Item>
      </IconSizeContext.Provider>
    </TextStyleContext.Provider>
  )
}

function PreviewMenuSeparator({
  style,
  ...props
}: React.ComponentProps<typeof PreviewMenuPrimitive.Separator>) {
  const { colors } = useTheme()
  return (
    <PreviewMenuPrimitive.Separator
      style={(state) => [
        styles.separator,
        { backgroundColor: withAlpha(colors.border, 0.4) },
        resolveStyle(style, state) as ViewStyle,
      ]}
      {...props}
    />
  )
}

export {
  PreviewMenu,
  PreviewMenuContent,
  PreviewMenuItem,
  PreviewMenuSeparator,
  PreviewMenuTrigger,
  usePreviewMenu,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

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

3. Copy the theme files every view uses:

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

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

4. Copy the source and every file it uses:

```swift title="swift/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

  static func size(trigger: CGRect, viewport: CGSize, aspectRatio: CGFloat?, reserve: CGFloat) -> CGSize {
    let ratio = aspectRatio.map { 1 / max($0, 0.01) } ?? trigger.height / max(trigger.width, 1)
    var width = min(viewport.width - edge * 2, maxWidth)
    var height = width * ratio
    let maxHeight = viewport.height - reserve - edge * 2
    if height > maxHeight {
      height = max(maxHeight, trigger.height)
      width = height / max(ratio, 0.01)
    }
    return CGSize(width: width, height: height)
  }

  static func menuMaxHeight(trigger: CGRect, viewport: CGSize, aspectRatio: CGFloat?) -> CGFloat {
    let base = size(trigger: trigger, viewport: viewport, aspectRatio: aspectRatio, reserve: menuSpace)
    return max(viewport.height - edge * 2 - base.height - menuGap, menuMinHeight)
  }

  static func frame(trigger: CGRect, viewport: CGSize, aspectRatio: CGFloat?, menuHeight: CGFloat) -> CGRect {
    let menu = min(menuHeight, menuMaxHeight(trigger: trigger, viewport: viewport, aspectRatio: aspectRatio))
    let size = size(trigger: trigger, viewport: viewport, aspectRatio: aspectRatio, reserve: menu + menuGap)
    let left = (viewport.width - size.width) / 2
    let top = edge + max(0, (viewport.height - edge * 2 - size.height - menuGap - menu) / 2)
    return CGRect(x: left, y: top, width: size.width, height: size.height)
  }

  static func menuOrigin(preview: CGRect, menu: CGSize, viewport: CGSize) -> CGPoint {
    let x = min(max(preview.midX - menu.width / 2, edge), max(edge, viewport.width - edge - menu.width))
    let y = min(preview.maxY + menuGap, max(edge, viewport.height - edge - menu.height))
    return CGPoint(x: x, y: y)
  }
}

extension View {
  public func previewMenu<Items: View>(
    isPresented: Binding<Bool>? = nil,
    aspectRatio: CGFloat? = nil,
    @ViewBuilder items: @escaping () -> Items
  ) -> some View {
    modifier(
      PreviewMenuModifier<Self, Items, EmptyView>(
        external: isPresented,
        aspectRatio: aspectRatio,
        trigger: { self },
        items: items,
        preview: nil
      )
    )
  }

  public func previewMenu<Items: View, Preview: View>(
    isPresented: Binding<Bool>? = nil,
    aspectRatio: CGFloat? = nil,
    @ViewBuilder items: @escaping () -> Items,
    @ViewBuilder preview: @escaping () -> Preview
  ) -> some View {
    modifier(
      PreviewMenuModifier(
        external: isPresented,
        aspectRatio: aspectRatio,
        trigger: { self },
        items: items,
        preview: preview
      )
    )
  }
}

struct PreviewMenuModifier<Trigger: View, Items: View, Preview: View>: ViewModifier {
  let external: Binding<Bool>?
  let aspectRatio: CGFloat?
  let trigger: () -> Trigger
  let items: () -> Items
  let preview: (() -> Preview)?

  @State private var local = false
  @State private var source: CGRect = .zero
  @State private var lifts = 0
  @State private var lifted = false
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  func body(content: Content) -> some View {
    let binding = external ?? $local
    let open = binding.wrappedValue
    content
      .opacity(lifted ? 0 : 1)
      .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { source = $0 }
      .contentShape(Rectangle())
      .onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10) {
        binding.wrappedValue = true
      }
      .accessibilityAction(named: Text("Show actions")) { binding.wrappedValue = true }
      .accessibilityHint(Text("Long press to preview and show actions"))
      .hapticFeedback(.impact(weight: .medium), trigger: lifts)
      .onChange(of: open, initial: true) { _, value in
        if value { lifts += 1 }
      }
      .task(id: open) {
        guard !open else { return }
        if !reduceMotion {
          try? await Task.sleep(for: .milliseconds(270))
        }
        if !Task.isCancelled { lifted = false }
      }
      .modifier(
        CoverPresenter(
          isPresented: binding,
          enter: reduceMotion ? .linear(duration: 0) : previewMenuEnter,
          exit: reduceMotion ? .linear(duration: 0) : previewMenuExit
        ) { visible in
          PreviewMenuCanvas(
            source: source,
            aspectRatio: aspectRatio,
            visible: visible,
            trigger: trigger,
            items: items,
            preview: preview,
            onShow: { lifted = true }
          )
        }
      )
  }
}

struct PreviewMenuCanvas<Trigger: View, Items: View, Preview: View>: View {
  let source: CGRect
  let aspectRatio: CGFloat?
  let visible: Bool
  @ViewBuilder let trigger: () -> Trigger
  @ViewBuilder let items: () -> Items
  let preview: (() -> Preview)?
  var onShow: () -> Void = {}

  @State private var menu = CGSize(width: PreviewMenuMath.menuWidth, height: PreviewMenuMath.menuSpace - PreviewMenuMath.menuGap)
  @Environment(\.overlayDismiss) private var dismiss

  var body: some View {
    GeometryReader { proxy in
      let origin = proxy.frame(in: .global).origin
      let local = source.offsetBy(dx: -origin.x, dy: -origin.y)
      let target = PreviewMenuMath.frame(trigger: local, viewport: proxy.size, aspectRatio: aspectRatio, menuHeight: menu.height)
      let current = visible ? target : local
      let menuOrigin = PreviewMenuMath.menuOrigin(preview: target, menu: menu, viewport: proxy.size)
      let menuMax = PreviewMenuMath.menuMaxHeight(trigger: local, viewport: proxy.size, aspectRatio: aspectRatio)
      let triggerZoom = max(target.width / max(local.width, 1), target.height / max(local.height, 1))
      let previewZoom = max(local.width / max(target.width, 1), local.height / max(target.height, 1))
      let anchor = UnitPoint(
        x: min(max((target.midX - menuOrigin.x) / max(menu.width, 1), 0), 1),
        y: min(max((target.maxY + PreviewMenuMath.menuGap - menuOrigin.y) / max(menu.height, 1), 0), 1)
      )
      ZStack(alignment: .topLeading) {
        Rectangle()
          .fill(.ultraThinMaterial)
          .overlay(Palette.black.opacity(0.5).opacity(0.4))
          .ignoresSafeArea()
          .opacity(visible ? 1 : 0)
          .contentShape(Rectangle())
          .onTapGesture { dismiss?() }
          .accessibilityHidden(true)
        ZStack {
          trigger()
            .frame(width: local.width, height: local.height)
            .scaleEffect(visible ? triggerZoom : 1)
            .opacity(preview == nil || !visible ? 1 : 0)
          if let preview {
            preview()
              .frame(width: target.width, height: target.height)
              .scaleEffect(visible ? 1 : previewZoom)
              .opacity(visible ? 1 : 0)
          }
        }
        .frame(width: current.width, height: current.height)
        .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
        .shadow(visible ? Shadow.lg : Shadow(color: .clear, radius: 0, x: 0, y: 0))
        .position(x: current.midX, y: current.midY)
        .allowsHitTesting(false)
        .accessibilityHidden(true)
        PreviewMenuPanel(content: items)
          .onGeometryChange(for: CGSize.self) { $0.size } action: { menu = $0 }
          .frame(maxHeight: menuMax, alignment: .top)
          .scaleEffect(visible ? 1 : 0.4, anchor: anchor)
          .opacity(visible ? 1 : 0)
          .offset(
            x: menuOrigin.x + (visible ? 0 : local.midX - target.midX),
            y: menuOrigin.y + (visible ? 0 : local.maxY - target.maxY - PreviewMenuMath.menuGap)
          )
      }
    }
    .onAppear(perform: onShow)
    .accessibilityAddTraits(.isModal)
    .accessibilityAction(.escape) { dismiss?() }
  }
}

struct PreviewMenuPanel<Content: View>: View {
  @ViewBuilder let content: () -> Content

  var body: some View {
    let stack = VStack(spacing: 0) {
      content()
    }
    .padding(.top, -1)
    ViewThatFits(in: .vertical) {
      stack
      ScrollView { stack }
    }
    .frame(width: PreviewMenuMath.menuWidth)
    .foregroundStyle(EsuissColor.foreground)
    .popupSurface(radius: Radius.xl2)
    .accessibilityElement(children: .contain)
  }
}

public struct PreviewMenuItem<Label: View>: View {
  public typealias Variant = PreviewMenuItemVariant

  let variant: Variant
  let icon: Image?
  let action: () -> Void
  let label: Label

  @Environment(\.overlayDismiss) private var dismiss

  public init(
    variant: Variant = .default,
    icon: Image? = nil,
    action: @escaping () -> Void,
    @ViewBuilder label: () -> Label
  ) {
    self.variant = variant
    self.icon = icon
    self.action = action
    self.label = label()
  }

  public var body: some View {
    Button {
      action()
      dismiss?()
    } label: {
      HStack(spacing: 24) {
        label
          .scaledTextStyle(.base)
          .lineLimit(2)
          .frame(maxWidth: .infinity, alignment: .leading)
        if let icon {
          icon
            .resizable()
            .scaledToFit()
            .frame(width: 20, height: 20)
            .accessibilityHidden(true)
        }
      }
    }
    .buttonStyle(PreviewMenuItemStyle(destructive: variant == .destructive))
  }
}

extension PreviewMenuItem where Label == Text {
  public init(
    _ title: LocalizedStringKey,
    icon: Image? = nil,
    variant: Variant = .default,
    action: @escaping () -> Void
  ) {
    self.init(variant: variant, icon: icon, action: action) { Text(title) }
  }

  public init(
    verbatim title: String,
    icon: Image? = nil,
    variant: Variant = .default,
    action: @escaping () -> Void
  ) {
    self.init(variant: variant, icon: icon, action: action) { Text(verbatim: title) }
  }
}

private struct PreviewMenuItemStyle: ButtonStyle {
  let destructive: Bool

  @Environment(\.isEnabled) private var isEnabled

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .foregroundStyle(destructive ? EsuissColor.destructive : EsuissColor.popoverForeground)
      .padding(.horizontal, 16)
      .padding(.vertical, 11)
      .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
      .opacity(isEnabled ? (configuration.isPressed ? 0.8 : 1) : 0.5)
      .overlay(alignment: .top) {
        EsuissColor.border.frame(height: 1)
      }
      .contentShape(Rectangle())
  }
}

public struct PreviewMenuSeparator: View {
  public init() {}

  public var body: some View {
    EsuissColor.border
      .opacity(0.4)
      .frame(height: 8)
      .frame(maxWidth: .infinity)
      .accessibilityHidden(true)
  }
}
```

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

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

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

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

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

5. Add the files to your app target.

**Jetpack Compose**

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

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

2. Copy the theme files every view uses:

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

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

3. Copy the source and every file it uses:

```kt title="kotlin/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
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.layout.positionOnScreen
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.DialogWindowProvider
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.LocalIconSize
import com.esuiss.ui.components.MenuItemVariant
import com.esuiss.ui.components.ModalWindow
import com.esuiss.ui.components.rememberHaptics
import com.esuiss.ui.components.rememberLongPressModifier
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlinx.coroutines.flow.first

private val Edge = 16.dp
private val Gap = 12.dp
private val MenuSpace = 260.dp
private val MenuMinHeight = 88.dp
private val MaxWidth = 360.dp
private val Blur = 24.dp
private val Easing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private val LocalPreviewMenuClose = staticCompositionLocalOf<() -> Unit> { {} }

private class PreviewMenuGeometry {
    var tile = Size.Zero
    var frame = Rect.Zero
}

internal fun previewMenuSize(trigger: Size, aspectRatio: Float?, maxWidth: Float, maxHeight: Float): Size {
    val ratio = if (aspectRatio != null && aspectRatio > 0f) 1f / aspectRatio else trigger.height / max(trigger.width, 1f)
    var width = maxWidth
    var height = width * ratio
    if (height > maxHeight) {
        height = max(maxHeight, trigger.height)
        width = height / max(ratio, 0.01f)
    }
    return Size(width, height)
}

internal fun previewMenuGroupTop(top: Float, bottom: Float, preview: Float, gap: Float, menu: Float): Float =
    top + max(0f, (bottom - top - preview - gap - menu) / 2f)

private fun lerpRect(from: Rect, to: Rect, amount: Float) = Rect(
    lerp(from.left, to.left, amount),
    lerp(from.top, to.top, amount),
    lerp(from.right, to.right, amount),
    lerp(from.bottom, to.bottom, amount),
)

private fun Modifier.coverLayer(size: () -> Size, alpha: () -> Float) = layout { measurable, constraints ->
    val content = size()
    val width = content.width.roundToInt().coerceAtLeast(1)
    val height = content.height.roundToInt().coerceAtLeast(1)
    val placeable = measurable.measure(Constraints.fixed(width, height))
    layout(constraints.maxWidth, constraints.maxHeight) {
        placeable.placeWithLayer((constraints.maxWidth - width) / 2, (constraints.maxHeight - height) / 2) {
            val zoom = max(constraints.maxWidth / width.toFloat(), constraints.maxHeight / height.toFloat())
            scaleX = zoom
            scaleY = zoom
            this.alpha = alpha()
        }
    }
}

@Composable
fun PreviewMenu(
    open: Boolean,
    onOpenChange: (Boolean) -> Unit,
    trigger: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    preview: (@Composable () -> Unit)? = null,
    aspectRatio: Float? = null,
    enabled: Boolean = true,
    content: @Composable ColumnScope.() -> Unit,
) {
    val haptics = rememberHaptics()
    val density = LocalDensity.current
    val change by rememberUpdatedState(onOpenChange)
    val opening by rememberUpdatedState(open)
    val safeTop = WindowInsets.safeDrawing.getTop(density)
    val safeBottom = WindowInsets.safeDrawing.getBottom(density)
    var triggerBounds by remember { mutableStateOf(Rect.Zero) }
    var shown by remember { mutableStateOf(open) }
    var drawn by remember { mutableStateOf(false) }
    val progress = remember { Animatable(if (open) 1f else 0f) }
    LaunchedEffect(open) {
        if (open) {
            shown = true
            snapshotFlow { drawn }.first { it }
            progress.animateTo(1f, tween(400, easing = Easing))
        } else if (shown) {
            progress.animateTo(0f, tween(300, easing = Easing))
            shown = false
            drawn = false
        }
    }
    Box(
        modifier = modifier
            .onGloballyPositioned { coordinates ->
                triggerBounds = Rect(coordinates.positionOnScreen(), Size(coordinates.size.width.toFloat(), coordinates.size.height.toFloat()))
            }
            .then(
                rememberLongPressModifier(enabled && !open) {
                    haptics.perform(HapticFeedbackType.LongPress)
                    change(true)
                },
            )
            .semantics {
                if (enabled) {
                    onLongClick(label = "Open preview menu") {
                        change(true)
                        true
                    }
                }
            }
            .alpha(if (drawn) 0f else 1f),
    ) {
        trigger()
    }
    ModalWindow(
        visible = shown,
        onDismissRequest = { change(false) },
        dismissOnBackPress = true,
        paneTitle = null,
    ) {
        val window = (LocalView.current.parent as? DialogWindowProvider)?.window
        SideEffect {
            window?.setWindowAnimations(0)
        }
        LaunchedEffect(window) {
            if (window != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
                val blur = with(density) { Blur.toPx() }
                snapshotFlow { (blur * progress.value).roundToInt() }.collect { radius ->
                    val params = window.attributes
                    params.blurBehindRadius = radius
                    window.attributes = params
                }
            }
        }
        val colors = EsuissTheme.colors
        val geometry = remember { PreviewMenuGeometry() }
        val shape = RoundedCornerShape(Radius.xl2)
        Layout(
            contents = listOf(
                {
                    Box(
                        modifier = Modifier
                            .shadow(Shadows.lg, shape)
                            .clip(shape)
                            .pointerInput(Unit) { detectTapGestures { } }
                            .drawWithContent {
                                drawContent()
                                if (!drawn && opening) drawn = true
                            },
                    ) {
                        Box(
                            modifier = Modifier.coverLayer({ geometry.tile }) {
                                if (preview == null) 1f else (1f - progress.value * 2f).coerceIn(0f, 1f)
                            },
                        ) {
                            trigger()
                        }
                        if (preview != null) {
                            Box(modifier = Modifier.coverLayer({ geometry.frame.size }) { (progress.value * 2f).coerceIn(0f, 1f) }) {
                                preview()
                            }
                        }
                    }
                },
                {
                    CompositionLocalProvider(
                        LocalPreviewMenuClose provides { change(false) },
                        LocalContentStyle provides Typography.base.copy(color = colors.foreground),
                        LocalContentColor provides colors.foreground,
                        LocalIconSize provides 20.dp,
                    ) {
                        Column(
                            modifier = Modifier
                                .widthIn(min = 240.dp)
                                .width(IntrinsicSize.Max)
                                .shadow(Shadows.lg, shape)
                                .clip(shape)
                                .background(colors.popover)
                                .pointerInput(Unit) { detectTapGestures { } }
                                .verticalScroll(rememberScrollState()),
                            content = content,
                        )
                    }
                },
            ),
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(Unit) { detectTapGestures { change(false) } }
                .drawBehind { drawRect(Palette.black.copy(alpha = 0.4f * progress.value)) },
        ) { (previewMeasurables, menuMeasurables), constraints ->
            val width = constraints.maxWidth.toFloat()
            val height = constraints.maxHeight.toFloat()
            val edge = Edge.toPx()
            val gap = Gap.toPx()
            val top = safeTop + edge
            val bottom = height - safeBottom - edge
            val tile = Size(max(triggerBounds.width, 1f), max(triggerBounds.height, 1f))
            val maxWidth = min(width - edge * 2, MaxWidth.toPx())
            val base = previewMenuSize(tile, aspectRatio, maxWidth, bottom - top - MenuSpace.toPx())
            val menu = menuMeasurables.first().measure(
                Constraints(
                    maxWidth = (width - edge * 2).roundToInt().coerceAtLeast(0),
                    maxHeight = max(bottom - top - base.height - gap, MenuMinHeight.toPx()).roundToInt(),
                ),
            )
            val size = previewMenuSize(tile, aspectRatio, maxWidth, bottom - top - menu.height - gap)
            val groupTop = previewMenuGroupTop(top, bottom, size.height, gap, menu.height.toFloat())
            val frame = Rect(Offset((width - size.width) / 2f, groupTop), size)
            geometry.tile = tile
            geometry.frame = frame
            val p = progress.value
            val previewWidth = lerp(tile.width, frame.width, p).roundToInt().coerceAtLeast(1)
            val previewHeight = lerp(tile.height, frame.height, p).roundToInt().coerceAtLeast(1)
            val previewPlaceable = previewMeasurables.first().measure(Constraints.fixed(previewWidth, previewHeight))
            val menuX = (frame.center.x - menu.width / 2f).coerceIn(edge, max(edge, width - edge - menu.width))
            val menuY = min(frame.bottom + gap, bottom - menu.height)
            layout(constraints.maxWidth, constraints.maxHeight) {
                val origin = coordinates?.positionOnScreen() ?: Offset.Zero
                val source = triggerBounds.translate(-origin)
                val current = lerpRect(source, frame, p)
                previewPlaceable.place(current.left.roundToInt(), current.top.roundToInt())
                val anchorX = frame.center.x
                val anchorY = frame.bottom + gap
                menu.placeWithLayer(menuX.roundToInt(), menuY.roundToInt()) {
                    val amount = progress.value
                    val moving = lerpRect(source, frame, amount)
                    translationX = moving.center.x - anchorX
                    translationY = moving.bottom + gap * amount - anchorY
                    val scale = lerp(0.4f, 1f, amount)
                    scaleX = scale
                    scaleY = scale
                    alpha = amount
                    transformOrigin = TransformOrigin(
                        ((anchorX - menuX) / max(menu.width, 1)).coerceIn(0f, 1f),
                        ((anchorY - menuY) / max(menu.height, 1)).coerceIn(0f, 1f),
                    )
                }
            }
        }
    }
}

@Composable
fun PreviewMenuItem(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    variant: MenuItemVariant = MenuItemVariant.Default,
    enabled: Boolean = true,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val close = LocalPreviewMenuClose.current
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    var first by remember { mutableStateOf(false) }
    val foreground = if (variant == MenuItemVariant.Destructive) colors.destructive else colors.foreground
    val separator = colors.border
    CompositionLocalProvider(
        LocalContentStyle provides Typography.base.copy(color = foreground),
        LocalContentColor provides foreground,
        LocalIconSize provides 20.dp,
    ) {
        Row(
            modifier = modifier
                .fillMaxWidth()
                .heightIn(min = 44.dp)
                .onPlaced { first = it.positionInParent().y == 0f }
                .alpha(
                    when {
                        !enabled -> 0.5f
                        pressed -> 0.8f
                        else -> 1f
                    },
                )
                .drawBehind {
                    if (!first) drawRect(separator, Offset.Zero, Size(size.width, 1.dp.toPx()))
                }
                .clickable(interactionSource = interaction, indication = null, enabled = enabled, role = Role.Button) {
                    onClick()
                    close()
                }
                .padding(horizontal = 16.dp, vertical = 11.dp),
            horizontalArrangement = Arrangement.spacedBy(24.dp),
            verticalAlignment = Alignment.CenterVertically,
            content = content,
        )
    }
}

@Composable
fun PreviewMenuItem(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    icon: ImageVector? = null,
    variant: MenuItemVariant = MenuItemVariant.Default,
    enabled: Boolean = true,
) {
    PreviewMenuItem(onClick, modifier, variant, enabled) {
        BasicText(text, modifier = Modifier.weight(1f), style = LocalContentStyle.current, maxLines = 1)
        if (icon != null) Icon(icon, contentDescription = null)
    }
}

@Composable
fun PreviewMenuSeparator(modifier: Modifier = Modifier) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(8.dp)
            .background(EsuissTheme.colors.border.copy(alpha = EsuissTheme.colors.border.alpha * 0.4f)),
    )
}
```

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

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

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

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

- `kotlin/components/DropdownMenu.kt`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

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

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

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

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

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

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

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

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

## Usage

```tsx
import { PreviewMenu, PreviewMenuTrigger, PreviewMenuContent, PreviewMenuItem, PreviewMenuSeparator } from "@/components/interactions/preview-menu"
```

```tsx
<PhotoGrid openIndex={1} />
```

Put a `PreviewMenuTrigger` with the element and a `PreviewMenuContent` with `PreviewMenuItem`s 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

![Preview](https://ui.suiss.app/previews/interactions-preview-menu--default.light.webp)

```tsx
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 />
  )
}
```

```swift
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)" }
      }
  }
}
```

```kotlin
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.

![Preview](https://ui.suiss.app/previews/interactions-preview-menu--disabled-item.light.webp)

```tsx
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} />
  )
}
```

```swift
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)" }
      }
  }
}
```

```kotlin
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.

![Preview](https://ui.suiss.app/previews/interactions-preview-menu--custom-preview.light.webp)

```tsx
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 />
  )
}
```

```swift
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)
    }
  }
}
```

```kotlin
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.

![Preview](https://ui.suiss.app/previews/interactions-preview-menu--custom-preview-open.light.webp)

```tsx
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 />
  )
}
```

```swift
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)
    }
  }
}
```

```kotlin
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

### PreviewMenu

The root that owns the open state and preview size.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Controls whether the preview is open. |
| `defaultOpen` | `boolean` | - | Whether the preview starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the preview opens or closes. |
| `aspectRatio` | `number` | - | Width-to-height ratio of the lifted preview. |
| `children` | `React.ReactNode` | - |  |

### PreviewMenuTrigger

The element that opens the preview on long press.

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

### PreviewMenuContent

The scrim, the lifted preview and the menu.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `preview` | `React.ReactNode` | - | Content to show in the preview instead of the trigger's children. |
| `previewClassName` | `string` | - | Classes for the preview container. |
| `portalHost` | `string` | - | Name of a portal host to render into. |

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

### PreviewMenuItem

A menu action.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "destructive"` | `"default"` | `default` or `destructive`, which uses `text-destructive`. |
| `children` | `React.ReactNode` | - |  |

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

### 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.

