Pending Transactions

August 2026

  • Bitcoin

    Pending

    $1,200.00

  • USDC

    Pending

    $150.00

  • Avalanche

    Pending

    $8.40

Three transactions would be three rows anywhere else. Stacked, they read as one object with a count on it, and the eye only has to take in the one at the front until it wants more.

Reaching for the stack opens it, so seeing all four costs a movement you were making anyway rather than a click. The header stays a real button underneath, because a finger has no hover and a keyboard has no pointer.

pending-transactions.tsx
"use client"

import { motion, useReducedMotion } from "motion/react"
import { useId, useState } from "react"
import { CARD_SPRING, ENTER } from "@/components/site/motion"
import { Icon } from "@/components/site/icons"
import { cn } from "@/lib/utils"

/**
 * A stack of pending wallet transactions that fans out when you reach for it.
 *
 * THE GEOMETRY IS MEASURED, not invented. Read off a reference implementation:
 * each card behind the front one is 16px narrower and 8px lower, and the stack
 * never shows more than three no matter how many are queued. That last rule is
 * what keeps twelve pending transactions from turning into a smear; the rest
 * wait behind at zero opacity and slide in as the ones in front resolve.
 *
 * Expanded, every card returns to full width on a 78px pitch, which is the
 * 70px card and an 8px gap.
 *
 * CONTRAST, MEASURED RATHER THAN ASSUMED, since two values here sit under the
 * usual thresholds and both are deliberate:
 *
 * | what | ratio on white | why it stands |
 * |---|---|---|
 * | status line, gray-1000 | 4.67:1 | clears 4.5:1 for body text |
 * | asset name, gray-1200 | 16.29:1 | |
 * | spinner arc, gray-1000 on gray-200 | 4.32:1 | decorative, "Pending" says it in words |
 * | card edge, the ring in `shadow-custom` | 1.13:1 | not alone, see below |
 * | white mark on Bitcoin orange | 2.30:1 | decorative, the name is written beside it |
 *
 * The card edge would need 3:1 if it were the only thing separating a card from
 * the page. It is not: the two drop shadows under the ring lift the card off
 * the surface, and a stack reads as cards because they overlap, not because
 * they are outlined. This project already rejected Bitcoin orange as a TEXT
 * colour at this very ratio; as a tile behind a mark whose meaning is repeated
 * in words beside it, the same 2.30:1 costs a reader nothing.
 */

/** Card height in px, and the pitch that separates two expanded cards. */
const CARD_H = 70
const PITCH = 78

/** How much narrower and lower each card sits behind the one in front. */
const NARROW = 16
const DROP = 8

/**
 * How many cards the collapsed stack shows at once.
 *
 * Three, and the fourth is already at zero opacity in the reference this was
 * measured from. A stack that keeps adding slivers stops reading as a stack and
 * starts reading as a gradient.
 */
const SHOWN = 3

type Asset = {
  label: string
  /**
   * The official mark, on its own 120 grid, each shape with its own fill.
   *
   * SUPPLIED AS SVG RATHER THAN REDRAWN, and the difference is not cosmetic. An
   * earlier pass had these three traced by hand: they read correctly at a
   * glance and none of them was right. The Bitcoin B stood upright where the
   * real mark leans about fourteen degrees, and a set of straight bars stood in
   * for a logo whose bars are sheared. A brand mark is either the mark or it is
   * an impression of one.
   *
   * Each file carries its own coloured disc, so there is no tinted tile behind
   * them here. That also means the colours are the brands' own, not values this
   * project picked: the first off-scale colours in the codebase, and the one
   * place where the site's grey scale legitimately does not apply.
   */
  shapes: Array<{ d: string; fill: string; evenodd?: boolean }>
}


