# Undo Bar

A floating bar that offers to undo the last action for a few seconds before committing it.

Undo Bar 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/undo-bar

**React**

**Example**

```tsx
import { TrashIcon } from "@phosphor-icons/react"
import * as React from "react"
import { UndoBarProvider, useUndoBar } from "@/components/interactions/undo-bar"
import { Button } from "@/components/ui/button"

const initialNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

type Note = (typeof initialNotes)[number]

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Delete a note.")
  const { show } = useUndoBar()

  const remove = (note: Note) => {
    const index = notes.findIndex((item) => item.id === note.id)
    setNotes((current) => current.filter((item) => item.id !== note.id))
    setLog(`Removed “${note.title}”`)
    show({
      title: `“${note.title}” deleted`,
      onUndo: () => {
        setNotes((current) => {
          const next = [...current]
          next.splice(Math.min(index, next.length), 0, note)
          return next
        })
        setLog(`Restored “${note.title}”`)
      },
      onCommit: () => setLog(`Deleted “${note.title}” permanently`),
    })
  }

  return { notes, remove, log }
}

function NotesList() {
  const { notes, remove, log } = useNotes()

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-3">
      <ul className="overflow-hidden rounded-2xl border">
        {notes.map((note) => (
          <li
            key={note.id}
            className="flex items-center gap-3 py-1 ps-4 pe-1 not-first:border-t"
          >
            <span className="flex min-w-0 flex-1 flex-col py-2">
              <span className="truncate text-base font-semibold">
                {note.title}
              </span>
              <span className="truncate text-sm text-muted-foreground">
                {note.preview}
              </span>
            </span>
            <Button
              variant="ghost"
              size="icon-lg"
              aria-label={`Delete ${note.title}`}
              onClick={() => remove(note)}
            >
              <TrashIcon className="size-5" />
            </Button>
          </li>
        ))}
      </ul>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function UndoBarDemo() {
  return (
    <UndoBarProvider>
      <NotesList />
    </UndoBarProvider>
  )
}
```

`UndoBarProvider` renders a floating bar at the bottom of the screen and provides `useUndoBar`. Calling `show` displays a title and an Undo button with a countdown ring. Pressing Undo, or `Ctrl+Z` or `Cmd+Z` outside text fields, runs `onUndo`; when the timer runs out, the bar closes and runs `onCommit`. Showing a new entry commits the previous one first.

The countdown pauses while the pointer hovers the bar, while it has focus, and while the page is hidden. The Undo button is a suiss/ui `Button`. Use it to make destructive actions, such as deleting from a list, immediate but reversible.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/undo-bar
```

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 undo-bar
```

### Manual

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button).

3. Copy the source into `components/interactions/undo-bar.tsx`:

```tsx title="components/interactions/undo-bar.tsx"
"use client"

import * as React from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

type UndoBarOptions = {
  title: React.ReactNode
  actionLabel?: React.ReactNode
  onUndo: () => void
  onCommit?: () => void
}

type UndoBarEntry = UndoBarOptions & { id: number }

type UndoBarContextProps = {
  show: (options: UndoBarOptions) => void
  undo: () => void
  dismiss: () => void
}

const UndoBarContext = React.createContext<UndoBarContextProps | null>(null)

function useUndoBar() {
  const context = React.useContext(UndoBarContext)

  if (!context) {
    throw new Error("useUndoBar must be used within an <UndoBarProvider />")
  }

  return context
}

const RADIUS = 8
const CIRCUMFERENCE = 2 * Math.PI * RADIUS

let nextId = 0

function isEditable(target: EventTarget | null) {
  return (
    target instanceof HTMLElement &&
    (target.isContentEditable ||
      target.closest("input, textarea, select") !== null)
  )
}

function UndoBarProvider({
  timeout = 5000,
  className,
  children,
}: {
  timeout?: number
  className?: string
  children?: React.ReactNode
}) {
  const [entry, setEntry] = React.useState<UndoBarEntry | null>(null)
  const [visible, setVisible] = React.useState(false)
  const [hovered, setHovered] = React.useState(false)
  const [focused, setFocused] = React.useState(false)
  const [hidden, setHidden] = React.useState(false)
  const entryRef = React.useRef<UndoBarEntry | null>(null)
  const ringRef = React.useRef<SVGCircleElement>(null)
  const animationRef = React.useRef<Animation | null>(null)
  const paused = hovered || focused || hidden
  const titleId = React.useId()

  const settle = React.useCallback((undo: boolean) => {
    const current = entryRef.current
    if (!current) return
    entryRef.current = null
    setVisible(false)
    setHovered(false)
    setFocused(false)
    if (undo) current.onUndo()
    else current.onCommit?.()
  }, [])

  const show = React.useCallback((options: UndoBarOptions) => {
    entryRef.current?.onCommit?.()
    nextId += 1
    const next = { ...options, id: nextId }
    entryRef.current = next
    setEntry(next)
    setVisible(true)
  }, [])

  React.useEffect(
    () => () => {
      const current = entryRef.current
      entryRef.current = null
      current?.onCommit?.()
    },
    []
  )

  const undo = React.useCallback(() => settle(true), [settle])
  const dismiss = React.useCallback(() => settle(false), [settle])

  React.useLayoutEffect(() => {
    if (!entry || !visible || !ringRef.current) return
    animationRef.current?.cancel()
    const animation = ringRef.current.animate(
      [{ strokeDashoffset: 0 }, { strokeDashoffset: CIRCUMFERENCE }],
      { duration: timeout, fill: "forwards" }
    )
    animation.onfinish = dismiss
    animationRef.current = animation
    return () => {
      animation.onfinish = null
      animation.pause()
    }
  }, [entry, visible, timeout, dismiss])

  React.useEffect(() => {
    const animation = animationRef.current
    if (!animation || !visible || !entry) return
    if (paused) animation.pause()
    else animation.play()
  }, [paused, visible, entry])

  React.useEffect(() => {
    const onVisibilityChange = () => setHidden(document.hidden)
    document.addEventListener("visibilitychange", onVisibilityChange)
    return () =>
      document.removeEventListener("visibilitychange", onVisibilityChange)
  }, [])

  React.useEffect(() => {
    if (!visible) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (
        (event.metaKey || event.ctrlKey) &&
        !event.shiftKey &&
        event.key.toLowerCase() === "z" &&
        !isEditable(event.target)
      ) {
        event.preventDefault()
        undo()
      }
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [visible, undo])

  const contextValue = React.useMemo(
    () => ({ show, undo, dismiss }),
    [show, undo, dismiss]
  )

  return (
    <UndoBarContext.Provider value={contextValue}>
      {children}
      <div
        data-slot="undo-bar-region"
        aria-live="polite"
        className={cn(
          "pointer-events-none fixed inset-x-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-50 mx-auto flex max-w-sm justify-center",
          className
        )}
      >
        {entry && (
          <div
            data-slot="undo-bar"
            role="group"
            aria-labelledby={titleId}
            data-closed={visible ? undefined : ""}
            data-paused={paused ? "" : undefined}
            onPointerEnter={(event) => {
              if (event.pointerType === "mouse") setHovered(true)
            }}
            onPointerLeave={() => setHovered(false)}
            onFocus={() => setFocused(true)}
            onBlur={(event) => {
              if (!event.currentTarget.contains(event.relatedTarget))
                setFocused(false)
            }}
            className="pointer-events-auto flex h-13 w-full items-center gap-3 rounded-full bg-popover/80 ps-4 pe-1 text-popover-foreground shadow-xl ring-1 ring-border backdrop-blur-3xl backdrop-saturate-180 transition-[translate,opacity,scale,visibility] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] transition-discrete starting:opacity-0 data-closed:invisible data-closed:opacity-0 data-closed:duration-300 motion-safe:starting:translate-y-[calc(100%+1rem)] motion-safe:starting:scale-95 motion-safe:data-closed:translate-y-[calc(100%+1rem)] motion-safe:data-closed:scale-95 dark:bg-muted/75"
          >
            <svg
              viewBox="0 0 20 20"
              aria-hidden
              className="size-5 shrink-0 -rotate-90 text-muted-foreground rtl:scale-x-[-1]"
            >
              <circle
                cx="10"
                cy="10"
                r={RADIUS}
                fill="none"
                strokeWidth="2"
                className="stroke-border"
              />
              <circle
                ref={ringRef}
                cx="10"
                cy="10"
                r={RADIUS}
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeDasharray={CIRCUMFERENCE}
              />
            </svg>
            <span
              id={titleId}
              data-slot="undo-bar-title"
              className="min-w-0 flex-1 truncate text-sm font-medium"
            >
              {entry.title}
            </span>
            <Button
              variant="plain"
              size="lg"
              data-slot="undo-bar-action"
              onClick={undo}
              className="px-4 font-semibold"
            >
              {entry.actionLabel ?? "Undo"}
            </Button>
          </div>
        )}
      </div>
    </UndoBarContext.Provider>
  )
}

export { UndoBarProvider, useUndoBar }
```

4. Update the import paths to match your project.

## Usage

```tsx
import { UndoBarProvider } from "@/components/interactions/undo-bar"
```

```tsx
<UndoBarProvider>
  <NotesList />
</UndoBarProvider>
```

- Wrap the app or screen in `UndoBarProvider` and call `show({ title, actionLabel, onUndo, onCommit })` from `useUndoBar` after performing the action.
- Apply the change optimistically, restore it in `onUndo`, and finalize it in `onCommit`.
- `actionLabel` defaults to `Undo`. `useUndoBar` also returns `undo` and `dismiss` to settle the current entry from code.
- It pairs well with `SwipeActions`, including full swipes.

## Examples

### Longer timeout

A `timeout` of 10 seconds keeps the bar on screen longer before committing.

**Example**

