Skip to main content

ns-ui

Lining Wear

A dunning card for past-due subscriptions where the clutch grip on 'active' is a friction lining that wears down: every failed charge slips the pad and permanently scores one lamination, and the plate disengages to 'paused' once the lining is spent.

Usage

registry/core/lining-wear/demo.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { LiningWear } from "./component";

const ATTEMPT_DATES = ["Mon 14 Aug", "Thu 17 Aug", "Sun 20 Aug", "Wed 23 Aug"];

// A billing cycle narrated on its own: four charge attempts fail in
// sequence, the subscription pauses, then a successful charge re-lines the
// pad and starts a new cycle — the same way a dunning flow narrates itself
// with no pointer or keyboard input in the loop at all.
const SCRIPT: { attemptsUsed: number; cycle: number; ms: number }[] = [
  { attemptsUsed: 0, cycle: 0, ms: 1400 },
  { attemptsUsed: 1, cycle: 0, ms: 1900 },
  { attemptsUsed: 2, cycle: 0, ms: 1900 },
  { attemptsUsed: 3, cycle: 0, ms: 1900 },
  { attemptsUsed: 4, cycle: 0, ms: 2600 },
  { attemptsUsed: 0, cycle: 1, ms: 2200 },
];

export default function LiningWearDemo() {
  const [step, setStep] = useState(0);
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  const current = SCRIPT[step % SCRIPT.length]!;

  useEffect(() => {
    timerRef.current = setTimeout(() => setStep((s) => s + 1), current.ms);
    return () => clearTimeout(timerRef.current);
  }, [step, current.ms]);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / lining-wear
      </p>

      <LiningWear
        planName="Pro plan"
        attemptsUsed={current.attemptsUsed}
        attemptDates={ATTEMPT_DATES}
        cycleId={current.cycle}
        onUpdatePayment={() => {}}
      />

      <p className="max-w-sm text-center text-xs text-ns-muted">
        Each failed charge slips the pad sideways and abrades one lamination —
        the scored line stays. A successful charge re-lines the pad and
        retires the old one into the last-cycle well.
      </p>
    </div>
  );
}

Component

registry/core/lining-wear/component.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// LiningWear — a dunning / payment-retry card that renders the subscription's
// grip on "active" as a clutch friction lining wearing down, not a yellow
// past-due badge. One governing scalar, `lining = attemptsRemaining /
// maxAttempts`, drives everything: pad height (base x lining) with
// laminations ruled every segment so remaining depth is countable at rest;
// each FAILED charge (attemptsUsed increasing) is one abrasion event — the
// pad+label group slips sideways under load by (1 - lining) x 12px (capped:
// a worn clutch grips worse, so later failures slip further) and settles
// back with a spring whose duration eases 300ms -> 700ms as lining drops
// (the same settle also carries the pad's one-segment drop, so the last
// failure's heavier, slower settle reads as the plate physically
// disengaging toward "paused" rather than a special-cased animation). One
// lamination abrades away per failure, leaving a permanent scored line at a
// fixed position that nothing later ever redraws over, and one debris tick
// drops into the row below. Wear is monotonic within a billing cycle: no
// code path here ever removes a scored line or a debris tick while `cycleId`
// is unchanged, because that would erase the datum a past-due customer and
// support both need ("this card has already failed three times"). Recovery
// is a distinct re-line, triggered only by a `cycleId` change: the old
// scored block is snapshotted into a small half-visible "last cycle" well
// and a fresh, unscored pad swaps in — never a health-bar refill.
//
// Status is text first, not carried by the graphic: a plain paragraph reads
// e.g. "Payment failed, attempt 2 of 4. Next retry Thu 21 Aug.", a second
// line spells out attempts remaining, the retry schedule is a real <ol>, and
// "Update payment method" is a real button. The wear graphic itself is
// aria-hidden and holds no interactive control. Each new failure announces
// exactly once through a role=alert/aria-live=assertive span — genuinely
// urgent, not routine chatter. prefers-reduced-motion drops the slip/settle
// transform outright; laminations still decrement and scored lines still
// accumulate on the very same render, so the state is never motion-gated.
//
// Direct-DOM refs carry the one hot-path transform (the grip's FLIP-style
// translate); every other visual (scored lines, laminations, debris,
// schedule, status text) is plain declarative React driven by props. Zero
// dependencies, DOM + CSS only, no canvas.
// ---------------------------------------------------------------------------