const ASSETS: Record<string, Asset> = {
  bitcoin: {
    label: "Bitcoin",
    shapes: [
      { d: "M118.205 74.5143C110.192 106.657 77.6358 126.219 45.4888 118.204C13.3553 110.19 -6.20637 77.6324 1.81058 45.4918C9.8205 13.3451 42.3763 -6.21803 74.5133 1.79541C106.658 9.80884 126.219 42.3702 118.204 74.5149L118.205 74.5143H118.205Z", fill: "#F7931A" },
      { d: "M85.9692 51.5806C87.1427 43.7345 81.1691 39.5168 73.0008 36.7033L75.6507 26.075L69.1809 24.4629L66.6013 34.8113C64.9004 34.3871 63.1537 33.9874 61.4177 33.5912L64.016 23.1744L57.5502 21.5623L54.8989 32.1871C53.4914 31.8666 52.109 31.5499 50.7678 31.2162L50.7753 31.1828L41.8533 28.9548L40.1323 35.8649C40.1323 35.8649 44.9323 36.9652 44.8311 37.033C47.451 37.6869 47.9248 39.4212 47.8461 40.7958L44.8277 52.904C45.0081 52.9498 45.2421 53.0161 45.5003 53.1198L45.3124 53.0729C45.1528 53.033 44.9868 52.9914 44.8164 52.9507L40.5856 69.9124C40.2654 70.7084 39.4527 71.9029 37.621 71.4493C37.6858 71.5433 32.9187 70.2758 32.9187 70.2758L29.7065 77.6817L38.1259 79.7805C39.0479 80.0117 39.9591 80.2493 40.861 80.4844L40.8616 80.4845C41.4916 80.6488 42.1171 80.8118 42.7386 80.9707L40.0614 91.7209L46.5237 93.3331L49.175 82.697C50.9404 83.1762 52.6537 83.6183 54.3309 84.035L51.6885 94.621L58.1586 96.2331L60.8356 85.503C71.8679 87.5909 80.1634 86.7491 83.655 76.7704C86.4686 68.7363 83.515 64.1023 77.7109 61.0804C81.9383 60.1055 85.1225 57.325 85.9715 51.5815L85.9695 51.58L85.9692 51.5806ZM71.1873 72.3088C69.3529 79.6798 57.8153 76.6325 52.5364 75.2382C52.0615 75.1128 51.6373 75.0007 51.2754 74.9109L54.8281 60.6687C55.2692 60.7788 55.8081 60.8997 56.4186 61.0368L56.419 61.0368C61.8793 62.2622 73.0668 64.7729 71.1876 72.3088H71.1873ZM57.5142 54.4266C61.9153 55.6012 71.5161 58.1634 73.1882 51.4641H73.1887C74.8961 44.6124 65.5657 42.5467 61.0087 41.5378C60.4961 41.4243 60.0439 41.3241 59.6745 41.2321L56.4535 54.1489C56.7576 54.2247 57.1145 54.3199 57.5142 54.4266Z", fill: "white", evenodd: true },
    ],
  },
  usdc: {
    label: "USDC",
    shapes: [
      { d: "M60 120C93.2502 120 120 93.2502 120 60C120 26.7498 93.2502 0 60 0C26.7498 0 0 26.7498 0 60C0 93.2502 26.7498 120 60 120Z", fill: "#2775CA" },
      { d: "M74.8536 68.6268C74.8536 60.75 70.1275 58.0494 60.6751 56.9243C53.9234 56.0239 52.5731 54.2236 52.5731 51.0725C52.5731 47.9213 54.8238 45.8963 59.3248 45.8963C63.3758 45.8963 65.6265 47.2467 66.7516 50.6225C66.9769 51.2977 67.652 51.7476 68.3272 51.7476H71.9277C72.8281 51.7476 73.5033 51.0725 73.5033 50.1726V49.9473C72.6029 44.9959 68.5519 41.1702 63.3758 40.7202V35.3189C63.3758 34.4185 62.7006 33.7433 61.5755 33.5181H58.1997C57.2993 33.5181 56.6241 34.1932 56.3989 35.3189V40.495C49.6472 41.3954 45.3715 45.8963 45.3715 51.5229C45.3715 58.9498 49.8724 61.8751 59.3248 63.0008C65.6265 64.1259 67.652 65.4762 67.652 69.0773C67.652 72.6784 64.5009 75.1538 60.2252 75.1538C54.3733 75.1538 52.3478 72.6778 51.6727 69.302C51.448 68.4021 50.7728 67.9517 50.0976 67.9517H46.2713C45.3715 67.9517 44.6963 68.6268 44.6963 69.5272V69.7525C45.5962 75.3785 49.1972 79.4295 56.6241 80.5552V85.9565C56.6241 86.8564 57.2993 87.5316 58.4244 87.7568H61.8002C62.7006 87.7568 63.3758 87.0816 63.601 85.9565V80.5552C70.3527 79.4295 74.8536 74.7034 74.8536 68.6268Z", fill: "white" },
      { d: "M28.4922 49.0469C21.9652 66.3766 30.9676 85.9564 48.522 92.2577C49.1972 92.7081 49.8724 93.608 49.8724 94.2832V97.4343C49.8724 97.8842 49.8724 98.1095 49.6471 98.3342C49.4224 99.2346 48.522 99.6845 47.6216 99.2346C35.0186 95.1836 25.341 85.506 21.29 72.903C14.5383 51.5228 26.2415 28.7918 47.6216 22.0401C47.8468 21.8154 48.2968 21.8154 48.522 21.8154C49.4224 22.0401 49.8724 22.7153 49.8724 23.6157V26.7663C49.8724 27.8919 49.4224 28.5671 48.522 29.017C39.2949 32.3929 31.868 39.5945 28.4922 49.0469ZM70.3528 22.9405C70.5775 22.0401 71.4779 21.5902 72.3783 22.0401C84.756 26.0911 94.6588 35.7687 98.7098 48.5969C105.462 69.9771 93.7584 92.7081 72.3783 99.4598C72.153 99.6845 71.7031 99.6845 71.4779 99.6845C70.5775 99.4598 70.1275 98.7847 70.1275 97.8843V94.7336C70.1275 93.608 70.5775 92.9328 71.4779 92.4829C80.705 89.1071 88.1318 81.9054 91.5077 72.4531C98.0347 55.1234 89.0322 35.5435 71.4779 29.2423C70.8027 28.7918 70.1275 27.892 70.1275 26.9915V23.8409C70.1275 23.3905 70.1275 23.1658 70.3528 22.9405Z", fill: "white", evenodd: true },
    ],
  },
  avalanche: {
    label: "Avalanche",
    shapes: [
      { d: "M60 60m-60 0a60 60 0 1 0 120 0a60 60 0 1 0 -120 0", fill: "#E84142" },
      { d: "M63.0416 26.1852C61.2807 23.1437 58.4792 23.1437 56.7183 26.1852L24.7415 82.4947C22.9806 85.5763 24.4214 88.0176 27.9432 88.0176H43.9916C47.2333 87.8175 50.1548 86.0966 51.9158 83.3752L71.2459 49.8776C72.6466 46.9961 72.6466 43.5944 71.2459 40.7128L65.4829 30.5876L63.0416 26.1852ZM84.2926 63.2446C82.5317 60.203 79.6902 60.203 77.9293 63.2446L66.7635 82.4946C65.0426 85.5362 66.4833 88.0175 69.9652 88.0175H92.0567C95.5786 88.0175 97.0193 85.5362 95.2584 82.4946L84.2926 63.2446Z", fill: "white", evenodd: true },
    ],
  },
}

