Inbox
- Ana Ruiz
The prints are in, come by after 4
- Jun Bae
Proofs attached, two small fixes
Inbox
The prints are in, come by after 4
Proofs attached, two small fixes
import { useEffect, useRef, useState } from 'react';
const ROWS = [
{ id: 'a', from: 'Ana Ruiz', text: 'The prints are in, come by after 4' },
{ id: 'b', from: 'Jun Bae', text: 'Proofs attached, two small fixes' },
];
const LEFT = 112;
const RIGHT = 56;
const css = `
.card-actions { box-sizing: border-box; width: 240px; padding: 12px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-actions__head { margin: 0 2px 8px; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-actions__list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.card-actions__row { position: relative; height: 54px; border-radius: 10px; overflow: hidden; transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s; }
.card-actions__row--gone { height: 0; opacity: 0; }
.card-actions__under { position: absolute; inset: 0; display: flex; justify-content: space-between; background: light-dark(#ebedf0, #232529); }
.card-actions__pair { display: flex; }
.card-actions__act { display: grid; place-items: center; width: ${RIGHT}px; border: 0; background: light-dark(#cbced3, #393c41); color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 10.5px; font-weight: 600; cursor: pointer; }
.card-actions__act--flag { background: light-dark(#6d7179, #7d8087); color: light-dark(#fbfcfd, #121417); }
.card-actions__act--del { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.card-actions__act:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -3px; }
.card-actions__front {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: 9px 12px;
background: light-dark(#dbdde1, #2e3035);
cursor: grab;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
.card-actions__front--settle { transition: transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1); }
.card-actions__front:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -2px; border-radius: 10px; }
.card-actions__from { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-actions__flag { width: 7px; height: 7px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); }
.card-actions__text { margin: 2px 0 0; overflow: hidden; font-size: 11px; color: light-dark(#50535b, #c6c8cd); white-space: nowrap; text-overflow: ellipsis; }
@media (prefers-reduced-motion: reduce) {
.card-actions__row, .card-actions__front--settle { transition: none; }
}
`;
// Past an open position the row keeps moving, but only a quarter as far as the pointer.
const resist = (dx: number) => (dx < -LEFT ? -LEFT + (dx + LEFT) * 0.25 : dx > RIGHT ? RIGHT + (dx - RIGHT) * 0.25 : dx);
export function ActionsCard() {
const [open, setOpen] = useState<{ id: string; dx: number } | null>(null);
const [drag, setDrag] = useState<{ id: string; x0: number; dx: number; base: number } | null>(null);
const [gone, setGone] = useState<string | null>(null);
const [flags, setFlags] = useState<string[]>([]);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
const remove = (id: string) => {
setOpen(null);
setGone(id);
// The demo puts it back after a moment.
timer.current = window.setTimeout(() => setGone(null), 1600);
};
const offset = (id: string) => (drag?.id === id ? drag.dx : open?.id === id ? open.dx : 0);
return (
<>
<style>
{css}
</style>
<article className="card-actions">
<p className="card-actions__head">Inbox</p>
<ul className="card-actions__list">
{ROWS.map((r) => {
const dx = offset(r.id);
const isOpen = open?.id === r.id;
return (
<li key={r.id} className={`card-actions__row${gone === r.id ? ' card-actions__row--gone' : ''}`}>
<div className="card-actions__under">
<button
type="button"
className="card-actions__act card-actions__act--flag"
tabIndex={isOpen && open!.dx > 0 ? 0 : -1}
onClick={() => {
setFlags((f) => (f.includes(r.id) ? f.filter((x) => x !== r.id) : [...f, r.id]));
setOpen(null);
}}
>
{flags.includes(r.id) ? 'Unflag' : 'Flag'}
</button>
<span className="card-actions__pair">
<button type="button" className="card-actions__act" tabIndex={isOpen && open!.dx < 0 ? 0 : -1} onClick={() => remove(r.id)}>
Archive
</button>
<button type="button" className="card-actions__act card-actions__act--del" tabIndex={isOpen && open!.dx < 0 ? 0 : -1} onClick={() => remove(r.id)}>
Delete
</button>
</span>
</div>
<div
className={`card-actions__front${drag?.id === r.id ? '' : ' card-actions__front--settle'}`}
style={{ transform: `translateX(${dx}px)` }}
tabIndex={0}
role="group"
aria-label={`${r.from}: ${r.text}. Left arrow for actions, right arrow to flag.`}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDrag({ id: r.id, x0: e.clientX, dx, base: dx });
}}
onPointerMove={(e) => drag?.id === r.id && setDrag({ ...drag, dx: resist(drag.base + e.clientX - drag.x0) })}
onPointerUp={() => {
if (!drag) return;
// Let go past halfway and it stays open; otherwise it springs shut.
const to = drag.dx < -LEFT / 2 ? -LEFT : drag.dx > RIGHT * 0.7 ? RIGHT : 0;
setOpen(to ? { id: r.id, dx: to } : null);
setDrag(null);
}}
onPointerCancel={() => setDrag(null)}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft') setOpen({ id: r.id, dx: -LEFT });
else if (e.key === 'ArrowRight') setOpen(isOpen && open!.dx < 0 ? null : { id: r.id, dx: RIGHT });
else if (e.key === 'Escape') setOpen(null);
else return;
e.preventDefault();
}}
>
<span className="card-actions__from">
{flags.includes(r.id) && <span className="card-actions__flag" role="img" aria-label="Flagged" />}
{r.from}
</span>
<p className="card-actions__text">{r.text}</p>
</div>
</li>
);
})}
</ul>
</article>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardActions(el) on the mounted element instead.
<article class="card-actions">
<p class="card-actions__head">Inbox</p>
<ul class="card-actions__list">
<li class="card-actions__row">
<div class="card-actions__under">
<button type="button" class="card-actions__act card-actions__act--flag" tabindex="-1">Flag</button>
<span class="card-actions__pair">
<button type="button" class="card-actions__act" tabindex="-1">Archive</button>
<button type="button" class="card-actions__act card-actions__act--del" tabindex="-1">Delete</button>
</span>
</div>
<div class="card-actions__front card-actions__front--settle" style="transform:translateX(0px)" tabindex="0" role="group" aria-label="Ana Ruiz: The prints are in, come by after 4. Left arrow for actions, right arrow to flag.">
<span class="card-actions__from">Ana Ruiz</span>
<p class="card-actions__text">The prints are in, come by after 4</p>
</div>
</li>
<li class="card-actions__row">
<div class="card-actions__under">
<button type="button" class="card-actions__act card-actions__act--flag" tabindex="-1">Flag</button>
<span class="card-actions__pair">
<button type="button" class="card-actions__act" tabindex="-1">Archive</button>
<button type="button" class="card-actions__act card-actions__act--del" tabindex="-1">Delete</button>
</span>
</div>
<div class="card-actions__front card-actions__front--settle" style="transform:translateX(0px)" tabindex="0" role="group" aria-label="Jun Bae: Proofs attached, two small fixes. Left arrow for actions, right arrow to flag.">
<span class="card-actions__from">Jun Bae</span>
<p class="card-actions__text">Proofs attached, two small fixes</p>
</div>
</li>
</ul>
</article>.card-actions { box-sizing: border-box; width: 240px; padding: 12px; border-radius: 16px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.card-actions__head { margin: 0 2px 8px; font-size: 13px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-actions__list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.card-actions__row { position: relative; height: 54px; border-radius: 10px; overflow: hidden; transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s; }
.card-actions__row--gone { height: 0; opacity: 0; }
.card-actions__under { position: absolute; inset: 0; display: flex; justify-content: space-between; background: light-dark(#ebedf0, #232529); }
.card-actions__pair { display: flex; }
.card-actions__act { display: grid; place-items: center; width: 56px; border: 0; background: light-dark(#cbced3, #393c41); color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 10.5px; font-weight: 600; cursor: pointer; }
.card-actions__act--flag { background: light-dark(#6d7179, #7d8087); color: light-dark(#fbfcfd, #121417); }
.card-actions__act--del { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.card-actions__act:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -3px; }
.card-actions__front {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: 9px 12px;
background: light-dark(#dbdde1, #2e3035);
cursor: grab;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
.card-actions__front--settle { transition: transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1); }
.card-actions__front:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -2px; border-radius: 10px; }
.card-actions__from { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); }
.card-actions__flag { width: 7px; height: 7px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); }
.card-actions__text { margin: 2px 0 0; overflow: hidden; font-size: 11px; color: light-dark(#50535b, #c6c8cd); white-space: nowrap; text-overflow: ellipsis; }
@media (prefers-reduced-motion: reduce) {
.card-actions__row, .card-actions__front--settle { transition: none; }
}function initCardActions(root) {
var LEFT = 112;
var RIGHT = 56;
var rows = root.querySelectorAll('.card-actions__row');
var fronts = root.querySelectorAll('.card-actions__front');
var open = null;
var drag = null;
var flags = [];
var timer = 0;
// Past an open position the row keeps moving, but only a quarter as far as the pointer.
function resist(dx) {
return dx < -LEFT ? -LEFT + (dx + LEFT) * 0.25 : dx > RIGHT ? RIGHT + (dx - RIGHT) * 0.25 : dx;
}
function render() {
rows.forEach(function (row, i) {
var dx = drag && drag.i === i ? drag.dx : open && open.i === i ? open.dx : 0;
var isOpen = !!open && open.i === i;
var acts = row.querySelectorAll('.card-actions__act');
acts[0].tabIndex = isOpen && open.dx > 0 ? 0 : -1;
acts[1].tabIndex = acts[2].tabIndex = isOpen && open.dx < 0 ? 0 : -1;
acts[0].textContent = flags.indexOf(i) > -1 ? 'Unflag' : 'Flag';
fronts[i].classList.toggle('card-actions__front--settle', !drag || drag.i !== i);
fronts[i].style.transform = 'translateX(' + dx + 'px)';
var from = fronts[i].querySelector('.card-actions__from');
var dot = from.querySelector('.card-actions__flag');
if (flags.indexOf(i) > -1 && !dot) from.insertAdjacentHTML('afterbegin', '<span class="card-actions__flag" role="img" aria-label="Flagged"></span>');
if (flags.indexOf(i) < 0 && dot) dot.remove();
});
}
function rowOf(el) {
return Array.prototype.indexOf.call(rows, el.closest('.card-actions__row'));
}
function remove(i) {
open = null;
rows[i].classList.add('card-actions__row--gone');
render();
// The demo puts it back after a moment.
clearTimeout(timer);
timer = setTimeout(function () {
rows[i].classList.remove('card-actions__row--gone');
}, 1600);
}
function onAct(e) {
var i = rowOf(e.currentTarget);
if (!e.currentTarget.classList.contains('card-actions__act--flag')) return remove(i);
var at = flags.indexOf(i);
if (at > -1) flags.splice(at, 1);
else flags.push(i);
open = null;
render();
}
function onDown(e) {
var i = rowOf(e.currentTarget);
var dx = open && open.i === i ? open.dx : 0;
e.currentTarget.setPointerCapture(e.pointerId);
drag = { i: i, x0: e.clientX, dx: dx, base: dx };
render();
}
function onMove(e) {
if (!drag || drag.i !== rowOf(e.currentTarget)) return;
drag.dx = resist(drag.base + e.clientX - drag.x0);
render();
}
function onUp() {
if (!drag) return;
// Let go past halfway and it stays open; otherwise it springs shut.
var to = drag.dx < -LEFT / 2 ? -LEFT : drag.dx > RIGHT * 0.7 ? RIGHT : 0;
open = to ? { i: drag.i, dx: to } : null;
drag = null;
render();
}
function onCancel() {
drag = null;
render();
}
function onKey(e) {
var i = rowOf(e.currentTarget);
var isOpen = !!open && open.i === i;
if (e.key === 'ArrowLeft') open = { i: i, dx: -LEFT };
else if (e.key === 'ArrowRight') open = isOpen && open.dx < 0 ? null : { i: i, dx: RIGHT };
else if (e.key === 'Escape') open = null;
else return;
e.preventDefault();
render();
}
var acts = root.querySelectorAll('.card-actions__act');
acts.forEach(function (a) {
a.addEventListener('click', onAct);
});
fronts.forEach(function (f) {
f.addEventListener('pointerdown', onDown);
f.addEventListener('pointermove', onMove);
f.addEventListener('pointerup', onUp);
f.addEventListener('pointercancel', onCancel);
f.addEventListener('keydown', onKey);
});
return function () {
clearTimeout(timer);
acts.forEach(function (a) {
a.removeEventListener('click', onAct);
});
fronts.forEach(function (f) {
f.removeEventListener('pointerdown', onDown);
f.removeEventListener('pointermove', onMove);
f.removeEventListener('pointerup', onUp);
f.removeEventListener('pointercancel', onCancel);
f.removeEventListener('keydown', onKey);
});
};
}
document.querySelectorAll('.card-actions').forEach(initCardActions);Goal: Create a "Actions" card.
Visual behavior: An inbox row you swipe: drag it left and Archive and Delete slide out from under it with some resistance, or right to Flag; let go past halfway and it stays open.
Technique:
- The row's front follows the pointer by translateX with pointer capture; past each open position it moves only a quarter as far as the pointer, so it feels rubbery.
- On release it settles open or shut on an overshooting transition, depending on whether it passed half the open width.
- Archive and Delete fold the row away by its height, and the demo brings it back a moment later.
Accessibility: Each row is focusable, with a label saying that Left arrow opens its actions and Right arrow flags it; Escape closes it. The action buttons are in the tab order only while their side is open. Under prefers-reduced-motion the row snaps open and shut.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Actions" card.
Visual behavior: An inbox row you swipe: drag it left and Archive and Delete slide out from under it with some resistance, or right to Flag; let go past halfway and it stays open.
Technique:
- The row's front follows the pointer by translateX with pointer capture; past each open position it moves only a quarter as far as the pointer, so it feels rubbery.
- On release it settles open or shut on an overshooting transition, depending on whether it passed half the open width.
- Archive and Delete fold the row away by its height, and the demo brings it back a moment later.
Accessibility: Each row is focusable, with a label saying that Left arrow opens its actions and Right arrow flags it; Escape closes it. The action buttons are in the tab order only while their side is open. Under prefers-reduced-motion the row snaps open and shut.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
An inbox row you swipe: drag it left and Archive and Delete slide out from under it with some resistance, or right to Flag; let go past halfway and it stays open.
Each row is focusable, with a label saying that Left arrow opens its actions and Right arrow flags it; Escape closes it. The action buttons are in the tab order only while their side is open. Under prefers-reduced-motion the row snaps open and shut.