```tsx
import { TrashIcon } from "@phosphor-icons/react"
import * as React from "react"
import { UndoBarProvider, useUndoBar } from "@/components/interactions/undo-bar"
import { Button } from "@/components/ui/button"

const initialNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

type Note = (typeof initialNotes)[number]

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Delete a note.")
  const { show } = useUndoBar()

  const remove = (note: Note) => {
    const index = notes.findIndex((item) => item.id === note.id)
    setNotes((current) => current.filter((item) => item.id !== note.id))
    setLog(`Removed “${note.title}”`)
    show({
      title: `“${note.title}” deleted`,
      onUndo: () => {
        setNotes((current) => {
          const next = [...current]
          next.splice(Math.min(index, next.length), 0, note)
          return next
        })
        setLog(`Restored “${note.title}”`)
      },
      onCommit: () => setLog(`Deleted “${note.title}” permanently`),
    })
  }

  return { notes, remove, log }
}

function NotesList() {
  const { notes, remove, log } = useNotes()

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-3">
      <ul className="overflow-hidden rounded-2xl border">
        {notes.map((note) => (
          <li
            key={note.id}
            className="flex items-center gap-3 py-1 ps-4 pe-1 not-first:border-t"
          >
            <span className="flex min-w-0 flex-1 flex-col py-2">
              <span className="truncate text-base font-semibold">
                {note.title}
              </span>
              <span className="truncate text-sm text-muted-foreground">
                {note.preview}
              </span>
            </span>
            <Button
              variant="ghost"
              size="icon-lg"
              aria-label={`Delete ${note.title}`}
              onClick={() => remove(note)}
            >
              <TrashIcon className="size-5" />
            </Button>
          </li>
        ))}
      </ul>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function UndoBarLongerTimeout() {
  return (
    <UndoBarProvider timeout={10000}>
      <NotesList />
    </UndoBarProvider>
  )
}
```

### With swipe actions

Notes deleted from swipe actions, including a full swipe, can be restored from the bar.

**Example**

```tsx
import { TrashIcon } from "@phosphor-icons/react"
import * as React from "react"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import { UndoBarProvider, useUndoBar } from "@/components/interactions/undo-bar"

const initialNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

type Note = (typeof initialNotes)[number]

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Delete a note.")
  const { show } = useUndoBar()

  const remove = (note: Note) => {
    const index = notes.findIndex((item) => item.id === note.id)
    setNotes((current) => current.filter((item) => item.id !== note.id))
    setLog(`Removed “${note.title}”`)
    show({
      title: `“${note.title}” deleted`,
      onUndo: () => {
        setNotes((current) => {
          const next = [...current]
          next.splice(Math.min(index, next.length), 0, note)
          return next
        })
        setLog(`Restored “${note.title}”`)
      },
      onCommit: () => setLog(`Deleted “${note.title}” permanently`),
    })
  }

  return { notes, remove, log }
}

function SwipeNotesList() {
  const { notes, remove, log } = useNotes()

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-3">
      <ul className="overflow-hidden rounded-2xl border">
        {notes.map((note) => (
          <li key={note.id} className="not-first:border-t">
            <SwipeActions>
              <SwipeActionsContent className="flex flex-col px-4 py-3">
                <span className="truncate text-base font-semibold">
                  {note.title}
                </span>
                <span className="truncate text-sm text-muted-foreground">
                  {note.preview}
                </span>
              </SwipeActionsContent>
              <SwipeActionsActions side="trailing">
                <SwipeAction
                  variant="destructive"
                  fullSwipe
                  onClick={() => remove(note)}
                >
                  <TrashIcon />
                  Delete
                </SwipeAction>
              </SwipeActionsActions>
            </SwipeActions>
          </li>
        ))}
      </ul>
      <p role="status" className="text-sm text-muted-foreground">
        {log}
      </p>
    </div>
  )
}

export default function UndoBarWithSwipeActions() {
  return (
    <UndoBarProvider>
      <SwipeNotesList />
    </UndoBarProvider>
  )
}
```

## API Reference

### UndoBarProvider

Provides `useUndoBar` and renders the floating undo bar.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `timeout` | `number` | `5000` | Milliseconds the bar stays visible before committing. |
| `className` | `string` | - | Classes for the fixed region that positions the bar. |
| `children` | `React.ReactNode` | - | The content that can show undo entries. |

## Accessibility

- The bar sits in a polite live region, so its title is announced when it appears.
- The bar has `role="group"` and is labelled by its title.
- `Ctrl+Z` or `Cmd+Z` triggers undo while the bar is visible, except when focus is in an editable field.
- The countdown pauses while the bar has keyboard focus, giving people time to reach the button.

## Notes

- Parts use `data-slot` values `undo-bar-region`, `undo-bar`, `undo-bar-title`, and `undo-bar-action`. The bar sets `data-paused` while the countdown is paused and `data-closed` while closing.
- The bar is fixed to the bottom of the viewport above the safe-area inset.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx
import { TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { UndoBarProvider, useUndoBar } from "@/components/interactions/undo-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const initialNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

type Note = (typeof initialNotes)[number]

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Delete a note.")
  const { show } = useUndoBar()

  const remove = (note: Note) => {
    const index = notes.findIndex((item) => item.id === note.id)
    setNotes((current) => current.filter((item) => item.id !== note.id))
    setLog(`Removed “${note.title}”`)
    show({
      title: `“${note.title}” deleted`,
      onUndo: () => {
        setNotes((current) => {
          const next = [...current]
          next.splice(Math.min(index, next.length), 0, note)
          return next
        })
        setLog(`Restored “${note.title}”`)
      },
      onCommit: () => setLog(`Deleted “${note.title}” permanently`),
    })
  }

  return { notes, remove, log }
}

function NoteText({ note }: { note: Note }) {
  return (
    <View className="min-w-0 flex-1 py-2">
      <Text numberOfLines={1} className="font-semibold">
        {note.title}
      </Text>
      <Text numberOfLines={1} className="text-sm text-muted-foreground">
        {note.preview}
      </Text>
    </View>
  )
}

