How to Implement Hold-to-Delete with clip-path: A CSS-First Approach

Animate an overlay's clip-path: inset() property from fully clipped to fully visible over 2 seconds during press, snapping it back in 200 ms on release to create a performant, hardware-accelerated hold-to-delete interaction.

The hold-to-delete pattern prevents accidental data loss by requiring users to press and hold a button to confirm destructive actions. According to the emilkowalski/skills repository, the most reliable implementation uses CSS clip-path animations that run entirely on the compositor thread, avoiding expensive layout recalculations while providing smooth bidirectional feedback.

Why Use clip-path for Hold-to-Delete Animations

The clip-path property offers unique advantages for timed interactions that require both deliberate progression and instant cancellation.

Hardware Acceleration Without Layout Thrash

Unlike animating width, height, or left properties, clip-path animations are performed on the GPU's compositor thread. This means the browser does not recalculate layout or paint during the hold animation, maintaining 60fps even on low-powered devices.

Bidirectional Control with inset()

The inset(t r b l) syntax provides granular control over which side of the element reveals the overlay. You can "eat" the overlay from left-to-right, right-to-left, or center-out, and—crucially—you can reverse the animation instantly when the user releases the button. As documented in /skills/review-animations/STANDARDS.md#L135, this technique is the standard for hold-to-delete overlays because it requires no extra DOM elements or SVG masks.

Timing and Architecture from Source

The concrete implementation values reside in the repository's engineering skill files. According to /skills/emil-design-eng/SKILL.md#L393-L419, the hold-to-delete interaction uses specific timing functions to communicate intent:

  • Press phase: 2 seconds linear (transition: clip-path 2s linear)
  • Release phase: 200 milliseconds ease-out (transition: clip-path 200ms ease-out)

This asymmetry is intentional—the slow linear reveal forces intentionality, while the fast ease-out provides immediate feedback that the action was aborted.

Structural Overview

The pattern requires a single overlay child inside your button:

  1. Initial state: Overlay is hidden via clip-path: inset(0 100% 0 0) (fully clipped on the right)
  2. Active state: While holding, animate to clip-path: inset(0 0 0 0) (fully visible)
  3. Completion: Listen for the transitionend event to trigger the deletion
  4. Cancellation: On pointerup, remove the active class to trigger the 200 ms snap-back

Complete Implementation Example

HTML Structure

<button class="hold-delete">
  Delete Item
  <span class="overlay"></span>
</button>

CSS with Repository Timing Values

.hold-delete {
  position: relative;
  overflow: hidden;
  background: #e74c3c;
  color: #fff;
  border: none;
  padding: 0.75rem 1.5rem;
  font-size: 1rem;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;
}

/* The overlay covers the entire button */
.hold-delete .overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.25);
  /* Start fully clipped on the right side */
  clip-path: inset(0 100% 0 0);
  /* Release timing: 200ms ease-out per /skills/emil-design-eng/SKILL.md#L415-L419 */
  transition: clip-path 200ms ease-out;
}

/* Active/holding state */
.hold-delete:active .overlay,
.hold-delete.holding .overlay {
  clip-path: inset(0 0 0 0);
  /* Press timing: 2s linear per /skills/emil-design-eng/SKILL.md#L415-L419 */
  transition: clip-path 2s linear;
}

JavaScript for Completion Detection

const btn = document.querySelector('.hold-delete');
let abortController = null;

btn.addEventListener('pointerdown', (e) => {
  // Prevent default touch actions like scrolling
  e.preventDefault();
  
  btn.classList.add('holding');
  
  const overlay = btn.querySelector('.overlay');
  abortController = new AbortController();
  
  // Listen for the transition to complete
  overlay.addEventListener('transitionend', (e) => {
    if (e.propertyName === 'clip-path') {
      // Verify we reached the end (fully revealed)
      if (getComputedStyle(overlay).clipPath === 'inset(0px)') {
        performDelete();
      }
    }
  }, { signal: abortController.signal });
});

['pointerup', 'pointercancel', 'pointerleave'].forEach(event => {
  btn.addEventListener(event, cancelHold);
});

function cancelHold() {
  btn.classList.remove('holding');
  if (abortController) {
    abortController.abort(); // Cancel the transitionend listener
  }
}

function performDelete() {
  console.log('Item deleted');
  // Reset state after deletion
  btn.classList.remove('holding');
}

Source File References

The implementation above follows specifications found in the following repository files:

Summary

  • Use clip-path: inset() to animate an overlay reveal without triggering layout or paint cycles, keeping the interaction at 60fps.
  • Apply asymmetric timing: Set a 2 second linear transition for the press phase and a 200 millisecond ease-out transition for the release phase, as specified in the emil-design-eng skill documentation.
  • Structure the DOM with a single overlay element inside the button; no additional wrappers or SVG masks are required.
  • Detect completion by listening for the transitionend event on the overlay, but only trigger the delete action if the clip-path reached its fully revealed state.
  • Cancel cleanly by removing the holding class on pointerup or pointercancel, allowing the CSS transition to snap the overlay back instantly.

Frequently Asked Questions

What makes clip-path better than animating width or opacity for hold-to-delete?

Animating width or left triggers layout recalculations and repaints, causing jank on mobile devices, whereas clip-path runs on the compositor thread. Additionally, clip-path: inset() allows you to clip the overlay from a specific side (like right-to-left), giving directional visual feedback that width-based animations cannot easily replicate.

How do I prevent the delete action from firing if the user releases the button early?

Remove the holding class immediately on pointerup and abort the transitionend listener. The CSS transition will snap the overlay back to its clipped state in 200 milliseconds, and because you cancelled the event listener (or checked the computed style at completion), the deletion logic will not execute.

Can I animate the clip-path from a different direction, such as center-out?

Yes, adjust the inset() values to control the clipping origin. For a center-out reveal, use inset(0 50% 0 50%) as the initial state and inset(0 0 0 0) as the final state. The four values represent top, right, bottom, and left offsets respectively, allowing precise control over the reveal direction.

Where are the official 2-second and 200-millisecond timing values defined?

The timing specifications are documented in /skills/emil-design-eng/SKILL.md at lines 393 through 419 within the emilkowalski/skills repository. These values represent the deliberate press duration required to trigger deletion and the rapid feedback duration for cancellation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →