Subframe
Components & interactionsUndo toast

Undo removal

A removed draft can be restored from a compact notification with a visible lifetime.

Remove the draft, pause expiry by hover or focus, and Undo or reset after expiry.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

"use client"
import * as React from "react"
function usePreview(active: boolean) {
  const [reduced, setReduced] = React.useState(true),
    [instant, setInstant] = React.useState(false)
  React.useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const read = () => setReduced(q.matches)
    read()
    q.addEventListener("change", read)
    return () => q.removeEventListener("change", read)
  }, [])
  return {
    still: !active || reduced || instant,
    reduced,
    onKeyDownCapture: () => setInstant(true),
    onPointerDownCapture: () => setInstant(false),
  }
}

export default function Example({ active = true }: { active?: boolean }) {
  const preview = usePreview(active)

  const [removed, setRemoved] = React.useState(false),
    [remaining, setRemaining] = React.useState(8),
    [hovered, setHovered] = React.useState(false),
    [focused, setFocused] = React.useState(false),
    [committed, setCommitted] = React.useState(false)
  const hold = hovered || focused
  const remove = () => {
    setHovered(false)
    setFocused(false)
    setRemoved(true)
    setCommitted(false)
    setRemaining(8)
  }
  const restore = () => {
    setHovered(false)
    setFocused(false)
    setRemoved(false)
    setCommitted(false)
    setRemaining(8)
  }
  React.useEffect(() => {
    if (!removed || committed || hold || !active) return
    const t = setTimeout(() => {
      if (remaining <= 1) {
        setRemaining(0)
        setCommitted(true)
      } else setRemaining((r) => r - 1)
    }, 1000)
    return () => clearTimeout(t)
  }, [removed, committed, remaining, hold, active])

  return (
    <section
      className="sf-w2-undo-removal"
      data-still={preview.still}
      onKeyDownCapture={preview.onKeyDownCapture}
      onPointerDownCapture={preview.onPointerDownCapture}
    >
      <div className="files">
        <header className="row between">
          <h2>Project files</h2>
          <span>{removed ? "2" : "3"} items</span>
        </header>
        <div className="file">
          <i aria-hidden>▤</i>
          <span>
            Brief.pdf<small>Updated today</small>
          </span>
          <b>2.4 MB</b>
        </div>
        {!removed && (
          <div className="file removable">
            <i aria-hidden>▧</i>
            <span>
              Exploration.png<small>Draft</small>
            </span>
            <button type="button" onClick={remove} aria-label="Remove Exploration.png">
              ×
            </button>
          </div>
        )}
        <div className="file">
          <i aria-hidden>◇</i>
          <span>
            Wordmark.svg<small>Approved</small>
          </span>
          <b>18 KB</b>
        </div>
        <div className="recovery">
          {removed && !committed ? (
            <div
              className="undo"
              onPointerEnter={(e) => {
                if (e.pointerType === "mouse") setHovered(true)
              }}
              onPointerLeave={() => setHovered(false)}
              onFocusCapture={() => setFocused(true)}
              onBlurCapture={(e) => {
                if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false)
              }}
            >
              <span role="status">File removed</span>
              <button type="button" onClick={restore}>
                Undo <span aria-hidden>↶</span>
              </button>
              <i aria-hidden style={{ transform: `scaleX(${remaining / 8})` }} />
            </div>
          ) : committed ? (
            <div className="finished">
              <span>Removed from this preview</span>
              <button type="button" onClick={restore}>
                Reset
              </button>
            </div>
          ) : null}
        </div>
      </div>

      <style>{`.sf-w2-undo-removal{position:relative;isolation:isolate;container-type:inline-size;width:100%;height:100%;min-height:280px;overflow:hidden;box-sizing:border-box;font:13px/1.4 Arial,sans-serif;-webkit-font-smoothing:antialiased}.sf-w2-undo-removal *{box-sizing:border-box}.sf-w2-undo-removal button,.sf-w2-undo-removal input,.sf-w2-undo-removal select,.sf-w2-undo-removal textarea{font:inherit}.sf-w2-undo-removal button{cursor:pointer;touch-action:manipulation;min-height:44px;border:0;background:none;color:inherit}.sf-w2-undo-removal button:disabled{opacity:.4;cursor:default}.sf-w2-undo-removal button:active:not(:disabled){transform:scale(.98)}.sf-w2-undo-removal input,.sf-w2-undo-removal textarea{font-size:16px;min-width:0}.sf-w2-undo-removal button:focus-visible,.sf-w2-undo-removal input:focus-visible,.sf-w2-undo-removal select:focus-visible,.sf-w2-undo-removal textarea:focus-visible,.sf-w2-undo-removal [tabindex]:focus-visible{outline:2px solid #3376ef;outline-offset:3px}.sf-w2-undo-removal h2,.sf-w2-undo-removal p{margin:0}.sf-w2-undo-removal .row{display:flex;align-items:center;gap:12px}.sf-w2-undo-removal .between{justify-content:space-between}.sf-w2-undo-removal .muted{opacity:.6}.sf-w2-undo-removal .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.sf-w2-undo-removal[data-still=true] *,.sf-w2-undo-removal[data-still=true] *::before,.sf-w2-undo-removal[data-still=true] *::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}@media(prefers-reduced-motion:reduce){.sf-w2-undo-removal *,.sf-w2-undo-removal *::before,.sf-w2-undo-removal *::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}@media(hover:hover) and (pointer:fine){.sf-w2-undo-removal button:hover:not(:disabled){filter:brightness(.96)}}.sf-w2-undo-removal{display:grid;place-items:center;padding:24px;background:#f0f0ee;color:#242424}.sf-w2-undo-removal .files{width:min(100%,393px)}.sf-w2-undo-removal header{height:42px;border-bottom:1px solid #d2d2cf;padding-bottom:10px}.sf-w2-undo-removal h2{font-size:14px;font-weight:500}.sf-w2-undo-removal header>span{font-size:10px;color:#888885}.sf-w2-undo-removal .file{display:flex;align-items:center;gap:12px;border-bottom:1px solid #dadad6;height:58px;animation:undo-file-w2 .18s ease-out}.sf-w2-undo-removal .file>i{font-style:normal;color:#7a7a76;font-size:23px;width:23px}.sf-w2-undo-removal .file>span{font-size:12px}.sf-w2-undo-removal .file small{display:block;margin-top:2px;font-size:9px;color:#8d8d89}.sf-w2-undo-removal .file>b{margin-left:auto;font:9px monospace;color:#898985}.sf-w2-undo-removal .file button{margin-left:auto;width:44px;color:#777773;font-size:22px}.sf-w2-undo-removal .recovery{height:58px;margin-top:13px}.sf-w2-undo-removal .undo{position:relative;display:flex;align-items:center;justify-content:space-between;gap:12px;background:#282826;color:#f3f3ed;border-radius:5px;padding:0 15px;overflow:hidden;animation:undo-toast-w2 .2s cubic-bezier(.2,.8,.2,1);height:48px;font-size:11px;box-shadow:0 8px 16px #31445a17}.sf-w2-undo-removal .undo button{color:#e8e8df;display:flex;align-items:center;gap:14px;font-size:11px}.sf-w2-undo-removal .undo>i{position:absolute;bottom:0;left:0;right:0;height:2px;background:#d59055;transform-origin:left;transition:transform 1s linear}.sf-w2-undo-removal .footnote{font-size:9px;color:#99a5b1}.sf-w2-undo-removal .finished{display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#8997a6}.sf-w2-undo-removal .finished button{font-size:11px;color:#426783}@keyframes undo-toast-w2{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes undo-file-w2{from{opacity:0}to{opacity:1}}`}</style>
    </section>
  )
}

Where it works well

Make a reversible file action quick while keeping recovery within reach.

Reduced motion

Removal and recovery appear instantly; the functional expiry timer remains available and pauses during interaction.

Things to know

One local draft removal, with an eight-second Undo window; no filesystem changes.

More examples