Optimistic-write feedback for a single row rendered as a tailor's basting stitch along its left seam — dashed hand-stitch while pending, pulled tight into a solid hairline on ack, or unraveled to muted and dimmed on failure with an inline retry.
npx shadcn add https://design.helpmarq.com /r/optimistic-stitch.jsonregistry/core/optimistic-stitch/component.tsx"use client";
import type { ReactNode } from "react";
// BasteStitch — optimistic-write feedback for a single row (list item,
// comment, form field) rendered as a tailor's basting stitch along its left
// seam, not a spinner or a toast.
//
// A pending write (created/edited optimistically, not yet acked) draws an
// uneven, hand-stitched dashed line — real stroke-dasharray, sized like
// unequal hand stitches, not a machine-even pattern. The instant the server
// acks, the dasharray pairwise-interpolates to a solid hairline (every
// dash grows toward a length far longer than the row while every gap
// shrinks to zero — a plain CSS transition, no JS tweening) while the whole
// row does a barely-there scaleX 0.995 -> 1 spring contraction: the stitch
// visibly pulls taut. On failure the dash offset snaps outward fast (the
// thread yanked loose), the pattern loosens into wider, frayed gaps, the
// seam settles to --muted, and the row's own content dims beside a real
// inline Retry button — never a red/accent decoration, never a spinner.
//
// This is deliberately a *lifecycle* encoding (provisional / committed /
// rolled back for ONE write), not a freshness signal: it never keeps
// running once a state is reached, and a still frame is fully legible with
// zero motion — dashed vs. solid vs. muted-frayed-dimmed are three
// structurally different rests, not degrees of the same shimmer. That's
// what keeps it distinct from streaming-ink-dry, which encodes a still-arriving
// token *stream* and never stops animating until the stream itself ends.
//
// The component holds no write logic of its own (no fetch, no retry
// backoff) — it is a pure function of `status`, and calls `onRetry` when
// the user asks to try again. The caller owns the actual write.
export type BasteStatus = "pending" | "committed" | "failed";
export interface BasteStitchProps {
/** Where this row's write currently sits in its lifecycle. */
status: BasteStatus;
/** Called when the user activates the Retry control after a failed write. */
onRetry?: () => void;
/** The row's real content — list text, a comment body, a form field. */
children: ReactNode;
/**
* Noun for what this row is, used only in the Retry button's own label
* ("Retry {itemLabel}") so multiple failed rows read as distinct
* controls to a screen reader. Default "change".
*/
itemLabel?: string;
className?: string;
}
const STATUS_MESSAGE: Record<BasteStatus, string> = {
pending: "Saving",
committed: "Saved",
failed: "Failed to save. Retry available.",
};
export function BasteStitch({
status,
onRetry,
children,
itemLabel = "change",
className = "",
}: BasteStitchProps) {
return (
<div className={`relative ${className}`} data-status={status}>
<div className="ns-baste-scale relative flex items-stretch">
<svg
className="ns-baste-seam absolute inset-y-0 left-0 w-2.5"
aria-hidden="true"
focusable="false"
>
<line className="ns-baste-seam-line" x1="5" y1="0" x2="5" y2="100%" />
</svg>
<div className="flex min-w-0 flex-1 items-center gap-3 py-2.5 pr-3 pl-4">
<div className="ns-baste-row-content min-w-0 flex-1">{children}</div>
{status === "failed" && (
<button
type="button"
onClick={onRetry}
className="ns-baste-retry shrink-0 rounded-sm border border-border px-2.5 py-1 font-mono text-[11px] whitespace-nowrap text-foreground transition-colors duration-150 hover:border-muted hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Retry {itemLabel}
</button>
)}
</div>
</div>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{STATUS_MESSAGE[status]}
</span>
<style>{CSS}</style>
</div>
);
}
const CSS = `
.ns-baste-scale{
transform-origin:left center;
transform:scaleX(1);
transition:transform 360ms cubic-bezier(.34,1.56,.64,1);
}
.ns-baste-seam{ overflow:visible; pointer-events:none; }
.ns-baste-seam-line{
stroke:var(--border);
stroke-width:2;
stroke-linecap:round;
fill:none;
stroke-dasharray:3 2 4 2 2 3;
stroke-dashoffset:0;
transition:
stroke-dasharray 300ms cubic-bezier(.16,1,.3,1),
stroke-dashoffset 300ms cubic-bezier(.16,1,.3,1),
stroke 300ms cubic-bezier(.16,1,.3,1);
}
.ns-baste-row-content{
opacity:1;
transition:opacity 220ms ease-out;
}
[data-status="pending"] .ns-baste-scale{ transform:scaleX(0.995); }
[data-status="committed"] .ns-baste-scale{ transform:scaleX(1); }
[data-status="committed"] .ns-baste-seam-line{
stroke:var(--border);
stroke-dasharray:1000 0 1000 0 1000 0;
stroke-dashoffset:0;
}
[data-status="failed"] .ns-baste-scale{ transform:scaleX(1); }
[data-status="failed"] .ns-baste-seam-line{
stroke:var(--muted);
stroke-dasharray:2 7 1 9 2 6;
stroke-dashoffset:18;
transition:
stroke-dasharray 160ms cubic-bezier(.55,0,1,.45),
stroke-dashoffset 160ms cubic-bezier(.55,0,1,.45),
stroke 260ms ease-out;
}
[data-status="failed"] .ns-baste-row-content{ opacity:.55; }
@media (prefers-reduced-motion: reduce){
.ns-baste-scale, .ns-baste-seam-line, .ns-baste-row-content{
transition:none;
}
}
`;
Per-row pending/committed/failed feedback for an optimistic write (a list item, a comment, a form field) where the row's own left seam encodes the write's lifecycle — dashed while unacked, solid the instant the server confirms, muted-and-frayed with a real Retry button on failure — and every one of those three rests must read correctly from a single still frame. Reach for streaming-ink-dry instead when the thing that's unsettled is a live token *stream* (chat/generation text) whose 'not yet dry' state keeps animating for as long as tokens keep arriving — optimistic-stitch's states are terminal snapshots of one write's outcome, never a continuously running effect.
<BasteStitch status={"pending"|"committed"|"failed"} onRetry={() => void} itemLabel?={string} className?={string}>{children}</BasteStitch> wraps one row's real content (list text, a comment body, a form field) with a left-seam SVG stitch that encodes exactly where that row's write sits in its optimistic lifecycle: PENDING renders an uneven, hand-stitch-sized stroke-dasharray (`3 2 4 2 2 3`, 2px stroke, color var(--border)) — deliberately irregular dash/gap lengths so it never reads as a machine-perforated line — on an absolutely positioned <svg><line> running the row's left edge (no viewBox; the line's y1/y2 are 0/100% so it always spans the row's actual rendered height, however tall the caller's content makes it). COMMIT: the instant the caller flips `status` to "committed" (the server ack landed), the dasharray transitions — a plain CSS `transition: stroke-dasharray 300ms cubic-bezier(.16,1,.3,1)` — to `1000 0 1000 0 1000 0`: every dash value pairwise-interpolates toward a length far longer than the row (reading as one continuous line) while every gap value interpolates to zero, so the uneven stitching visibly draws itself solid rather than cross-fading or swapping images. That transition is paired with a `scaleX(0.995) -> scaleX(1)` spring contraction (`cubic-bezier(.34,1.56,.64,1)`, 360ms, slight overshoot) on the whole row, not just the seam — the fabric itself pulls taut. FAIL: flipping `status` to "failed" instead snaps the dash offset outward fast (`stroke-dashoffset` 0 -> 18 over a sharp 160ms ease-in `cubic-bezier(.55,0,1,.45)`, reading as the thread yanked loose), loosens the dash pattern into wider frayed gaps (`2 7 1 9 2 6`), and settles the stroke color to var(--muted) over a slower trailing 260ms — never red, never --accent, structure and value only. The row's own content (everything the caller passed as children) dims to 55% opacity in the same beat, and a real `<button>` reading "Retry {itemLabel}" (itemLabel defaults "change") mounts inline beside it and calls the caller's `onRetry` — so failure is never signaled by the border shape alone: dimmed content plus a visible, focusable, tab-reachable Retry control are the other two channels, satisfying 'never the sole failure signal' even for a colorblind or non-visual read. AT REST, ZERO MOTION: this is the component's core constraint — pending (dashed, full opacity, no button), committed (solid hairline, full opacity, no button) and failed (muted frayed dashes, dimmed content, Retry button) are three structurally distinct static frames, not three points along one continuous shimmer; a screenshot of any one of them must be legible with nothing animating, which is also what separates this from a freshness/shimmer effect — if it ever reads as "this row is newer" rather than "this row's write is in state X" it has failed at its one job. LIFECYCLE, NOT LOGIC: the component holds no fetch, no retry backoff, no timers of its own — it is a pure function of the `status` prop plus one `onRetry` callback; the caller owns issuing the actual retry request and flipping `status` again once it resolves. ACCESSIBILITY: a visually-hidden `aria-live="polite" aria-atomic="true"` span holds exactly "Saving", "Saved", or "Failed to save. Retry available." and updates on every `status` change, so a screen-reader user gets the same three-way signal a sighted user reads from the seam; the Retry button is a real, unstyled-away `<button>` in normal tab order (never a div with a click handler) whose visible text already is its accessible name (no separate aria-label to drift out of sync). REDUCED MOTION: `prefers-reduced-motion: reduce` drops every transition on the seam, the row's scaleX and the content's opacity to `none` — the three states still swap instantly and remain exactly as legible, just without the ease-out-expo draw-solid or the spring contraction. Pure DOM + one inline SVG <line> + CSS custom properties; no canvas, no dependencies, no JS-scheduled animation — every visual change is a `data-status` attribute swap that CSS transitions pick up on their own.