function NotesList({ deleteFirst = false }: { deleteFirst?: boolean }) {
  const { notes, remove, log } = useNotes()
  const removeRef = React.useRef(remove)
  const deletedRef = React.useRef(false)
  removeRef.current = remove

  React.useEffect(() => {
    if (!deleteFirst || deletedRef.current) return
    deletedRef.current = true
    removeRef.current(initialNotes[0])
  }, [deleteFirst])

  return (
    <View className="w-full gap-3">
      <View className="overflow-hidden rounded-2xl border border-border">
        {notes.map((note, index) => (
          <View
            key={note.id}
            className={
              index > 0
                ? "flex-row items-center gap-3 border-t border-border py-1 ps-4 pe-1"
                : "flex-row items-center gap-3 py-1 ps-4 pe-1"
            }
          >
            <NoteText note={note} />
            <Button
              variant="ghost"
              size="icon-lg"
              accessibilityLabel={`Delete ${note.title}`}
              onPress={() => remove(note)}
            >
              <Icon as={TrashIcon} className="size-5" />
            </Button>
          </View>
        ))}
      </View>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

export function UndoBarExample() {
  return (
    <UndoBarProvider>
      <NotesList />
    </UndoBarProvider>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct UndoBarExample: View {
  var body: some View {
    UndoBarStage(timeout: .seconds(5)) { UndoBarNotes() }
  }
}

struct UndoBarNote: Identifiable, Equatable {
  let id: Int
  let title: String
  let preview: String
}

let undoBarInitialNotes = [
  UndoBarNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  UndoBarNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  UndoBarNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  UndoBarNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
]

struct UndoBarStage<Content: View>: View {
  let timeout: Duration
  @ViewBuilder let content: () -> Content

  @State private var manager = UndoBarManager()

  var body: some View {
    content()
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .undoBar(manager, timeout: timeout)
  }
}

struct UndoBarNoteText: View {
  let note: UndoBarNote

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      Text(verbatim: note.title)
        .textStyle(.base, weight: .semibold)
        .foregroundStyle(EsuissColor.foreground)
        .lineLimit(1)
      Text(verbatim: note.preview)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .lineLimit(1)
    }
    .padding(.vertical, 8)
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct UndoBarNotes: View {
  var deleteFirst = false
  var swipe = false

  @State private var notes = undoBarInitialNotes
  @State private var log = "Delete a note."
  @State private var deleted = false
  @Environment(\.undoBarManager) private var manager

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 12) {
      VStack(spacing: 0) {
        ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
          row(note)
            .overlay(alignment: .top) {
              if index > 0 {
                EsuissColor.border.frame(height: 1)
              }
            }
        }
      }
      .clipShape(shape)
      .overlay { shape.strokeBorder(EsuissColor.border) }
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .accessibilityAddTraits(.updatesFrequently)
    }
    .onAppear {
      guard deleteFirst, !deleted else { return }
      deleted = true
      remove(undoBarInitialNotes[0])
    }
  }

  @ViewBuilder
  func row(_ note: UndoBarNote) -> some View {
    if swipe {
      SwipeActions {
        UndoBarNoteText(note: note)
          .padding(.horizontal, 16)
          .padding(.vertical, 4)
      } trailing: {
        SwipeAction("Delete", icon: Ph.trash.fill, variant: .destructive, fullSwipe: true) {
          remove(note)
        }
      }
    } else {
      HStack(spacing: 12) {
        UndoBarNoteText(note: note)
        Button {
          remove(note)
        } label: {
          Ph.trash.regular.frame(width: 20, height: 20)
        }
        .buttonStyle(.variant(.ghost, size: .iconLg))
        .accessibilityLabel(Text(verbatim: "Delete \(note.title)"))
      }
      .padding(.leading, 16)
      .padding(.trailing, 4)
      .padding(.vertical, 4)
    }
  }

  func remove(_ note: UndoBarNote) {
    let index = notes.firstIndex(of: note) ?? 0
    notes.removeAll { $0.id == note.id }
    log = "Removed “\(note.title)”"
    manager.show(
      "“\(note.title)” deleted",
      onUndo: {
        notes.insert(note, at: min(index, notes.count))
        log = "Restored “\(note.title)”"
      },
      onCommit: {
        log = "Deleted “\(note.title)” permanently"
      }
    )
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.Trash
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.UndoBarProvider
import com.esuiss.ui.interactions.UndoBarState
import com.esuiss.ui.interactions.rememberUndoBarState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun UndoBarExample() {
    UndoBarDemo(timeout = 5000L) { state -> UndoBarNotesList(state) }
}

class UndoBarNote(val id: Int, val title: String, val preview: String)

val undoBarInitialNotes = listOf(
    UndoBarNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    UndoBarNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    UndoBarNote(3, "Book list", "Three novels for the summer"),
    UndoBarNote(4, "Meeting notes", "Ship the beta on Friday"),
)

class UndoBarNotes(
    val notes: List<UndoBarNote>,
    val log: String,
    val remove: (UndoBarNote) -> Unit,
)

@Composable
fun UndoBarDemo(timeout: Long, content: @Composable (UndoBarState) -> Unit) {
    val state = rememberUndoBarState()
    UndoBarProvider(state, timeoutMillis = timeout) {
        content(state)
    }
}

@Composable
fun rememberUndoBarNotes(state: UndoBarState): UndoBarNotes {
    var ids by rememberSaveable { mutableStateOf(undoBarInitialNotes.map { it.id }) }
    var log by rememberSaveable { mutableStateOf("Delete a note.") }
    val notes = ids.mapNotNull { id -> undoBarInitialNotes.firstOrNull { it.id == id } }
    return UndoBarNotes(notes, log) { note ->
        val index = ids.indexOf(note.id)
        ids = ids - note.id
        log = "Removed “${note.title}”"
        state.show(
            title = "“${note.title}” deleted",
            onUndo = {
                ids = ids.toMutableList().apply { add(minOf(index, size), note.id) }
                log = "Restored “${note.title}”"
            },
            onCommit = { log = "Deleted “${note.title}” permanently" },
        )
    }
}

@Composable
fun UndoBarNoteText(note: UndoBarNote, modifier: Modifier = Modifier) {
    Column(modifier.padding(vertical = 8.dp)) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

@Composable
fun UndoBarFrame(notes: UndoBarNotes, row: @Composable (UndoBarNote) -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Column(
            Modifier
                .fillMaxWidth()
                .clip(shape)
                .border(1.dp, colors.border, shape),
        ) {
            notes.notes.forEachIndexed { index, note ->
                key(note.id) {
                    if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                    row(note)
                }
            }
        }
        Text(
            notes.log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = colors.mutedForeground),
        )
    }
}

@Composable
fun UndoBarNotesList(state: UndoBarState, deleteFirst: Boolean = false) {
    val notes = rememberUndoBarNotes(state)
    var deleted by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (!deleteFirst || deleted) return@LaunchedEffect
        deleted = true
        notes.remove(undoBarInitialNotes.first())
    }
    UndoBarFrame(notes) { note ->
        Row(
            modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            UndoBarNoteText(note, Modifier.weight(1f))
            Button(onClick = { notes.remove(note) }, variant = ButtonVariant.Ghost, size = ButtonSize.IconLg) {
                Icon(PhosphorIcons.Regular.Trash, contentDescription = "Delete ${note.title}", size = 20.dp)
            }
        }
    }
}
```

`UndoBarProvider` shows a pill-shaped bar above the bottom safe area when `show` is called from `useUndoBar`. It holds a message, a countdown ring and an Undo button, and slides up into view.

If the user presses Undo, `onUndo` runs. If the countdown runs out, `onCommit` runs. Only one bar shows at a time: calling `show` again commits the previous action first. The countdown pauses while the app is in the background and resumes with the time left. Use it after deleting, archiving or moving items.

## Installation

**React Native**

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

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 undo-bar
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [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-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/portal.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/undo-bar.tsx`:

```tsx title="uniwind/components/interactions/undo-bar.tsx"
import { cn } from "cn"
import * as React from "react"
import {
  AccessibilityInfo,
  AppState,
  I18nManager,
  useWindowDimensions,
  View,
} from "react-native"
import {
  cancelAnimation,
  Easing,
  Keyframe,
  ReduceMotion,
  type SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

type UndoBarOptions = {
  title: React.ReactNode
  actionLabel?: React.ReactNode
  onUndo: () => void
  onCommit?: () => void
}

type UndoBarEntry = UndoBarOptions & { id: number }

type UndoBarContextProps = {
  show: (options: UndoBarOptions) => void
  undo: () => void
  dismiss: () => void
}

const UndoBarContext = React.createContext<UndoBarContextProps | null>(null)

function useUndoBar() {
  const context = React.useContext(UndoBarContext)
  if (!context) {
    throw new Error("useUndoBar must be used within an <UndoBarProvider />")
  }
  return context
}

const MAX_WIDTH = 384

const RING_SIZE = 18

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 80 }, { scale: 0.95 }] },
  100: {
    opacity: 1,
    transform: [{ translateY: 0 }, { scale: 1 }],
    easing: EASING,
  },
})
  .duration(400)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
  100: {
    opacity: 0,
    transform: [{ translateY: 80 }, { scale: 0.95 }],
    easing: EASING,
  },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

let nextId = 0

function CountdownRing({ progress }: { progress: SharedValue<number> }) {
  const half = RING_SIZE / 2
  const endStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${Math.min(progress.value * 360, 180)}deg` }],
  }))
  const startStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${Math.max(progress.value * 360 - 180, 0)}deg` }],
  }))
  return (
    <View
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      className="size-5 shrink-0 items-center justify-center"
      style={I18nManager.isRTL ? { transform: [{ scaleX: -1 }] } : undefined}
    >
      <View style={{ width: RING_SIZE, height: RING_SIZE }}>
        <View className="absolute top-0 left-0 size-full rounded-full border-2 border-border" />
        <View
          className="absolute top-0 overflow-hidden"
          style={{ left: half, width: half, height: RING_SIZE }}
        >
          <AnimatedView
            className="absolute top-0"
            style={[
              { left: -half, width: RING_SIZE, height: RING_SIZE },
              endStyle,
            ]}
          >
            <View
              className="overflow-hidden"
              style={{ width: half, height: RING_SIZE }}
            >
              <View
                className="rounded-full border-2 border-muted-foreground"
                style={{ width: RING_SIZE, height: RING_SIZE }}
              />
            </View>
          </AnimatedView>
        </View>
        <View
          className="absolute top-0 left-0 overflow-hidden"
          style={{ width: half, height: RING_SIZE }}
        >
          <AnimatedView
            className="absolute top-0 left-0"
            style={[{ width: RING_SIZE, height: RING_SIZE }, startStyle]}
          >
            <View
              className="absolute top-0 overflow-hidden"
              style={{ left: half, width: half, height: RING_SIZE }}
            >
              <View
                className="absolute top-0 rounded-full border-2 border-muted-foreground"
                style={{ left: -half, width: RING_SIZE, height: RING_SIZE }}
              />
            </View>
          </AnimatedView>
        </View>
      </View>
    </View>
  )
}

function UndoBarProvider({
  timeout = 5000,
  className,
  portalHost,
  children,
}: {
  timeout?: number
  className?: string
  portalHost?: string
  children?: React.ReactNode
}) {
  const [entry, setEntry] = React.useState<UndoBarEntry | null>(null)
  const [active, setActive] = React.useState(AppState.currentState === "active")
  const entryRef = React.useRef<UndoBarEntry | null>(null)
  const remainingRef = React.useRef({ id: 0, duration: timeout })
  const progress = useSharedValue(1)
  const insets = useSafeAreaInsets()
  const { width } = useWindowDimensions()
  const inset = Math.max(16, (width - MAX_WIDTH) / 2)
  const id = React.useId()

  const settle = React.useCallback((undo: boolean) => {
    const current = entryRef.current
    if (!current) return
    entryRef.current = null
    setEntry(null)
    if (undo) current.onUndo()
    else current.onCommit?.()
  }, [])

  const show = React.useCallback(
    (options: UndoBarOptions) => {
      entryRef.current?.onCommit?.()
      nextId += 1
      const next = { ...options, id: nextId }
      entryRef.current = next
      remainingRef.current = { id: next.id, duration: timeout }
      setEntry(next)
      if (typeof options.title === "string") {
        AccessibilityInfo.announceForAccessibility(options.title)
      }
    },
    [timeout]
  )

  const undo = React.useCallback(() => settle(true), [settle])
  const dismiss = React.useCallback(() => settle(false), [settle])

  React.useEffect(() => {
    const subscription = AppState.addEventListener("change", (state) =>
      setActive(state === "active")
    )
    return () => subscription.remove()
  }, [])

  React.useEffect(() => {
    if (!entry) return
    if (!active) {
      cancelAnimation(progress)
      return
    }
    const remaining =
      remainingRef.current.id === entry.id
        ? remainingRef.current.duration
        : timeout
    const started = Date.now()
    progress.value = remaining / timeout
    progress.value = withTiming(0, {
      duration: remaining,
      easing: Easing.linear,
      reduceMotion: ReduceMotion.Never,
    })
    const timer = setTimeout(dismiss, remaining)
    return () => {
      clearTimeout(timer)
      if (remainingRef.current.id !== entry.id) return
      remainingRef.current = {
        id: entry.id,
        duration: Math.max(0, remaining - (Date.now() - started)),
      }
    }
  }, [entry, active, timeout, dismiss, progress])

  const contextValue = React.useMemo(
    () => ({ show, undo, dismiss }),
    [show, undo, dismiss]
  )

  return (
    <UndoBarContext.Provider value={contextValue}>
      {children}
      <Portal name={`undo-bar-${id}`} hostName={portalHost}>
        {entry ? (
          <AnimatedView
            key={entry.id}
            entering={ENTERING}
            exiting={EXITING}
            className="absolute z-50"
            style={{
              left: inset,
              right: inset,
              bottom: Math.max(16, insets.bottom),
              pointerEvents: "box-none",
            }}
          >
            <View
              role="group"
              accessibilityLiveRegion="polite"
              className={cn(
                "h-13 w-full flex-row items-center gap-3 rounded-full border border-border bg-popover ps-4 pe-1 shadow-xl dark:bg-muted",
                className
              )}
            >
              <CountdownRing progress={progress} />
              <View className="min-w-0 flex-1">
                {typeof entry.title === "string" ||
                typeof entry.title === "number" ? (
                  <Text numberOfLines={1} className="text-sm font-medium">
                    {entry.title}
                  </Text>
                ) : (
                  entry.title
                )}
              </View>
              <Button variant="plain" size="lg" className="px-4" onPress={undo}>
                {typeof entry.actionLabel === "string" ||
                typeof entry.actionLabel === "number" ||
                entry.actionLabel === undefined ? (
                  <Text className="font-semibold">
                    {entry.actionLabel ?? "Undo"}
                  </Text>
                ) : (
                  entry.actionLabel
                )}
              </Button>
            </View>
          </AnimatedView>
        ) : null}
      </Portal>
    </UndoBarContext.Provider>
  )
}

export { UndoBarProvider, useUndoBar }
```

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/portal.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/undo-bar.tsx`:

```tsx title="nativewind/components/interactions/undo-bar.tsx"
import { cn } from "cn"
import * as React from "react"
import {
  AccessibilityInfo,
  AppState,
  I18nManager,
  useWindowDimensions,
  View,
} from "react-native"
import {
  cancelAnimation,
  Easing,
  Keyframe,
  ReduceMotion,
  type SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

type UndoBarOptions = {
  title: React.ReactNode
  actionLabel?: React.ReactNode
  onUndo: () => void
  onCommit?: () => void
}

type UndoBarEntry = UndoBarOptions & { id: number }

type UndoBarContextProps = {
  show: (options: UndoBarOptions) => void
  undo: () => void
  dismiss: () => void
}

const UndoBarContext = React.createContext<UndoBarContextProps | null>(null)

function useUndoBar() {
  const context = React.useContext(UndoBarContext)
  if (!context) {
    throw new Error("useUndoBar must be used within an <UndoBarProvider />")
  }
  return context
}

const MAX_WIDTH = 384

const RING_SIZE = 18

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 80 }, { scale: 0.95 }] },
  100: {
    opacity: 1,
    transform: [{ translateY: 0 }, { scale: 1 }],
    easing: EASING,
  },
})
  .duration(400)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
  100: {
    opacity: 0,
    transform: [{ translateY: 80 }, { scale: 0.95 }],
    easing: EASING,
  },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

