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

suiss/ui · Interaction

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.

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

$ pnpm dlx shadcn@latest add @esuiss/undo-bar

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

$ pnpm dlx @esuiss/ui@latest add interactions undo-bar

Manual

  1. Add the cn helper.

  2. Add the pieces it builds on: Button.

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

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 = {
  1. Update the import paths to match your project.

Usage

import { UndoBarProvider } from "@/components/interactions/undo-bar"
<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.

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.

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

01

UndoBarProvider

Provides useUndoBar and renders the floating undo bar.

PropTypeDefaultDescription
timeoutnumber5000Milliseconds the bar stays visible before committing.
classNamestring-Classes for the fixed region that positions the bar.
childrenReact.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.
iOS
Undo Bar on iOS in light modeUndo Bar on iOS in dark mode
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>
  )
}
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"
      }
    )
  }
}
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

$ pnpm dlx shadcn@latest add @esuiss-rn/undo-bar

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

$ pnpm dlx @esuiss/ui@latest add undo-bar
$ pnpm dlx @esuiss/ui@latest add undo-bar --platform swift
$ pnpm dlx @esuiss/ui@latest add undo-bar --platform kotlin

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

Manual

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

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

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

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

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

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/undo-bar.tsx:
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,
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn react-native-reanimated react-native-safe-area-context react-native-worklets
  1. Add the pieces it builds on: Button, Text, and the primitives components/primitives/portal.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

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

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

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

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/undo-bar.tsx:
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,
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets
  1. Add the pieces it builds on: Button, Text, and the primitives components/primitives/portal.tsx, and the animated view components/ui/animated-view.tsx from the next step.

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

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

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

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

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/undo-bar.tsx:
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,
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Interactions/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?
swift/Components/Button.swift
import SwiftUI

public struct VariantButtonStyle: ButtonStyle {
  public enum Variant: CaseIterable, Sendable {
    case `default`, outline, secondary, tinted, ghost, plain, destructive, link
  }

  public enum Size: CaseIterable, Sendable {
    case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl

    var height: CGFloat {
      switch self {
      case .xs, .iconXs: 24
      case .sm, .iconSm: 32
      case .default, .icon: 36
      case .lg, .iconLg: 44
swift/Components/ButtonGroup.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var buttonGroup: Axis? = nil
  @Entry var buttonGroupSize: CGSize = .zero
}

enum ButtonGroupSpace: Hashable {
  case group
}

struct ButtonGroupFrame {
  let bounds: Anchor<CGRect>
  let outlined: Bool
}
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
swift/Interactions/SelectionList.swift
import PhosphorSwift
import SwiftUI

struct SelectionModel<Value: Hashable> {
  var editing = false
  var selected: Set<Value> = []
  var order: [Value] = []

  mutating func toggle(_ value: Value) {
    if selected.contains(value) {
      selected.remove(value)
    } else {
      selected.insert(value)
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/interactions/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
kotlin/components/Button.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
kotlin/components/ButtonGroup.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

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

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Icon(
    imageVector: ImageVector,
kotlin/interactions/FloatingBar.kt
package com.esuiss.ui.interactions

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
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.PaddingValues
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { UndoBarProvider } from "@/components/interactions/undo-bar"
<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.

iOS
Undo Bar (showing) on iOS in light modeUndo Bar (showing) on iOS in dark mode
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>
  )
}
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"
      }
    )
  }
}
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.

iOS
Undo Bar (longer timeout) on iOS in light modeUndo Bar (longer timeout) on iOS in dark mode
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>
  )
}
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"
      }
    )
  }
}
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.

iOS
Undo Bar (with swipe actions) on iOS in light modeUndo Bar (with swipe actions) on iOS in dark mode
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>
  )
}
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"
      }
    )
  }
}
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

01

UndoBarProvider

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

PropTypeDefaultDescription
timeoutnumber5000Countdown length in milliseconds. Defaults to 5000.
classNamestring-Classes for the bar.
portalHoststring-Name of a portal host to render into.
childrenReact.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.
NextChartsPreviousTop Banner

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Longer timeout
    • With swipe actions
  4. 04API Reference
    • UndoBarProvider
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Showing
    • Longer timeout
    • With swipe actions
  10. 10API Reference
    • UndoBarProvider
  11. 11Accessibility
  12. 12Notes