export type Transaction = {
  id: number
  asset: keyof typeof ASSETS
  /** What it is waiting on. Written on the row, not only spun at it. */
  status: string
  /** Already formatted. The component never does currency maths. */
  amount: string
}

/**
 * The coin, with a spinner clipped to its bottom right corner.
 *
 * The badge sits ON the coin rather than beside it, so one glance carries both
 * what is moving and that it has not landed. Put in the row instead, the two
 * would compete and the spinner would read as a second column.
 *
 * IT IS NOT THE ONLY THING REPORTING THE STATE. The header says "Pending" and
 * counts, and both survive a reader who has asked for less motion, where the
 * arc holds still. A spinning shape is the fastest way to say "not yet", never
 * the only way this component says it.
 */
function Mark({ asset }: { asset: Asset }) {
  const reduced = useReducedMotion() ?? false

  return (
    <span aria-hidden="true" className="relative shrink-0">
      {/* NO TILE UNDER THE MARK. Each supplied file draws its own coloured
          disc, so wrapping it in a tinted circle would put one disc on another
          and show a rim of the wrong colour wherever the two disagreed. */}
      <svg
        className="size-10"
        fill="none"
        viewBox="0 0 120 120"
        xmlns="http://www.w3.org/2000/svg"
      >
        {asset.shapes.map((forme) => (
          <path
            clipRule={forme.evenodd ? "evenodd" : undefined}
            d={forme.d}
            fill={forme.fill}
            fillRule={forme.evenodd ? "evenodd" : undefined}
            key={forme.d}
          />
        ))}
      </svg>
      {/* THREE GREYS STACKED, AND NO COLOUR. The badge tried the home page's
          blue badge pair first; beside three brand discs it became a fourth
          colour competing with them, on the one element that should stay quiet.
          Greys let the coin keep the row's colour to itself.

          They are three steps of the same scale rather than one: `gray-200`
          fills the badge, `gray-400` draws the track it turns on, `gray-1000`
          draws the arc. A single grey would give a flat disc with a mark on it;
          the middle step is what makes the arc read as travelling along
          something rather than floating.

          `ring-2` IN THE PAGE COLOUR, not a border. The badge overlaps the coin,
          and without a ring the blue met the brand colour edge to edge and the
          two read as one smeared shape. The ring cuts it out of the coin the way
          a sticker sits on a surface. In the page colour and not white, so it
          keeps working if the surface under it ever changes. */}
      <span className="-right-1 -bottom-1 absolute flex size-5 items-center justify-center rounded-full bg-gray-200 ring-2 ring-preview-bg">
        {/* The svg is a step smaller than the badge, which leaves a ring of page
            colour around the dial. Flush to the edge the track met the badge
            outline and the two merged; the margin is what makes the dial sit
            IN something rather than BE the badge. */}
        {/* THE WHOLE SVG TURNS, NOT THE ARC INSIDE IT. Rotating the `circle`
            looked right and did nothing: an SVG child has no CSS box of its own
            to spin around, so without `transform-box: fill-box` the origin
            lands somewhere else entirely and motion never starts the animation.
            Measured while broken: zero animations on the element and a
            `transform` stuck at `none`.

            Turning the `svg` is safe because it is a replaced element with an
            ordinary box. The track rides along, which costs nothing: a complete
            ring rotating about its centre is indistinguishable from a still
            one, and only the dashed arc reads as movement. */}
        <motion.svg
          animate={reduced ? undefined : { rotate: 360 }}
          className="size-4"
          fill="none"
          transition={
            reduced ? undefined : { duration: 0.9, ease: "linear", repeat: Number.POSITIVE_INFINITY }
          }
          viewBox="0 0 24 24"
          xmlns="http://www.w3.org/2000/svg"
        >
          {/* THE TRACK IS A WHOLE RING AND IT IS DRAWN FIRST. Without it the arc
              turned in empty space and the badge read as a fragment; with it,
              the arc reads as a position along something. It is the light half
              of the same blue pair the moving arc uses, so the two belong to
              each other rather than merely sitting together.

              `r=9.5` with a 3px stroke reaches 11 of the 12 the viewBox allows:
              the ring runs to the edge of the badge instead of floating in the
              middle of it, which is what makes it read as a dial. */}
          <circle className="text-gray-400" cx="12" cy="12" r="9.5" stroke="currentColor" strokeWidth="3" />
          <circle
            className="text-gray-1000"
            cx="12"
            cy="12"
            r="9.5"
            stroke="currentColor"
            // A third of the circumference, which is about 59.7 at r=9.5. Two
            // thirds was tried while the arc turned alone and needed the weight;
            // over a track it only has to mark a place, so it can be lighter.
            strokeDasharray="20 40"
            strokeLinecap="round"
            strokeWidth="3"
          />
        </motion.svg>
      </span>
    </span>
  )
}