let nextId = 0

function CountdownRing({ progress }: { progress: SharedValue<number> }) {
  const half = RING_SIZE / 2
  const endStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${Math.min(progress.value * 360, 180)}deg` }],
  }))
  const startStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${Math.max(progress.value * 360 - 180, 0)}deg` }],
  }))
  return (
    <View
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      className="size-5 shrink-0 items-center justify-center"
      style={I18nManager.isRTL ? { transform: [{ scaleX: -1 }] } : undefined}
    >
      <View style={{ width: RING_SIZE, height: RING_SIZE }}>
        <View className="absolute top-0 left-0 size-full rounded-full border-2 border-border" />
        <View
          className="absolute top-0 overflow-hidden"
          style={{ left: half, width: half, height: RING_SIZE }}
        >
          <AnimatedView
            className="absolute top-0"
            style={[
              { left: -half, width: RING_SIZE, height: RING_SIZE },
              endStyle,
            ]}
          >
            <View
              className="overflow-hidden"
              style={{ width: half, height: RING_SIZE }}
            >
              <View
                className="rounded-full border-2 border-muted-foreground"
                style={{ width: RING_SIZE, height: RING_SIZE }}
              />
            </View>
          </AnimatedView>
        </View>
        <View
          className="absolute top-0 left-0 overflow-hidden"
          style={{ width: half, height: RING_SIZE }}
        >
          <AnimatedView
            className="absolute top-0 left-0"
            style={[{ width: RING_SIZE, height: RING_SIZE }, startStyle]}
          >
            <View
              className="absolute top-0 overflow-hidden"
              style={{ left: half, width: half, height: RING_SIZE }}
            >
              <View
                className="absolute top-0 rounded-full border-2 border-muted-foreground"
                style={{ left: -half, width: RING_SIZE, height: RING_SIZE }}
              />
            </View>
          </AnimatedView>
        </View>
      </View>
    </View>
  )
}

function UndoBarProvider({
  timeout = 5000,
  className,
  portalHost,
  children,
}: {
  timeout?: number
  className?: string
  portalHost?: string
  children?: React.ReactNode
}) {
  const [entry, setEntry] = React.useState<UndoBarEntry | null>(null)
  const [active, setActive] = React.useState(AppState.currentState === "active")
  const entryRef = React.useRef<UndoBarEntry | null>(null)
  const remainingRef = React.useRef({ id: 0, duration: timeout })
  const progress = useSharedValue(1)
  const insets = useSafeAreaInsets()
  const { width } = useWindowDimensions()
  const inset = Math.max(16, (width - MAX_WIDTH) / 2)
  const id = React.useId()

  const settle = React.useCallback((undo: boolean) => {
    const current = entryRef.current
    if (!current) return
    entryRef.current = null
    setEntry(null)
    if (undo) current.onUndo()
    else current.onCommit?.()
  }, [])

  const show = React.useCallback(
    (options: UndoBarOptions) => {
      entryRef.current?.onCommit?.()
      nextId += 1
      const next = { ...options, id: nextId }
      entryRef.current = next
      remainingRef.current = { id: next.id, duration: timeout }
      setEntry(next)
      if (typeof options.title === "string") {
        AccessibilityInfo.announceForAccessibility(options.title)
      }
    },
    [timeout]
  )

  const undo = React.useCallback(() => settle(true), [settle])
  const dismiss = React.useCallback(() => settle(false), [settle])

  React.useEffect(() => {
    const subscription = AppState.addEventListener("change", (state) =>
      setActive(state === "active")
    )
    return () => subscription.remove()
  }, [])

  React.useEffect(() => {
    if (!entry) return
    if (!active) {
      cancelAnimation(progress)
      return
    }
    const remaining =
      remainingRef.current.id === entry.id
        ? remainingRef.current.duration
        : timeout
    const started = Date.now()
    progress.value = remaining / timeout
    progress.value = withTiming(0, {
      duration: remaining,
      easing: Easing.linear,
      reduceMotion: ReduceMotion.Never,
    })
    const timer = setTimeout(dismiss, remaining)
    return () => {
      clearTimeout(timer)
      if (remainingRef.current.id !== entry.id) return
      remainingRef.current = {
        id: entry.id,
        duration: Math.max(0, remaining - (Date.now() - started)),
      }
    }
  }, [entry, active, timeout, dismiss, progress])

  const contextValue = React.useMemo(
    () => ({ show, undo, dismiss }),
    [show, undo, dismiss]
  )

  return (
    <UndoBarContext.Provider value={contextValue}>
      {children}
      <Portal name={`undo-bar-${id}`} hostName={portalHost}>
        {entry ? (
          <AnimatedView
            key={entry.id}
            entering={ENTERING}
            exiting={EXITING}
            className="absolute z-50"
            style={{
              left: inset,
              right: inset,
              bottom: Math.max(16, insets.bottom),
              pointerEvents: "box-none",
            }}
          >
            <View
              role="group"
              accessibilityLiveRegion="polite"
              className={cn(
                "h-[3.25rem] w-full flex-row items-center gap-3 rounded-full border border-border bg-popover ps-4 pe-1 shadow-xl dark:bg-muted",
                className
              )}
            >
              <CountdownRing progress={progress} />
              <View className="min-w-0 flex-1">
                {typeof entry.title === "string" ||
                typeof entry.title === "number" ? (
                  <Text numberOfLines={1} className="text-sm font-medium">
                    {entry.title}
                  </Text>
                ) : (
                  entry.title
                )}
              </View>
              <Button variant="plain" size="lg" className="px-4" onPress={undo}>
                {typeof entry.actionLabel === "string" ||
                typeof entry.actionLabel === "number" ||
                entry.actionLabel === undefined ? (
                  <Text className="font-semibold">
                    {entry.actionLabel ?? "Undo"}
                  </Text>
                ) : (
                  entry.actionLabel
                )}
              </Button>
            </View>
          </AnimatedView>
        ) : null}
      </Portal>
    </UndoBarContext.Provider>
  )
}

export { UndoBarProvider, useUndoBar }
```

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/portal.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/undo-bar.tsx`:

```tsx title="stylesheet/components/interactions/undo-bar.tsx"
import * as React from "react"
import {
  AccessibilityInfo,
  AppState,
  I18nManager,
  type StyleProp,
  StyleSheet,
  useWindowDimensions,
  View,
  type ViewStyle,
} from "react-native"
import {
  cancelAnimation,
  Easing,
  Keyframe,
  ReduceMotion,
  type SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Portal } from "@/components/primitives/portal"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  radius,
  shadow,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type UndoBarOptions = {
  title: React.ReactNode
  actionLabel?: React.ReactNode
  onUndo: () => void
  onCommit?: () => void
}

type UndoBarEntry = UndoBarOptions & { id: number }

type UndoBarContextProps = {
  show: (options: UndoBarOptions) => void
  undo: () => void
  dismiss: () => void
}

const UndoBarContext = React.createContext<UndoBarContextProps | null>(null)

function useUndoBar() {
  const context = React.useContext(UndoBarContext)
  if (!context) {
    throw new Error("useUndoBar must be used within an <UndoBarProvider />")
  }
  return context
}

const MAX_WIDTH = 384

const RING_SIZE = 18

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ translateY: 80 }, { scale: 0.95 }] },
  100: {
    opacity: 1,
    transform: [{ translateY: 0 }, { scale: 1 }],
    easing: EASING,
  },
})
  .duration(400)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] },
  100: {
    opacity: 0,
    transform: [{ translateY: 80 }, { scale: 0.95 }],
    easing: EASING,
  },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

let nextId = 0

const styles = StyleSheet.create({
  ringBox: {
    width: 20,
    height: 20,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
  },
  ring: { width: RING_SIZE, height: RING_SIZE },
  circle: {
    width: RING_SIZE,
    height: RING_SIZE,
    borderRadius: radius.full,
    borderWidth: 2,
  },
  frame: { position: "absolute", zIndex: 50 },
  bar: {
    height: 52,
    width: "100%",
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    borderRadius: radius.full,
    borderWidth: 1,
    paddingStart: 16,
    paddingEnd: 4,
    ...shadow.xl,
  },
  titleBox: { minWidth: 0, flex: 1 },
  title: { ...fontSize.sm, fontWeight: "500" },
  action: { paddingHorizontal: 16 },
  actionText: { fontWeight: "600" },
})

function CountdownRing({
  progress,
  colors,
}: {
  progress: SharedValue<number>
  colors: ThemeColors
}) {
  const half = RING_SIZE / 2
  const endStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${Math.min(progress.value * 360, 180)}deg` }],
  }))
  const startStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${Math.max(progress.value * 360 - 180, 0)}deg` }],
  }))
  const arc = [styles.circle, { borderColor: colors.mutedForeground }]
  return (
    <View
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      style={[
        styles.ringBox,
        I18nManager.isRTL ? { transform: [{ scaleX: -1 }] } : undefined,
      ]}
    >
      <View style={styles.ring}>
        <View
          style={[
            StyleSheet.absoluteFill,
            styles.circle,
            { borderColor: colors.border },
          ]}
        />
        <View
          style={{
            position: "absolute",
            top: 0,
            left: half,
            width: half,
            height: RING_SIZE,
            overflow: "hidden",
          }}
        >
          <AnimatedView
            style={[
              {
                position: "absolute",
                top: 0,
                left: -half,
                width: RING_SIZE,
                height: RING_SIZE,
              },
              endStyle,
            ]}
          >
            <View
              style={{ width: half, height: RING_SIZE, overflow: "hidden" }}
            >
              <View style={arc} />
            </View>
          </AnimatedView>
        </View>
        <View
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            width: half,
            height: RING_SIZE,
            overflow: "hidden",
          }}
        >
          <AnimatedView
            style={[
              {
                position: "absolute",
                top: 0,
                left: 0,
                width: RING_SIZE,
                height: RING_SIZE,
              },
              startStyle,
            ]}
          >
            <View
              style={{
                position: "absolute",
                top: 0,
                left: half,
                width: half,
                height: RING_SIZE,
                overflow: "hidden",
              }}
            >
              <View
                style={[arc, { position: "absolute", top: 0, left: -half }]}
              />
            </View>
          </AnimatedView>
        </View>
      </View>
    </View>
  )
}

function UndoBarProvider({
  timeout = 5000,
  style,
  portalHost,
  children,
}: {
  timeout?: number
  style?: StyleProp<ViewStyle>
  portalHost?: string
  children?: React.ReactNode
}) {
  const { colors, isDark } = useTheme()
  const [entry, setEntry] = React.useState<UndoBarEntry | null>(null)
  const [active, setActive] = React.useState(AppState.currentState === "active")
  const entryRef = React.useRef<UndoBarEntry | null>(null)
  const remainingRef = React.useRef({ id: 0, duration: timeout })
  const progress = useSharedValue(1)
  const insets = useSafeAreaInsets()
  const { width } = useWindowDimensions()
  const inset = Math.max(16, (width - MAX_WIDTH) / 2)
  const id = React.useId()

  const settle = React.useCallback((undo: boolean) => {
    const current = entryRef.current
    if (!current) return
    entryRef.current = null
    setEntry(null)
    if (undo) current.onUndo()
    else current.onCommit?.()
  }, [])

  const show = React.useCallback(
    (options: UndoBarOptions) => {
      entryRef.current?.onCommit?.()
      nextId += 1
      const next = { ...options, id: nextId }
      entryRef.current = next
      remainingRef.current = { id: next.id, duration: timeout }
      setEntry(next)
      if (typeof options.title === "string") {
        AccessibilityInfo.announceForAccessibility(options.title)
      }
    },
    [timeout]
  )

  const undo = React.useCallback(() => settle(true), [settle])
  const dismiss = React.useCallback(() => settle(false), [settle])

  React.useEffect(() => {
    const subscription = AppState.addEventListener("change", (state) =>
      setActive(state === "active")
    )
    return () => subscription.remove()
  }, [])

  React.useEffect(() => {
    if (!entry) return
    if (!active) {
      cancelAnimation(progress)
      return
    }
    const remaining =
      remainingRef.current.id === entry.id
        ? remainingRef.current.duration
        : timeout
    const started = Date.now()
    progress.value = remaining / timeout
    progress.value = withTiming(0, {
      duration: remaining,
      easing: Easing.linear,
      reduceMotion: ReduceMotion.Never,
    })
    const timer = setTimeout(dismiss, remaining)
    return () => {
      clearTimeout(timer)
      if (remainingRef.current.id !== entry.id) return
      remainingRef.current = {
        id: entry.id,
        duration: Math.max(0, remaining - (Date.now() - started)),
      }
    }
  }, [entry, active, timeout, dismiss, progress])

  const contextValue = React.useMemo(
    () => ({ show, undo, dismiss }),
    [show, undo, dismiss]
  )

  return (
    <UndoBarContext.Provider value={contextValue}>
      {children}
      <Portal name={`undo-bar-${id}`} hostName={portalHost}>
        {entry ? (
          <AnimatedView
            key={entry.id}
            entering={ENTERING}
            exiting={EXITING}
            style={[
              styles.frame,
              {
                left: inset,
                right: inset,
                bottom: Math.max(16, insets.bottom),
                pointerEvents: "box-none",
              },
            ]}
          >
            <View
              role="group"
              accessibilityLiveRegion="polite"
              style={[
                styles.bar,
                {
                  borderColor: colors.border,
                  backgroundColor: isDark ? colors.muted : colors.popover,
                },
                style,
              ]}
            >
              <CountdownRing progress={progress} colors={colors} />
              <View style={styles.titleBox}>
                {typeof entry.title === "string" ||
                typeof entry.title === "number" ? (
                  <Text numberOfLines={1} style={styles.title}>
                    {entry.title}
                  </Text>
                ) : (
                  entry.title
                )}
              </View>
              <Button
                variant="plain"
                size="lg"
                style={styles.action}
                onPress={undo}
              >
                {typeof entry.actionLabel === "string" ||
                typeof entry.actionLabel === "number" ||
                entry.actionLabel === undefined ? (
                  <Text style={styles.actionText}>
                    {entry.actionLabel ?? "Undo"}
                  </Text>
                ) : (
                  entry.actionLabel
                )}
              </Button>
            </View>
          </AnimatedView>
        ) : null}
      </Portal>
    </UndoBarContext.Provider>
  )
}

export { UndoBarProvider, useUndoBar }
```

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/UndoBar.swift"
import Observation
import SwiftUI

public struct UndoBarEntry: Identifiable, Sendable {
  public let id: UUID
  public let title: LocalizedStringResource
  public let actionLabel: LocalizedStringResource?
  let onUndo: @MainActor @Sendable () -> Void
  let onCommit: (@MainActor @Sendable () -> Void)?
}

@Observable
public final class UndoBarManager: @unchecked Sendable {
  public static let shared = UndoBarManager()

  public private(set) var entry: UndoBarEntry?
  public var timeout: Duration = .seconds(5)
  private(set) var paused = false

  @ObservationIgnored private var started = Date()
  @ObservationIgnored private var elapsed: TimeInterval = 0
  @ObservationIgnored private var timer: Task<Void, Never>?

  public init() {}

  @MainActor
  public func show(
    _ title: LocalizedStringResource,
    actionLabel: LocalizedStringResource? = nil,
    onUndo: @escaping @MainActor @Sendable () -> Void,
    onCommit: (@MainActor @Sendable () -> Void)? = nil
  ) {
    let previous = entry
    entry = UndoBarEntry(id: UUID(), title: title, actionLabel: actionLabel, onUndo: onUndo, onCommit: onCommit)
    previous?.onCommit?()
    elapsed = 0
    started = Date()
    if !paused { schedule() }
    AccessibilityNotification.Announcement(String(localized: title)).post()
  }

  @MainActor
  public func undo() {
    settle(undo: true)
  }

  @MainActor
  public func dismiss() {
    settle(undo: false)
  }

  @MainActor
  func pause() {
    guard !paused else { return }
    paused = true
    elapsed += Date().timeIntervalSince(started)
    timer?.cancel()
    timer = nil
  }

  @MainActor
  func resume() {
    guard paused else { return }
    paused = false
    started = Date()
    if entry != nil { schedule() }
  }

  func progress(at date: Date) -> Double {
    let total = Double(timeout.components.seconds) + Double(timeout.components.attoseconds) / 1e18
    guard total > 0 else { return 1 }
    let spent = elapsed + (paused ? 0 : date.timeIntervalSince(started))
    return min(max(spent / total, 0), 1)
  }

  @MainActor
  private func settle(undo: Bool) {
    guard let current = entry else { return }
    timer?.cancel()
    timer = nil
    entry = nil
    if undo {
      current.onUndo()
    } else {
      current.onCommit?()
    }
  }

  @MainActor
  private func schedule() {
    timer?.cancel()
    guard let id = entry?.id else { return }
    let remaining = timeout - .milliseconds(Int(elapsed * 1000))
    timer = Task { @MainActor [weak self] in
      try? await Task.sleep(for: max(remaining, .zero))
      guard !Task.isCancelled, let self, self.entry?.id == id else { return }
      self.dismiss()
    }
  }
}

extension EnvironmentValues {
  @Entry public var undoBarManager: UndoBarManager = .shared
}

extension View {
  public func undoBar(_ manager: UndoBarManager = .shared, timeout: Duration = .seconds(5)) -> some View {
    overlay(alignment: .bottom) {
      UndoBarRegion(manager: manager)
    }
    .environment(\.undoBarManager, manager)
    .onAppear { manager.timeout = timeout }
    .onChange(of: timeout) { _, value in manager.timeout = value }
  }
}

struct UndoBarRegion: View {
  let manager: UndoBarManager

  @Environment(\.scenePhase) private var scenePhase
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    ZStack(alignment: .bottom) {
      if let entry = manager.entry {
        UndoBarView(manager: manager, entry: entry)
          .id(entry.id)
          .transition(
            reduceMotion
              ? .opacity
              : .asymmetric(
                insertion: .offset(y: 68).combined(with: .scale(scale: 0.95, anchor: .bottom)).combined(with: .opacity),
                removal: .offset(y: 68).combined(with: .scale(scale: 0.95, anchor: .bottom)).combined(with: .opacity)
              )
          )
      }
    }
    .animation(.timingCurve(0.32, 0.72, 0, 1, duration: 0.4), value: manager.entry?.id)
    .onChange(of: scenePhase, initial: true) { _, phase in
      if phase == .active {
        manager.resume()
      } else {
        manager.pause()
      }
    }
  }
}

struct UndoBarView: View {
  let manager: UndoBarManager
  let entry: UndoBarEntry

  var body: some View {
    FloatingBar(visible: true, maxWidth: 384) {
      HStack(spacing: 12) {
        TimelineView(.animation(minimumInterval: nil, paused: manager.paused)) { context in
          UndoBarRing(progress: manager.progress(at: context.date))
        }
        Text(entry.title)
          .scaledFont(.sm, weight: .medium)
          .lineLimit(1)
          .truncationMode(.tail)
          .frame(maxWidth: .infinity, alignment: .leading)
        Button {
          manager.undo()
        } label: {
          Text(entry.actionLabel ?? "Undo").fontWeight(.semibold)
        }
        .buttonStyle(VariantButtonStyle(.plain, size: .lg, metrics: ButtonMetrics(horizontalPadding: 16, spacing: 6, iconSize: 16, textStyle: .base)))
      }
      .padding(.leading, 16)
      .padding(.trailing, 4)
    }
    .accessibilityElement(children: .contain)
    .accessibilityAction(.escape) { manager.dismiss() }
  }
}

struct UndoBarRing: View {
  let progress: Double