export interface LiningWearProps {
  /** Subscription / plan name shown in the card header. Default "Pro plan". */
  planName?: string;
  /**
   * Failed charge attempts so far THIS billing cycle. Monotonic within a
   * cycle — increment by 1 each time a charge attempt fails. Reset to 0
   * (together with a changed `cycleId`) once a charge succeeds and a new
   * cycle begins; that is the only sanctioned way this component's wear
   * ever goes down.
   */
  attemptsUsed: number;
  /** Total attempts allotted before the subscription pauses. Default 4. */
  maxAttempts?: number;
  /**
   * Calendar label for every retry slot, oldest first, length `maxAttempts`
   * (e.g. ["Mon 14 Aug", "Thu 17 Aug", "Sun 20 Aug", "Wed 23 Aug"]). Drives
   * both the status line's "next retry" text and the schedule list.
   */
  attemptDates: string[];
  /**
   * Change this (any new value) to start a fresh billing cycle: the current
   * scored pad retires into the "last cycle" well and a clean, unscored
   * pad swaps in. Pass `attemptsUsed={0}` alongside the new id.
   */
  cycleId?: string | number;
  /** Called when the visitor presses "Update payment method". */
  onUpdatePayment?: () => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const FRAME_H = 64;
const SLIP_MAX_PX = 12;
const SETTLE_MIN_MS = 300;
const SETTLE_MAX_MS = 700;
const EASE_SETTLE = "cubic-bezier(0.34, 1.56, 0.64, 1)";

function segmentH(maxAttempts: number) {
  return FRAME_H / Math.max(1, maxAttempts);
}

// Deterministic pseudo-random in [0,1) so debris drift never differs between
// server and client renders (a real Math.random() here would hydrate wrong).
function hash01(seed: number) {
  const x = Math.sin(seed * 12.9898) * 43758.5453;
  return x - Math.floor(x);
}

function computeSettle(lining: number) {
  const clampedLining = Math.min(1, Math.max(0, lining));
  const slip = Math.round((1 - clampedLining) * SLIP_MAX_PX * 10) / 10;
  const ms = Math.round(SETTLE_MIN_MS + (1 - clampedLining) * (SETTLE_MAX_MS - SETTLE_MIN_MS));
  return { slip, ms };
}

function animateGripStep(
  el: HTMLDivElement | null,
  fromTopPx: number,
  toTopPx: number,
  slipPx: number,
  ms: number,
  reduced: boolean
) {
  if (!el) return;
  if (reduced) {
    el.style.transition = "none";
    el.style.transform = "translate(0px, 0px)";
    return;
  }
  // FLIP: land at the new resting position instantly (the React-controlled
  // `top` already moved there), then hold the OLD visual position plus the
  // sideways slip via transform, and only next frame ease both back to zero.
  el.style.transition = "none";
  el.style.transform = `translate(${slipPx}px, ${fromTopPx - toTopPx}px)`;
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      el.style.transition = `transform ${ms}ms ${EASE_SETTLE}`;
      el.style.transform = "translate(0px, 0px)";
    });
  });
}

function statusText(attemptsUsed: number, maxAttempts: number, attemptDates: string[]) {
  if (attemptsUsed <= 0) {
    return `Payment active. ${maxAttempts} of ${maxAttempts} retry attempts available.`;
  }
  if (attemptsUsed < maxAttempts) {
    const next = attemptDates[attemptsUsed] ?? "soon";
    return `Payment failed, attempt ${attemptsUsed} of ${maxAttempts}. Next retry ${next}.`;
  }
  return `Payment failed ${maxAttempts} times this cycle. Subscription paused — update your payment method to resume.`;
}

interface LastCycle {
  attemptsUsed: number;
  maxAttempts: number;
}