function Card({ tx, rank, expanded }: { tx: Transaction; rank: number; expanded: boolean }) {
  const asset = ASSETS[tx.asset]
  const depth = Math.min(rank, SHOWN)
  const reduced = useReducedMotion() ?? false

  return (
    <motion.li
      // `filter` IS IN BOTH BRANCHES ON PURPOSE. Without it the card kept the
      // `blur(2px)` it was born with: `initial` sets a property that `animate`
      // never mentions, so motion has nothing to animate it back to and the
      // blur stays for good. Every property an entrance introduces has to be
      // resolved by the state that follows it.
      animate={
        expanded
          ? { y: rank * PITCH, scaleX: 1, opacity: 1, filter: ENTER.to.filter }
          : {
              filter: ENTER.to.filter,
              y: depth * DROP,
              // Narrowing by scale and not by width: width would relayout the
              // card and its text on every frame, scaleX is composited. The
              // text is held at full size by the counter-scale below, so the
              // card narrows without its content squashing.
              scaleX: (320 - depth * NARROW) / 320,
              opacity: rank < SHOWN ? 1 : 0,
            }
      }
      className="absolute inset-x-0 top-0 origin-top"
      initial={reduced ? false : { ...ENTER.from, y: 0, scaleX: 1 }}
      style={{ zIndex: 40 - rank * 10 }}
      transition={reduced ? { duration: 0 } : CARD_SPRING}
    >
      <div
        className={cn(
          "flex h-[70px] items-center gap-3 rounded-2xl px-3",
          // NO `border` HERE, AND THAT IS THE FIX. The card carried a 1px
          // border AND `shadow-custom`, which opens with its own `0 0 0 1px`
          // ring: two hairlines stacked, and an edge that reads twice as thick
          // as every other surface on the site. This project already found and
          // named that defect once, on the install command frame.
          //
          // The ring alone is thinner than a border can be. It renders at
          // `#0000000f`, six percent black, where the border token is a solid
          // `#e8e8e8`.
          "bg-preview-bg shadow-custom",
        )}
      >
        <Mark asset={asset} />
        <div className="min-w-0 flex-1">
          {/* THE ASSET LEADS AND THE STATUS FOLLOWS. The row used to open with
              "Sending to" and a recipient, which made the destination the
              headline and left the asset as its subtitle. On a pending list the
              question is what is moving and whether it has landed; who receives
              it is a detail of one transaction among three.

              THE STATUS IS WRITTEN, and that matters beyond wording. The
              spinner reported it alone before, and a spinner is motion: it says
              nothing to a reader who has asked for less of it, or who arrives
              while it sits still. Now the word carries the state and the
              spinner only makes it quicker to see. */}
          {/* ONE TYPE SIZE IN THE WHOLE COMPONENT, and it is the site's most
              used one. Three lived here before: 15px for the asset name and the
              header, 13px for the status. An audit of every size in the project
              settled it. 13px is well established, six older files use it. 15px
              came from nowhere: its only other use is a level three heading, so
              it was a heading size borrowed for body copy.

              HIERARCHY IS CARRIED BY WEIGHT AND COLOUR instead of by size, 500
              against 400 and two steps of grey. That is what the site's list
              row already does, and this component sits beside it on these
              pages. Four pairings were rendered and looked at before settling:
              15/13, 14/14, 16/13 and 16/16. */}
          <p className="truncate font-medium text-sm text-gray-1200 leading-tight">
            {asset.label}
          </p>
          <p className="truncate text-sm text-gray-1000 leading-tight">{tx.status}</p>
        </div>
        <p className="shrink-0 font-medium text-sm text-gray-1000 tabular-nums">{tx.amount}</p>
      </div>
    </motion.li>
  )
}