  var body: some View {
    ZStack {
      Circle()
        .stroke(EsuissColor.border, lineWidth: 2)
      Circle()
        .trim(from: 0, to: 1 - progress)
        .stroke(EsuissColor.mutedForeground, style: StrokeStyle(lineWidth: 2, lineCap: .round))
        .rotationEffect(.degrees(-90))
    }
    .frame(width: 16, height: 16)
    .frame(width: 20, height: 20)
    .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/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Interactions/SelectionList.swift`: see [Selection List](https://ui.suiss.app/docs/interactions/selection-list.md)

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

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/UndoBar.kt"
package com.esuiss.ui.interactions

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Immutable
internal class UndoBarEntry(
    val id: Long,
    val title: String,
    val actionLabel: String?,
    val onUndo: () -> Unit,
    val onCommit: (() -> Unit)?,
)

@Stable
class UndoBarState {
    private var nextId = 0L
    private var entry by mutableStateOf<UndoBarEntry?>(null)
    internal var visible by mutableStateOf(false)
        private set
    private var pending: UndoBarEntry? = null

    internal val current: UndoBarEntry?
        get() = entry

    fun show(
        title: String,
        onUndo: () -> Unit,
        actionLabel: String? = null,
        onCommit: (() -> Unit)? = null,
    ) {
        pending?.onCommit?.invoke()
        nextId += 1
        val next = UndoBarEntry(nextId, title, actionLabel, onUndo, onCommit)
        pending = next
        entry = next
        visible = true
    }

    fun undo() {
        settle(true)
    }

    fun dismiss() {
        settle(false)
    }

    private fun settle(undo: Boolean) {
        val active = pending ?: return
        pending = null
        visible = false
        if (undo) active.onUndo() else active.onCommit?.invoke()
    }
}

@Composable
fun rememberUndoBarState(): UndoBarState = remember { UndoBarState() }

@Composable
fun UndoBarProvider(
    state: UndoBarState,
    modifier: Modifier = Modifier,
    timeoutMillis: Long = 5000,
    content: @Composable () -> Unit,
) {
    content()
    val entry = state.current ?: return
    val visible = state.visible
    val progress = remember { Animatable(0f) }
    LaunchedEffect(entry.id, visible, timeoutMillis) {
        if (!visible) return@LaunchedEffect
        progress.snapTo(0f)
        progress.animateTo(1f, tween(timeoutMillis.toInt(), easing = LinearEasing))
        state.dismiss()
    }
    val colors = EsuissTheme.colors
    FloatingBar(
        visible = visible,
        maxWidth = 384.dp,
        contentPadding = PaddingValues(start = 16.dp, end = 4.dp),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
    ) {
        val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
        Canvas(modifier = Modifier.size(20.dp)) {
            val stroke = 2.dp.toPx()
            val radius = 8.dp.toPx()
            val topLeft = Offset(center.x - radius, center.y - radius)
            val arc = Size(radius * 2, radius * 2)
            drawCircle(colors.border, radius, style = Stroke(stroke))
            scale(if (rtl) -1f else 1f, 1f) {
                drawArc(
                    color = colors.mutedForeground,
                    startAngle = -90f + progress.value * 360f,
                    sweepAngle = (1f - progress.value) * 360f,
                    useCenter = false,
                    topLeft = topLeft,
                    size = arc,
                    style = Stroke(stroke, cap = StrokeCap.Round),
                )
            }
        }
        BasicText(
            entry.title,
            modifier = Modifier.weight(1f),
            style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.Medium),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Button(onClick = { state.undo() }, variant = ButtonVariant.Plain, size = ButtonSize.Lg) {
            BasicText(
                entry.actionLabel ?: "Undo",
                style = LocalContentStyle.current.copy(fontWeight = FontWeight.SemiBold),
                maxLines = 1,
            )
        }
    }
}
```

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

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

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

- `kotlin/components/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/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

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

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

## Usage

```tsx
import { UndoBarProvider } from "@/components/interactions/undo-bar"
```

```tsx
<UndoBarProvider>
  <NotesList />
</UndoBarProvider>
```

Wrap the screen or app in `UndoBarProvider`. After removing an item from the UI, call `show({ title, onUndo, onCommit?, actionLabel? })`: restore the item in `onUndo`, and do the permanent work, such as a server delete, in `onCommit`.

`useUndoBar` also returns `undo`, which runs the current undo, and `dismiss`, which commits and hides the bar.

## Examples

### Showing

The bar shown right after deleting a note, with a long timeout.

![Preview](https://ui.suiss.app/previews/interactions-undo-bar--showing.light.webp)

```tsx
import { TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { UndoBarProvider, useUndoBar } from "@/components/interactions/undo-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const initialNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

type Note = (typeof initialNotes)[number]

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Delete a note.")
  const { show } = useUndoBar()

  const remove = (note: Note) => {
    const index = notes.findIndex((item) => item.id === note.id)
    setNotes((current) => current.filter((item) => item.id !== note.id))
    setLog(`Removed “${note.title}”`)
    show({
      title: `“${note.title}” deleted`,
      onUndo: () => {
        setNotes((current) => {
          const next = [...current]
          next.splice(Math.min(index, next.length), 0, note)
          return next
        })
        setLog(`Restored “${note.title}”`)
      },
      onCommit: () => setLog(`Deleted “${note.title}” permanently`),
    })
  }

  return { notes, remove, log }
}

function NoteText({ note }: { note: Note }) {
  return (
    <View className="min-w-0 flex-1 py-2">
      <Text numberOfLines={1} className="font-semibold">
        {note.title}
      </Text>
      <Text numberOfLines={1} className="text-sm text-muted-foreground">
        {note.preview}
      </Text>
    </View>
  )
}

function NotesList({ deleteFirst = false }: { deleteFirst?: boolean }) {
  const { notes, remove, log } = useNotes()
  const removeRef = React.useRef(remove)
  const deletedRef = React.useRef(false)
  removeRef.current = remove

  React.useEffect(() => {
    if (!deleteFirst || deletedRef.current) return
    deletedRef.current = true
    removeRef.current(initialNotes[0])
  }, [deleteFirst])

  return (
    <View className="w-full gap-3">
      <View className="overflow-hidden rounded-2xl border border-border">
        {notes.map((note, index) => (
          <View
            key={note.id}
            className={
              index > 0
                ? "flex-row items-center gap-3 border-t border-border py-1 ps-4 pe-1"
                : "flex-row items-center gap-3 py-1 ps-4 pe-1"
            }
          >
            <NoteText note={note} />
            <Button
              variant="ghost"
              size="icon-lg"
              accessibilityLabel={`Delete ${note.title}`}
              onPress={() => remove(note)}
            >
              <Icon as={TrashIcon} className="size-5" />
            </Button>
          </View>
        ))}
      </View>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

export function UndoBarShowingExample() {
  return (
    <UndoBarProvider timeout={60000}>
      <NotesList deleteFirst />
    </UndoBarProvider>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct UndoBarShowingExample: View {
  var body: some View {
    UndoBarStage(timeout: .seconds(60)) { UndoBarNotes(deleteFirst: true) }
  }
}

struct UndoBarNote: Identifiable, Equatable {
  let id: Int
  let title: String
  let preview: String
}

let undoBarInitialNotes = [
  UndoBarNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  UndoBarNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  UndoBarNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  UndoBarNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
]

struct UndoBarStage<Content: View>: View {
  let timeout: Duration
  @ViewBuilder let content: () -> Content

  @State private var manager = UndoBarManager()

  var body: some View {
    content()
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .undoBar(manager, timeout: timeout)
  }
}

struct UndoBarNoteText: View {
  let note: UndoBarNote

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      Text(verbatim: note.title)
        .textStyle(.base, weight: .semibold)
        .foregroundStyle(EsuissColor.foreground)
        .lineLimit(1)
      Text(verbatim: note.preview)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .lineLimit(1)
    }
    .padding(.vertical, 8)
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct UndoBarNotes: View {
  var deleteFirst = false
  var swipe = false

  @State private var notes = undoBarInitialNotes
  @State private var log = "Delete a note."
  @State private var deleted = false
  @Environment(\.undoBarManager) private var manager

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 12) {
      VStack(spacing: 0) {
        ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
          row(note)
            .overlay(alignment: .top) {
              if index > 0 {
                EsuissColor.border.frame(height: 1)
              }
            }
        }
      }
      .clipShape(shape)
      .overlay { shape.strokeBorder(EsuissColor.border) }
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .accessibilityAddTraits(.updatesFrequently)
    }
    .onAppear {
      guard deleteFirst, !deleted else { return }
      deleted = true
      remove(undoBarInitialNotes[0])
    }
  }

  @ViewBuilder
  func row(_ note: UndoBarNote) -> some View {
    if swipe {
      SwipeActions {
        UndoBarNoteText(note: note)
          .padding(.horizontal, 16)
          .padding(.vertical, 4)
      } trailing: {
        SwipeAction("Delete", icon: Ph.trash.fill, variant: .destructive, fullSwipe: true) {
          remove(note)
        }
      }
    } else {
      HStack(spacing: 12) {
        UndoBarNoteText(note: note)
        Button {
          remove(note)
        } label: {
          Ph.trash.regular.frame(width: 20, height: 20)
        }
        .buttonStyle(.variant(.ghost, size: .iconLg))
        .accessibilityLabel(Text(verbatim: "Delete \(note.title)"))
      }
      .padding(.leading, 16)
      .padding(.trailing, 4)
      .padding(.vertical, 4)
    }
  }

  func remove(_ note: UndoBarNote) {
    let index = notes.firstIndex(of: note) ?? 0
    notes.removeAll { $0.id == note.id }
    log = "Removed “\(note.title)”"
    manager.show(
      "“\(note.title)” deleted",
      onUndo: {
        notes.insert(note, at: min(index, notes.count))
        log = "Restored “\(note.title)”"
      },
      onCommit: {
        log = "Deleted “\(note.title)” permanently"
      }
    )
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.Trash
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.UndoBarProvider
import com.esuiss.ui.interactions.UndoBarState
import com.esuiss.ui.interactions.rememberUndoBarState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun UndoBarShowingExample() {
    UndoBarDemo(timeout = 60000L) { state -> UndoBarNotesList(state, deleteFirst = true) }
}

class UndoBarNote(val id: Int, val title: String, val preview: String)

val undoBarInitialNotes = listOf(
    UndoBarNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    UndoBarNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    UndoBarNote(3, "Book list", "Three novels for the summer"),
    UndoBarNote(4, "Meeting notes", "Ship the beta on Friday"),
)

class UndoBarNotes(
    val notes: List<UndoBarNote>,
    val log: String,
    val remove: (UndoBarNote) -> Unit,
)

@Composable
fun UndoBarDemo(timeout: Long, content: @Composable (UndoBarState) -> Unit) {
    val state = rememberUndoBarState()
    UndoBarProvider(state, timeoutMillis = timeout) {
        content(state)
    }
}

@Composable
fun rememberUndoBarNotes(state: UndoBarState): UndoBarNotes {
    var ids by rememberSaveable { mutableStateOf(undoBarInitialNotes.map { it.id }) }
    var log by rememberSaveable { mutableStateOf("Delete a note.") }
    val notes = ids.mapNotNull { id -> undoBarInitialNotes.firstOrNull { it.id == id } }
    return UndoBarNotes(notes, log) { note ->
        val index = ids.indexOf(note.id)
        ids = ids - note.id
        log = "Removed “${note.title}”"
        state.show(
            title = "“${note.title}” deleted",
            onUndo = {
                ids = ids.toMutableList().apply { add(minOf(index, size), note.id) }
                log = "Restored “${note.title}”"
            },
            onCommit = { log = "Deleted “${note.title}” permanently" },
        )
    }
}

@Composable
fun UndoBarNoteText(note: UndoBarNote, modifier: Modifier = Modifier) {
    Column(modifier.padding(vertical = 8.dp)) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

@Composable
fun UndoBarFrame(notes: UndoBarNotes, row: @Composable (UndoBarNote) -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Column(
            Modifier
                .fillMaxWidth()
                .clip(shape)
                .border(1.dp, colors.border, shape),
        ) {
            notes.notes.forEachIndexed { index, note ->
                key(note.id) {
                    if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                    row(note)
                }
            }
        }
        Text(
            notes.log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = colors.mutedForeground),
        )
    }
}

@Composable
fun UndoBarNotesList(state: UndoBarState, deleteFirst: Boolean = false) {
    val notes = rememberUndoBarNotes(state)
    var deleted by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (!deleteFirst || deleted) return@LaunchedEffect
        deleted = true
        notes.remove(undoBarInitialNotes.first())
    }
    UndoBarFrame(notes) { note ->
        Row(
            modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            UndoBarNoteText(note, Modifier.weight(1f))
            Button(onClick = { notes.remove(note) }, variant = ButtonVariant.Ghost, size = ButtonSize.IconLg) {
                Icon(PhosphorIcons.Regular.Trash, contentDescription = "Delete ${note.title}", size = 20.dp)
            }
        }
    }
}
```

### Longer timeout

Uses a 10 second `timeout`.

![Preview](https://ui.suiss.app/previews/interactions-undo-bar--longer-timeout.light.webp)

```tsx
import { TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { UndoBarProvider, useUndoBar } from "@/components/interactions/undo-bar"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const initialNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

type Note = (typeof initialNotes)[number]

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Delete a note.")
  const { show } = useUndoBar()

  const remove = (note: Note) => {
    const index = notes.findIndex((item) => item.id === note.id)
    setNotes((current) => current.filter((item) => item.id !== note.id))
    setLog(`Removed “${note.title}”`)
    show({
      title: `“${note.title}” deleted`,
      onUndo: () => {
        setNotes((current) => {
          const next = [...current]
          next.splice(Math.min(index, next.length), 0, note)
          return next
        })
        setLog(`Restored “${note.title}”`)
      },
      onCommit: () => setLog(`Deleted “${note.title}” permanently`),
    })
  }

  return { notes, remove, log }
}

function NoteText({ note }: { note: Note }) {
  return (
    <View className="min-w-0 flex-1 py-2">
      <Text numberOfLines={1} className="font-semibold">
        {note.title}
      </Text>
      <Text numberOfLines={1} className="text-sm text-muted-foreground">
        {note.preview}
      </Text>
    </View>
  )
}

function NotesList({ deleteFirst = false }: { deleteFirst?: boolean }) {
  const { notes, remove, log } = useNotes()
  const removeRef = React.useRef(remove)
  const deletedRef = React.useRef(false)
  removeRef.current = remove

  React.useEffect(() => {
    if (!deleteFirst || deletedRef.current) return
    deletedRef.current = true
    removeRef.current(initialNotes[0])
  }, [deleteFirst])

  return (
    <View className="w-full gap-3">
      <View className="overflow-hidden rounded-2xl border border-border">
        {notes.map((note, index) => (
          <View
            key={note.id}
            className={
              index > 0
                ? "flex-row items-center gap-3 border-t border-border py-1 ps-4 pe-1"
                : "flex-row items-center gap-3 py-1 ps-4 pe-1"
            }
          >
            <NoteText note={note} />
            <Button
              variant="ghost"
              size="icon-lg"
              accessibilityLabel={`Delete ${note.title}`}
              onPress={() => remove(note)}
            >
              <Icon as={TrashIcon} className="size-5" />
            </Button>
          </View>
        ))}
      </View>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

export function UndoBarLongerTimeoutExample() {
  return (
    <UndoBarProvider timeout={10000}>
      <NotesList />
    </UndoBarProvider>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct UndoBarLongerTimeoutExample: View {
  var body: some View {
    UndoBarStage(timeout: .seconds(10)) { UndoBarNotes() }
  }
}

struct UndoBarNote: Identifiable, Equatable {
  let id: Int
  let title: String
  let preview: String
}

let undoBarInitialNotes = [
  UndoBarNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  UndoBarNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  UndoBarNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  UndoBarNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
]

struct UndoBarStage<Content: View>: View {
  let timeout: Duration
  @ViewBuilder let content: () -> Content

  @State private var manager = UndoBarManager()

  var body: some View {
    content()
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .undoBar(manager, timeout: timeout)
  }
}

struct UndoBarNoteText: View {
  let note: UndoBarNote

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      Text(verbatim: note.title)
        .textStyle(.base, weight: .semibold)
        .foregroundStyle(EsuissColor.foreground)
        .lineLimit(1)
      Text(verbatim: note.preview)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .lineLimit(1)
    }
    .padding(.vertical, 8)
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct UndoBarNotes: View {
  var deleteFirst = false
  var swipe = false

  @State private var notes = undoBarInitialNotes
  @State private var log = "Delete a note."
  @State private var deleted = false
  @Environment(\.undoBarManager) private var manager

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 12) {
      VStack(spacing: 0) {
        ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
          row(note)
            .overlay(alignment: .top) {
              if index > 0 {
                EsuissColor.border.frame(height: 1)
              }
            }
        }
      }
      .clipShape(shape)
      .overlay { shape.strokeBorder(EsuissColor.border) }
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .accessibilityAddTraits(.updatesFrequently)
    }
    .onAppear {
      guard deleteFirst, !deleted else { return }
      deleted = true
      remove(undoBarInitialNotes[0])
    }
  }

  @ViewBuilder
  func row(_ note: UndoBarNote) -> some View {
    if swipe {
      SwipeActions {
        UndoBarNoteText(note: note)
          .padding(.horizontal, 16)
          .padding(.vertical, 4)
      } trailing: {
        SwipeAction("Delete", icon: Ph.trash.fill, variant: .destructive, fullSwipe: true) {
          remove(note)
        }
      }
    } else {
      HStack(spacing: 12) {
        UndoBarNoteText(note: note)
        Button {
          remove(note)
        } label: {
          Ph.trash.regular.frame(width: 20, height: 20)
        }
        .buttonStyle(.variant(.ghost, size: .iconLg))
        .accessibilityLabel(Text(verbatim: "Delete \(note.title)"))
      }
      .padding(.leading, 16)
      .padding(.trailing, 4)
      .padding(.vertical, 4)
    }
  }

  func remove(_ note: UndoBarNote) {
    let index = notes.firstIndex(of: note) ?? 0
    notes.removeAll { $0.id == note.id }
    log = "Removed “\(note.title)”"
    manager.show(
      "“\(note.title)” deleted",
      onUndo: {
        notes.insert(note, at: min(index, notes.count))
        log = "Restored “\(note.title)”"
      },
      onCommit: {
        log = "Deleted “\(note.title)” permanently"
      }
    )
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.Trash
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.UndoBarProvider
import com.esuiss.ui.interactions.UndoBarState
import com.esuiss.ui.interactions.rememberUndoBarState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun UndoBarLongerTimeoutExample() {
    UndoBarDemo(timeout = 10000L) { state -> UndoBarNotesList(state) }
}

class UndoBarNote(val id: Int, val title: String, val preview: String)

val undoBarInitialNotes = listOf(
    UndoBarNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    UndoBarNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    UndoBarNote(3, "Book list", "Three novels for the summer"),
    UndoBarNote(4, "Meeting notes", "Ship the beta on Friday"),
)

class UndoBarNotes(
    val notes: List<UndoBarNote>,
    val log: String,
    val remove: (UndoBarNote) -> Unit,
)

@Composable
fun UndoBarDemo(timeout: Long, content: @Composable (UndoBarState) -> Unit) {
    val state = rememberUndoBarState()
    UndoBarProvider(state, timeoutMillis = timeout) {
        content(state)
    }
}

@Composable
fun rememberUndoBarNotes(state: UndoBarState): UndoBarNotes {
    var ids by rememberSaveable { mutableStateOf(undoBarInitialNotes.map { it.id }) }
    var log by rememberSaveable { mutableStateOf("Delete a note.") }
    val notes = ids.mapNotNull { id -> undoBarInitialNotes.firstOrNull { it.id == id } }
    return UndoBarNotes(notes, log) { note ->
        val index = ids.indexOf(note.id)
        ids = ids - note.id
        log = "Removed “${note.title}”"
        state.show(
            title = "“${note.title}” deleted",
            onUndo = {
                ids = ids.toMutableList().apply { add(minOf(index, size), note.id) }
                log = "Restored “${note.title}”"
            },
            onCommit = { log = "Deleted “${note.title}” permanently" },
        )
    }
}

@Composable
fun UndoBarNoteText(note: UndoBarNote, modifier: Modifier = Modifier) {
    Column(modifier.padding(vertical = 8.dp)) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

@Composable
fun UndoBarFrame(notes: UndoBarNotes, row: @Composable (UndoBarNote) -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Column(
            Modifier
                .fillMaxWidth()
                .clip(shape)
                .border(1.dp, colors.border, shape),
        ) {
            notes.notes.forEachIndexed { index, note ->
                key(note.id) {
                    if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                    row(note)
                }
            }
        }
        Text(
            notes.log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = colors.mutedForeground),
        )
    }
}

@Composable
fun UndoBarNotesList(state: UndoBarState, deleteFirst: Boolean = false) {
    val notes = rememberUndoBarNotes(state)
    var deleted by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (!deleteFirst || deleted) return@LaunchedEffect
        deleted = true
        notes.remove(undoBarInitialNotes.first())
    }
    UndoBarFrame(notes) { note ->
        Row(
            modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            UndoBarNoteText(note, Modifier.weight(1f))
            Button(onClick = { notes.remove(note) }, variant = ButtonVariant.Ghost, size = ButtonSize.IconLg) {
                Icon(PhosphorIcons.Regular.Trash, contentDescription = "Delete ${note.title}", size = 20.dp)
            }
        }
    }
}
```

### With swipe actions

Shows the bar after a swipe-to-delete in a list with swipe actions.

![Preview](https://ui.suiss.app/previews/interactions-undo-bar--with-swipe-actions.light.webp)

```tsx
import { TrashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { SwipeAction, SwipeActions, SwipeActionsActions, SwipeActionsContent } from "@/components/interactions/swipe-actions"
import { UndoBarProvider, useUndoBar } from "@/components/interactions/undo-bar"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const initialNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

type Note = (typeof initialNotes)[number]

function useNotes() {
  const [notes, setNotes] = React.useState(initialNotes)
  const [log, setLog] = React.useState("Delete a note.")
  const { show } = useUndoBar()

  const remove = (note: Note) => {
    const index = notes.findIndex((item) => item.id === note.id)
    setNotes((current) => current.filter((item) => item.id !== note.id))
    setLog(`Removed “${note.title}”`)
    show({
      title: `“${note.title}” deleted`,
      onUndo: () => {
        setNotes((current) => {
          const next = [...current]
          next.splice(Math.min(index, next.length), 0, note)
          return next
        })
        setLog(`Restored “${note.title}”`)
      },
      onCommit: () => setLog(`Deleted “${note.title}” permanently`),
    })
  }

  return { notes, remove, log }
}

function NoteText({ note }: { note: Note }) {
  return (
    <View className="min-w-0 flex-1 py-2">
      <Text numberOfLines={1} className="font-semibold">
        {note.title}
      </Text>
      <Text numberOfLines={1} className="text-sm text-muted-foreground">
        {note.preview}
      </Text>
    </View>
  )
}

function SwipeNotesList() {
  const { notes, remove, log } = useNotes()
  return (
    <View className="w-full gap-3">
      <View className="overflow-hidden rounded-2xl border border-border">
        {notes.map((note, index) => (
          <View
            key={note.id}
            className={index > 0 ? "border-t border-border" : undefined}
          >
            <SwipeActions>
              <SwipeActionsContent className="bg-background px-4 py-1">
                <NoteText note={note} />
              </SwipeActionsContent>
              <SwipeActionsActions side="trailing">
                <SwipeAction
                  variant="destructive"
                  fullSwipe
                  onPress={() => remove(note)}
                >
                  <Icon as={TrashIcon} weight="fill" />
                  <Text>Delete</Text>
                </SwipeAction>
              </SwipeActionsActions>
            </SwipeActions>
          </View>
        ))}
      </View>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

export function UndoBarWithSwipeActionsExample() {
  return (
    <UndoBarProvider>
      <SwipeNotesList />
    </UndoBarProvider>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct UndoBarWithSwipeActionsExample: View {
  var body: some View {
    UndoBarStage(timeout: .seconds(5)) { UndoBarNotes(swipe: true) }
  }
}

struct UndoBarNote: Identifiable, Equatable {
  let id: Int
  let title: String
  let preview: String
}

let undoBarInitialNotes = [
  UndoBarNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  UndoBarNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  UndoBarNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  UndoBarNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
]

struct UndoBarStage<Content: View>: View {
  let timeout: Duration
  @ViewBuilder let content: () -> Content

  @State private var manager = UndoBarManager()

  var body: some View {
    content()
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .undoBar(manager, timeout: timeout)
  }
}

struct UndoBarNoteText: View {
  let note: UndoBarNote

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      Text(verbatim: note.title)
        .textStyle(.base, weight: .semibold)
        .foregroundStyle(EsuissColor.foreground)
        .lineLimit(1)
      Text(verbatim: note.preview)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .lineLimit(1)
    }
    .padding(.vertical, 8)
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct UndoBarNotes: View {
  var deleteFirst = false
  var swipe = false

  @State private var notes = undoBarInitialNotes
  @State private var log = "Delete a note."
  @State private var deleted = false
  @Environment(\.undoBarManager) private var manager

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 12) {
      VStack(spacing: 0) {
        ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
          row(note)
            .overlay(alignment: .top) {
              if index > 0 {
                EsuissColor.border.frame(height: 1)
              }
            }
        }
      }
      .clipShape(shape)
      .overlay { shape.strokeBorder(EsuissColor.border) }
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .accessibilityAddTraits(.updatesFrequently)
    }
    .onAppear {
      guard deleteFirst, !deleted else { return }
      deleted = true
      remove(undoBarInitialNotes[0])
    }
  }

  @ViewBuilder
  func row(_ note: UndoBarNote) -> some View {
    if swipe {
      SwipeActions {
        UndoBarNoteText(note: note)
          .padding(.horizontal, 16)
          .padding(.vertical, 4)
      } trailing: {
        SwipeAction("Delete", icon: Ph.trash.fill, variant: .destructive, fullSwipe: true) {
          remove(note)
        }
      }
    } else {
      HStack(spacing: 12) {
        UndoBarNoteText(note: note)
        Button {
          remove(note)
        } label: {
          Ph.trash.regular.frame(width: 20, height: 20)
        }
        .buttonStyle(.variant(.ghost, size: .iconLg))
        .accessibilityLabel(Text(verbatim: "Delete \(note.title)"))
      }
      .padding(.leading, 16)
      .padding(.trailing, 4)
      .padding(.vertical, 4)
    }
  }

  func remove(_ note: UndoBarNote) {
    let index = notes.firstIndex(of: note) ?? 0
    notes.removeAll { $0.id == note.id }
    log = "Removed “\(note.title)”"
    manager.show(
      "“\(note.title)” deleted",
      onUndo: {
        notes.insert(note, at: min(index, notes.count))
        log = "Restored “\(note.title)”"
      },
      onCommit: {
        log = "Deleted “\(note.title)” permanently"
      }
    )
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.Trash
import com.adamglin.phosphoricons.regular.Trash
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.SwipeAction
import com.esuiss.ui.interactions.SwipeActionVariant
import com.esuiss.ui.interactions.SwipeActions
import com.esuiss.ui.interactions.UndoBarProvider
import com.esuiss.ui.interactions.UndoBarState
import com.esuiss.ui.interactions.rememberUndoBarState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun UndoBarWithSwipeActionsExample() {
    UndoBarDemo(timeout = 5000L) { state -> UndoBarSwipeNotesList(state) }
}

class UndoBarNote(val id: Int, val title: String, val preview: String)

val undoBarInitialNotes = listOf(
    UndoBarNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    UndoBarNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    UndoBarNote(3, "Book list", "Three novels for the summer"),
    UndoBarNote(4, "Meeting notes", "Ship the beta on Friday"),
)

class UndoBarNotes(
    val notes: List<UndoBarNote>,
    val log: String,
    val remove: (UndoBarNote) -> Unit,
)

@Composable
fun UndoBarDemo(timeout: Long, content: @Composable (UndoBarState) -> Unit) {
    val state = rememberUndoBarState()
    UndoBarProvider(state, timeoutMillis = timeout) {
        content(state)
    }
}

@Composable
fun rememberUndoBarNotes(state: UndoBarState): UndoBarNotes {
    var ids by rememberSaveable { mutableStateOf(undoBarInitialNotes.map { it.id }) }
    var log by rememberSaveable { mutableStateOf("Delete a note.") }
    val notes = ids.mapNotNull { id -> undoBarInitialNotes.firstOrNull { it.id == id } }
    return UndoBarNotes(notes, log) { note ->
        val index = ids.indexOf(note.id)
        ids = ids - note.id
        log = "Removed “${note.title}”"
        state.show(
            title = "“${note.title}” deleted",
            onUndo = {
                ids = ids.toMutableList().apply { add(minOf(index, size), note.id) }
                log = "Restored “${note.title}”"
            },
            onCommit = { log = "Deleted “${note.title}” permanently" },
        )
    }
}

@Composable
fun UndoBarNoteText(note: UndoBarNote, modifier: Modifier = Modifier) {
    Column(modifier.padding(vertical = 8.dp)) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

@Composable
fun UndoBarFrame(notes: UndoBarNotes, row: @Composable (UndoBarNote) -> Unit) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(Radius.xl2)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Column(
            Modifier
                .fillMaxWidth()
                .clip(shape)
                .border(1.dp, colors.border, shape),
        ) {
            notes.notes.forEachIndexed { index, note ->
                key(note.id) {
                    if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                    row(note)
                }
            }
        }
        Text(
            notes.log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = colors.mutedForeground),
        )
    }
}

@Composable
fun UndoBarSwipeNotesList(state: UndoBarState) {
    val notes = rememberUndoBarNotes(state)
    UndoBarFrame(notes) { note ->
        SwipeActions(
            trailing = listOf(
                SwipeAction(
                    "Delete",
                    { notes.remove(note) },
                    icon = PhosphorIcons.Fill.Trash,
                    variant = SwipeActionVariant.Destructive,
                    fullSwipe = true,
                ),
            ),
            background = EsuissTheme.colors.background,
        ) {
            UndoBarNoteText(note, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp))
        }
    }
}
```

## API Reference

### UndoBarProvider

Holds the current undo entry and renders the bar in a portal.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `timeout` | `number` | `5000` | Countdown length in milliseconds. Defaults to `5000`. |
| `className` | `string` | - | Classes for the bar. |
| `portalHost` | `string` | - | Name of a portal host to render into. |
| `children` | `React.ReactNode` | - |  |

## Accessibility

- A string title is announced when the bar appears.
- The bar has the `group` role and is a polite live region.
- The countdown ring is hidden from screen readers.

## Notes

- The bar is up to 384 points wide and centered; it uses `bg-popover`, or `bg-muted` in dark mode, with a `border-border` outline.
- The ring drains linearly and runs counterclockwise in right-to-left layouts.
- Slide animations respect the system reduce motion setting; the countdown always runs.