export function LiningWear({
  planName = "Pro plan",
  attemptsUsed,
  maxAttempts = 4,
  attemptDates,
  cycleId,
  onUpdatePayment,
  className = "",
}: LiningWearProps) {
  const gripRef = useRef<HTMLDivElement | null>(null);
  const topPxRef = useRef(0);
  const reducedRef = useRef(false);
  const prevAttemptsRef = useRef(0);
  const prevCycleRef = useRef<string | number | undefined>(cycleId);
  const firstRunRef = useRef(true);

  const [liveMsg, setLiveMsg] = useState("");
  const [lastCycle, setLastCycle] = useState<LastCycle | null>(null);
  const [wellEntering, setWellEntering] = useState(false);

  const introId = useId();

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onChange = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  const clamped = Math.max(0, Math.min(attemptsUsed, maxAttempts));

  useEffect(() => {
    const prevAttempts = prevAttemptsRef.current;
    const prevCycle = prevCycleRef.current;
    prevAttemptsRef.current = clamped;
    prevCycleRef.current = cycleId;

    if (firstRunRef.current) {
      firstRunRef.current = false;
      topPxRef.current = clamped * segmentH(maxAttempts);
      return;
    }

    const cycleChanged = prevCycle !== cycleId;
    if (cycleChanged) {
      // Re-line: retire the worn block into the "last cycle" well instead of
      // erasing it, then swap in a fresh unscored pad with no animation of
      // its own — the well entrance is what carries the transition.
      setLastCycle({ attemptsUsed: prevAttempts, maxAttempts });
      topPxRef.current = clamped * segmentH(maxAttempts);
      if (gripRef.current) {
        gripRef.current.style.transition = "none";
        gripRef.current.style.transform = "translate(0px, 0px)";
      }
      setWellEntering(true);
      requestAnimationFrame(() => requestAnimationFrame(() => setWellEntering(false)));
      return;
    }

    if (clamped > prevAttempts) {
      const lining = (maxAttempts - clamped) / maxAttempts;
      const { slip, ms } = computeSettle(lining);
      const newTop = clamped * segmentH(maxAttempts);
      animateGripStep(gripRef.current, topPxRef.current, newTop, slip, ms, reducedRef.current);
      topPxRef.current = newTop;
      setLiveMsg(statusText(clamped, maxAttempts, attemptDates));
    } else if (clamped !== prevAttempts) {
      // Defensive: attemptsUsed dropped without a cycleId change. That
      // shouldn't happen per contract, but resync silently rather than
      // pretend a lamination came back.
      topPxRef.current = clamped * segmentH(maxAttempts);
      if (gripRef.current) {
        gripRef.current.style.transition = "none";
        gripRef.current.style.transform = "translate(0px, 0px)";
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [clamped, cycleId, maxAttempts]);

  const remaining = maxAttempts - clamped;
  const disengaged = clamped >= maxAttempts;
  const segH = segmentH(maxAttempts);
  const gripTop = clamped * segH;
  const gripH = FRAME_H - gripTop;

  return (
    <div
      className={`w-full max-w-sm rounded-[12px] border border-border bg-background p-5 ${className}`}
    >
      <style>{CSS}</style>

      <div className="flex items-start justify-between gap-3">
        <div className="min-w-0">
          <p className="truncate text-sm font-medium text-foreground">{planName}</p>
          <p id={introId} className="mt-1 text-xs text-ns-muted">
            {statusText(clamped, maxAttempts, attemptDates)}
          </p>
        </div>

        {/* Wear mechanism — purely decorative, aria-hidden. Every fact it
            encodes is also written as plain text elsewhere on the card. */}
        <div className="flex shrink-0 flex-col items-center gap-1" aria-hidden="true">
          <div
            className="relative w-[92px] overflow-hidden rounded-[6px] border border-border bg-background"
            style={{ height: FRAME_H }}
          >
            {/* Permanent scored lines — one per past failure, fixed position,
                never moved or removed while this cycle lives. */}
            {Array.from({ length: clamped }, (_, i) => (
              <div
                key={`scar-${i}`}
                className="absolute left-0 right-0 bg-foreground"
                style={{ top: (i + 1) * segH - 1, height: 1.5 }}
              />
            ))}

            {/* The grip: remaining lining + laminations + the "active"/
                "paused" label, all sliding and settling together as one
                unit — the slip lives on this ref's transform only. */}
            <div
              ref={gripRef}
              className="ns-lw-grip absolute left-0 right-0"
              style={{ top: gripTop, height: gripH }}
            >
              <div className="h-full w-full border-t-2 border-t-foreground bg-border/25">
                {Array.from({ length: Math.max(0, remaining - 1) }, (_, i) => (
                  <div
                    key={`lam-${i}`}
                    className="absolute left-0 right-0 h-px bg-border"
                    style={{ top: (i + 1) * segH }}
                  />
                ))}
              </div>
              <span className="absolute left-1.5 top-0.5 font-mono text-[8px] uppercase tracking-wider text-foreground">
                {disengaged ? "Paused" : "Active"}
              </span>
            </div>
          </div>

          {/* Debris ticks — one per failed attempt this cycle, drifting. */}
          <div className="flex h-2.5 items-end gap-[3px]">
            {Array.from({ length: clamped }, (_, i) => {
              const j = hash01(i + 1);
              return (
                <span
                  key={`debris-${i}`}
                  className="block w-[2px] bg-ns-muted"
                  style={{
                    height: 3 + j * 3,
                    transform: `translateY(${j * 2}px) rotate(${(j - 0.5) * 24}deg)`,
                  }}
                />
              );
            })}
          </div>

          {/* Last cycle well — half-visible retired block from the cycle
              before this one, swapped in only on a cycleId change. */}
          {lastCycle && (
            <div
              className={`ns-lw-well flex items-center gap-1 opacity-40 ${wellEntering ? "ns-lw-well-enter" : ""}`}
            >
              <span className="font-mono text-[8px] uppercase tracking-wide text-ns-muted">
                last cycle
              </span>
              <div className="flex gap-[1.5px]">
                {Array.from({ length: lastCycle.maxAttempts }, (_, i) => (
                  <span
                    key={i}
                    className={`h-1.5 w-1 ${i < lastCycle.attemptsUsed ? "bg-foreground" : "border border-border"}`}
                  />
                ))}
              </div>
            </div>
          )}
        </div>
      </div>

      <p className="mt-3 text-xs text-foreground">
        {remaining} of {maxAttempts} retry attempts remaining.
      </p>

      <ol className="mt-3 divide-y divide-border border-t border-border">
        {attemptDates.slice(0, maxAttempts).map((date, i) => {
          const state = i < clamped ? "Failed" : i === clamped && !disengaged ? "Scheduled" : "Pending";
          return (
            <li key={i} className="flex items-center justify-between gap-3 py-1.5 text-xs">
              <span className="text-foreground">Attempt {i + 1}</span>
              <span className="font-mono text-ns-muted">{date}</span>
              <span className="font-mono text-[10px] uppercase tracking-wide text-foreground">
                {state}
              </span>
            </li>
          );
        })}
      </ol>

      <button
        type="button"
        onClick={onUpdatePayment}
        aria-describedby={introId}
        className="mt-4 w-full rounded-[6px] border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors duration-150 hover:bg-border/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
      >
        Update payment method
      </button>

      {/* Fires once per new failure. Genuinely urgent, so assertive — not
          the routine-status polite region other indicators in this
          registry use. */}
      <span role="alert" aria-live="assertive" aria-atomic="true" className="sr-only">
        {liveMsg}
      </span>
    </div>
  );
}

const CSS = `
.ns-lw-well{transition:opacity 260ms ease-out, transform 260ms ease-out;}
.ns-lw-well-enter{opacity:0 !important;transform:translateX(-6px);}
@media (prefers-reduced-motion: reduce){
  .ns-lw-grip{transition:none !important;transform:translate(0px,0px) !important;}
  .ns-lw-well,.ns-lw-well-enter{transition:none !important;transform:none !important;opacity:0.4 !important;}
}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/lining-wear.tsx, so point the import there.

Use when a billing/subscription card that has to feel the accumulated history of failed charges within a cycle — depletion of finite grace, retained as scored lines and debris until a successful charge re-lines the pad; pick button-retry-backoff instead when what matters is a single control counting down to the next retry attempt, not the cycle's whole failure history.

Install

npx shadcn add https://design.helpmarq.com/r/lining-wear.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build the past-due surface for a billing/subscription card as a clutch friction lining wearing down, not a yellow badge. One governing scalar, `lining = attemptsRemaining / maxAttempts` (attemptsRemaining = maxAttempts - attemptsUsed), drives the whole graphic. A 92x64px frame holds a 'grip': the remaining lining pad, height = 64 * lining, filled at var(--border)/25% with a var(--foreground) 2px top edge (the live wear boundary) and internal 1px var(--border) lamination rules every FRAME_H/maxAttempts px, so remaining depth is literally countable at rest by counting stripes. An 'Active'/'Paused' label sits pinned to the grip's top edge. `attemptsUsed` is a controlled prop, monotonic within a cycle (the caller increments it by 1 per failed charge). Each increase is one abrasion event: the whole grip (pad + laminations + label, one ref, one FLIP-style transform) slips sideways by (1 - lining) * 12px, capped at 12px so the label never leaves the card's reading line, then settles back to 0 with a spring whose duration eases from 300ms at lining=1 down to 700ms at lining=0 (a worn clutch grips worse and settles slower) — the SAME settle carries the grip's one-segment drop to its new resting position via the FLIP delta, so the final failure's heavier settle reads as the plate physically disengaging toward 'Paused' rather than a special-cased transition. Simultaneously (plain declarative render, not animated) one permanent scored line appears at var(--foreground) at the exact y where that lamination sheared off — a fixed position nothing later ever redraws, so all of a cycle's scars stay visible stacked above the current, shorter pad — and one debris tick drops into a row below the frame, deterministically jittered in height/rotation/offset (a seeded sine hash, never Math.random, so server and client render identically) so the row reads as drifting sediment, not a bar chart. Wear is strictly monotonic within a cycle: no code path decrements attemptsUsed or removes a scored line/debris tick on its own — the forbidden shape is a health bar that refills on success, because refilling erases the exact datum a past-due customer and support both need ('this card has already failed three times this month'). Recovery is a distinct re-line, triggered only by changing the `cycleId` prop (pass it alongside resetting attemptsUsed to 0): the current scored/debris state is snapshotted into a small 'last cycle' well (a tiny row of filled/outline ticks plus the label 'last cycle', fading in to 40% opacity) and an entirely fresh, unscored pad swaps in with no scored lines of its own. Every fact the graphic carries is ALSO written as real text outside the aria-hidden wrapper: a status paragraph reads exactly 'Payment failed, attempt 2 of 4. Next retry Thu 21 Aug.' (or the active/paused variants) built from `attemptsUsed`/`maxAttempts`/`attemptDates`; a second line spells out 'N of maxAttempts retry attempts remaining'; the retry schedule renders as a real `<ol>` of Attempt N / date / Failed-Scheduled-Pending built from the `attemptDates` array (oldest first); and 'Update payment method' is a real `<button onClick={onUpdatePayment}>` — nothing interactive lives inside the aria-hidden mechanism. Each NEW failure (attemptsUsed increasing) announces exactly once through a dedicated `role=alert aria-live=assertive aria-atomic=true` sr-only span holding the same status sentence — assertive because a failed charge is genuinely urgent, unlike the polite/throttled chatter appropriate to routine autosave or sync indicators. prefers-reduced-motion drops the grip's slip/settle transform entirely (it snaps straight to its resolved position) and disables the last-cycle well's entrance transition, but the lamination count, the scored lines and the debris ticks still decrement/accumulate on the very same render — the wear record is never gated behind motion. Direct-DOM refs carry only the grip's transform (a FLIP two-rAF handoff, matching the ratchet/retry components already in this registry); every other visual — scored lines, laminations, debris, schedule, status text — is plain declarative React keyed off props, no imperative DOM writes needed for those. Zero dependencies, DOM + CSS only, no canvas, no SVG required.

Props

PropTypeDefaultDescription
planName?string"Pro plan"Subscription / plan name shown in the card header. Default "Pro plan".
attemptsUsednumber—Failed charge attempts so far THIS billing cycle. Monotonic within a cycle — increment by 1 each time a charge attempt fails. Reset to 0 (together with a changed `cycleId`) once a charge succeeds and a new cycle begins; that is the only sanctioned way this component's wear ever goes down.
maxAttempts?number4Total attempts allotted before the subscription pauses. Default 4.
attemptDatesstring[]—Calendar label for every retry slot, oldest first, length `maxAttempts` (e.g. ["Mon 14 Aug", "Thu 17 Aug", "Sun 20 Aug", "Wed 23 Aug"]). Drives both the status line's "next retry" text and the schedule list.
cycleId?string | number—Change this (any new value) to start a fresh billing cycle: the current scored pad retires into the "last cycle" well and a clean, unscored pad swaps in. Pass `attemptsUsed={0}` alongside the new id.
onUpdatePayment?() => void—Called when the visitor presses "Update payment method".
className?string—extra classes merged onto the rendered root element