export function PendingTransactions({ transactions }: { transactions: Transaction[] }) {
  const [pinned, setEpingle] = useState(false)
  const [hovered, setSurvole] = useState(false)
  const reduced = useReducedMotion() ?? false
  const id = useId()
  const expanded = pinned || hovered

  /**
   * THE STACK IS ABSOLUTELY POSITIONED, so its container has no height of its
   * own and the page would close up behind it. The height is therefore computed
   * from the same two numbers the cards use, never written twice.
   */
  const shown = Math.min(transactions.length, SHOWN)
  const stackHeight = expanded
    ? Math.max(transactions.length - 1, 0) * PITCH + CARD_H
    : Math.max(shown - 1, 0) * DROP + CARD_H

  /**
   * POINTER EVENTS FIRE ON TOUCH TOO, and a tap would leave the stack stuck
   * open with no way to read it as a hover. The guard is the same idea as the
   * project's `hover-hover:` variant, expressed in JS because the state lives
   * in React: only a device that really hovers gets the hover.
   */
  const pointerHovers = (e: { pointerType: string }) => e.pointerType === "mouse"

  return (
    <section
      className="w-[320px] select-none"
      onPointerEnter={(e) => pointerHovers(e) && setSurvole(true)}
      onPointerLeave={(e) => pointerHovers(e) && setSurvole(false)}
    >
      <button
        aria-controls={id}
        aria-expanded={expanded}
        className="flex w-full cursor-pointer items-center justify-between px-3 pb-3"
        onBlur={() => setSurvole(false)}
        onClick={() => setEpingle((v) => !v)}
        // Keyboard gets the same reveal as the pointer: reaching the trigger
        // opens the stack, so a keyboard reader sees what a mouse reader sees
        // before deciding to pin it.
        onFocus={() => setSurvole(true)}
        type="button"
      >
        <span className="font-medium text-sm text-gray-1200">Recent transactions</span>
        <span className="flex items-center gap-1.5">
          <span className="font-medium text-sm text-gray-1200 tabular-nums">
            {transactions.length}
          </span>
          {/* THE CHEVRON IS THE STATIC CUE, and it is not decoration. The stack
              opening is a movement, and a movement is never allowed to be the
              only thing that reports a state: a reader who arrives mid-hover,
              or who has asked for less motion, still needs to see which way
              this thing is. It points down closed and up open. */}
          <motion.span
            animate={{ rotate: expanded ? 180 : 0 }}
            className="flex text-gray-1000"
            initial={false}
            transition={reduced ? { duration: 0 } : CARD_SPRING}
          >
            <Icon className="size-4" name="chevron-down" />
          </motion.span>
        </span>
      </button>

      <motion.ul
        animate={{ height: stackHeight }}
        className="relative"
        id={id}
        initial={false}
        transition={reduced ? { duration: 0 } : CARD_SPRING}
      >
        {transactions.map((tx, rank) => (
          <Card expanded={expanded} key={tx.id} rank={rank} tx={tx} />
        ))}
      </motion.ul>
    </section>
  )
}