Inline Confirm
A delete button that grows into “Sure? Cancel / Delete” in place, then settles on a check.
npx shadcn@latest add https://sundaeui.dev/r/inline-confirm.json'use client'
import { useEffect, useRef, useState, type KeyboardEvent } from 'react'
import './inline-confirm.css'
type Phase = 'idle' | 'ask' | 'done'
export interface InlineConfirmProps {
/** Text on the resting button. */
label?: string
question?: string
confirmLabel?: string
cancelLabel?: string
doneLabel?: string
onConfirm?: () => void
/** Seconds before the done state returns to the resting button. 0 keeps it. */
resetAfter?: number
shape?: 'pill' | 'rounded'
fill?: 'dark' | 'light'
/** Drive the phase from outside (for demos). */
phase?: Phase
className?: string
}
const Trash = () => (
<svg viewBox="0 0 16 16" aria-hidden className="sd-ic__ico">
<path d="M2.5 4.5h11M6 4.5V3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5M4 4.5l.6 8.1a1.5 1.5 0 0 0 1.5 1.4h3.8a1.5 1.5 0 0 0 1.5-1.4l.6-8.1" />
</svg>
)
const Check = () => (
<svg viewBox="0 0 16 16" aria-hidden className="sd-ic__ico">
<path d="M3 8.5l3.2 3L13 4.5" />
</svg>
)
/** A destructive button that asks "Sure?" in place instead of opening a dialog. */
export function InlineConfirm({ label = 'Delete', question = 'Delete file?', confirmLabel = 'Delete', cancelLabel = 'Cancel', doneLabel = 'Deleted', onConfirm, resetAfter = 2, shape = 'pill', fill = 'dark', phase: forced, className }: InlineConfirmProps) {
const [own, setOwn] = useState<Phase>('idle')
const phase = forced ?? own
const trigger = useRef<HTMLButtonElement>(null)
const cancel = useRef<HTMLButtonElement>(null)
const user = useRef(false)
useEffect(() => {
if (!user.current) return
if (phase === 'ask') cancel.current?.focus()
if (phase === 'idle') trigger.current?.focus()
}, [phase])
useEffect(() => {
if (phase !== 'done' || !resetAfter || forced) return
const t = setTimeout(() => setOwn('idle'), resetAfter * 1000)
return () => clearTimeout(t)
}, [phase, resetAfter, forced])
const go = (p: Phase) => {
user.current = true
setOwn(p)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && phase === 'ask') {
e.preventDefault()
go('idle')
}
}
return (
<div className={['sd-ic', className].filter(Boolean).join(' ')} data-fill={fill} data-shape={shape} data-phase={phase} onKeyDown={onKey}>
{phase === 'idle' && (
<button ref={trigger} type="button" className="sd-ic__btn sd-ic__main" onClick={() => go('ask')} aria-haspopup="true">
<Trash />
{label}
</button>
)}
{phase === 'ask' && (
<div className="sd-ic__ask" role="group" aria-label={question}>
<span className="sd-ic__q">{question}</span>
<button ref={cancel} type="button" className="sd-ic__btn sd-ic__ghost" onClick={() => go('idle')}>
{cancelLabel}
</button>
<button
type="button"
className="sd-ic__btn sd-ic__main"
onClick={() => {
go('done')
onConfirm?.()
}}
>
{confirmLabel}
</button>
</div>
)}
{phase === 'done' && (
<p className="sd-ic__done">
<Check />
{doneLabel}
</p>
)}
<span className="sd-ic__sr" role="status" aria-live="polite">
{phase === 'done' ? doneLabel : ''}
</span>
</div>
)
}.sd-ic { --bg: #1d1d1d; --fg: #f7f7f7; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.09); --soft: rgb(255 255 255 / 0.08); --r: 999px;
display: inline-flex; align-items: center; height: 52px; padding: 6px; border-radius: var(--r); background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line);
interpolate-size: allow-keywords; transition: width 420ms cubic-bezier(0.2, 1, 0.3, 1); overflow: hidden; max-width: 100%; }
.sd-ic[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.08); --soft: rgb(0 0 0 / 0.05); }
.sd-ic[data-shape='rounded'] { --r: 16px; }
.sd-ic__btn { display: inline-flex; align-items: center; gap: 7px; height: 40px; padding: 0 16px; border-radius: calc(var(--r) - 6px); font-size: 14px; font-weight: 500; white-space: nowrap; transition: transform 160ms, background-color 200ms; }
.sd-ic[data-shape='pill'] .sd-ic__btn { border-radius: 999px; }
.sd-ic__btn:active { transform: scale(0.95); }
.sd-ic__main { background: var(--fg); color: var(--bg); }
.sd-ic__main:hover { background: color-mix(in srgb, var(--fg) 88%, var(--bg)); }
.sd-ic__ghost { background: var(--soft); color: var(--fg); }
.sd-ic__ghost:hover { background: color-mix(in srgb, var(--soft) 100%, var(--fg) 6%); }
.sd-ic__ask, .sd-ic__done { display: flex; align-items: center; gap: 6px; animation: sd-ic-in 380ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-ic__q { padding: 0 8px 0 10px; font-size: 14px; color: var(--mute); white-space: nowrap; }
.sd-ic__done { margin: 0; height: 40px; padding: 0 16px 0 12px; font-size: 14px; font-weight: 500; }
.sd-ic__ico { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.sd-ic__done .sd-ic__ico { stroke-width: 1.9; animation: sd-ic-pop 420ms cubic-bezier(0.2, 1.5, 0.4, 1); }
.sd-ic__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.sd-ic button:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
@keyframes sd-ic-in { from { opacity: 0; transform: translateX(8px); filter: blur(2px); } }
@keyframes sd-ic-pop { from { transform: scale(0.3); } }
@media (prefers-reduced-motion: reduce) { .sd-ic, .sd-ic * { animation: none !important; transition: none !important; } }Overview
The resting button widens into a question with Cancel and Delete side by side, so a destructive action gets a second look without a modal stealing the page.
Focus lands on Cancel, the safe choice. Escape backs out. After confirming, a check and “Deleted” show for a moment and the button returns.
When to use
- Deleting a row, file or draft that can be recreated or undone.
- Dense lists where a modal for every delete is too heavy.
When not to use
- Irreversible actions with wide impact (deleting an account): use a dialog that asks for the name.
- Actions that are not destructive: just do them.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/inline-confirm.jsonnpx shadcn@latest add https://sundaeui.dev/r/inline-confirm.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/inline-confirm.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/inline-confirm.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy inline-confirm.tsx and inline-confirm.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { InlineConfirm } from '@/components/sundae/inline-confirm'
<InlineConfirm label="Delete file" question="Delete file?" onConfirm={() => remove(id)} />Props
| Prop | Type | Default |
|---|---|---|
labelResting button text. | string | 'Delete' |
questionShown while asking; also the group label. | string | 'Delete file?' |
confirmLabel / cancelLabelButton texts. | string | 'Delete' / 'Cancel' |
doneLabelShown and announced after confirming. | string | 'Deleted' |
onConfirmRuns on confirm. | () => void | — |
resetAfterSeconds before returning to rest. 0 keeps the done state. | number | 2 |
shapeCorner style. | 'pill' | 'rounded' | 'pill' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Opening moves focus to Cancel; cancelling or finishing returns it to the trigger.
- The question labels the button group, and a polite live region announces the done state.
- Reduced motion removes the width and slide animation.
Customization
- --r sets the corner radius; --fg is the solid button colour.
- Swap the Trash icon for any 16px glyph.
Keyboard
- Enter / Space
- Ask, then confirm or cancel
- Tab
- Move between Cancel and Delete
- Escape
- Back out without deleting
FAQ
- Why does focus go to Cancel?
- A double press on Enter should never delete by accident. Landing on the safe option makes the second press a cancel.
- Can I add undo instead?
- Yes. Keep resetAfter at 0 and render your own Undo link next to the done state.
- Is Inline Confirm free?
- Yes. Inline Confirm is free for personal and commercial projects, with full source on this page.
- How do I install Inline Confirm?
- Run npx shadcn@latest add https://sundaeui.dev/r/inline-confirm.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Inline Confirm support keyboard and screen readers?
- Opening moves focus to Cancel; cancelling or finishing returns it to the trigger. The question labels the button group, and a polite live region announces the done state.
- Does Inline Confirm respect reduced motion?
- Reduced motion removes the width and slide animation.
More like this
All components →Hold to Confirm
Components · RefinedConfirm Dialog
Components · UnderstatedFold Accordion
Components · Understated- Pro
Team Members
Components · Understated - Pro
AI Composer
Components · Understated Agent Steps
Components · Understated