Swish UI
All componentsFeedback and forms Free

Recording pill

A floating record pill: pulsing dot and timer, pause morphs into play, stop folds it into a Saved link chip.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

type Phase = "recording" | "paused" | "saved";
// The auto script: pause at 0:05 for a breath, stop at 0:12, then record again after a rest.
const AUTO = { pauseAt: 5000, pauseFor: 1600, stopAt: 12000, restartAfter: 3800 };

const clock = (ms: number) => {
  const s = Math.floor(ms / 1000);
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const pill = useRef<HTMLDivElement>(null);
  const timer = useRef<HTMLSpanElement>(null);
  const elapsed = useRef(0);
  const fromWidth = useRef(0);
  const auto = useRef(true);
  const pausedOnce = useRef(false);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [phase, setPhase] = useState<Phase>("recording");
  const [length, setLength] = useState(12000);
  const [copied, setCopied] = useState(false);

  // Every phase change goes through here so the pill's old width is known before React re-renders it.
  const go = (next: Phase) => {
    fromWidth.current = pill.current?.offsetWidth ?? 0;
    if (next === "saved") setLength(Math.max(1000, elapsed.current));
    if (next === "recording" && phase === "saved") {
      elapsed.current = 0;
      pausedOnce.current = false;
      setCopied(false);
    }
    setPhase(next);
  };
  const user = (next: Phase) => {
    auto.current = false;
    go(next);
  };

  // Width morph: animate from the measured old width to the new natural width, then hand back to auto.
  useLayoutEffect(() => {
    const el = pill.current;
    const from = fromWidth.current;
    fromWidth.current = 0;
    if (!el || !from || reduced) return;
    el.style.width = "";
    const to = el.offsetWidth;
    if (to === from) return;
    el.style.width = `${from}px`;
    void el.offsetWidth;
    el.style.width = `${to}px`;
    const t = setTimeout(() => { el.style.width = ""; }, 520);
    return () => { clearTimeout(t); el.style.width = ""; };
  }, [phase, reduced]);

  // Reduced motion opens on the saved chip.
  useEffect(() => {
    if (reduced) { auto.current = false; setPhase("saved"); }
  }, [reduced]);

  // Recording: count time while on screen and run the auto script.
  useEffect(() => {
    const out = timer.current;
    if (phase !== "recording" || !inView) return;
    let raf = 0, last = performance.now();
    const frame = (now: number) => {
      elapsed.current += Math.min(64, now - last);
      last = now;
      if (out) out.textContent = clock(elapsed.current);
      if (auto.current && !pausedOnce.current && elapsed.current >= AUTO.pauseAt) {
        pausedOnce.current = true;
        go("paused");
        return;
      }
      if (auto.current && elapsed.current >= AUTO.stopAt) {
        go("saved");
        return;
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [phase, inView]);

  // Auto script outside recording: resume after the pause, record again after saving.
  useEffect(() => {
    if (!auto.current || !inView || phase === "recording") return;
    const t = setTimeout(() => go("recording"), phase === "paused" ? AUTO.pauseFor : AUTO.restartAfter);
    return () => clearTimeout(t);
  }, [phase, inView]);

  useEffect(() => {
    if (!copied) return;
    const t = setTimeout(() => setCopied(false), 1600);
    return () => clearTimeout(t);
  }, [copied]);

  const live = phase !== "saved";

  return (
    <div ref={root} className={styles.root} data-phase={phase}>
      <div className={styles.screen}>
        <div className={styles.window} aria-hidden="true">
          <span className={styles.bar}><i /><i /><i /></span>
          <span className={styles.heading} />
          <span className={styles.line} />
          <span className={styles.line} />
          <span className={styles.short} />
          <span className={styles.tiles}><i /><i /><i /></span>
        </div>
        <span className={styles.cam} aria-hidden="true" />
        <div ref={pill} className={styles.pill} role="group" aria-label="Recording controls">
          {live ? (
            <>
              <span className={styles.dot} aria-hidden="true" />
              <span ref={timer} className={styles.time} role="timer" aria-label="Recording time">{clock(elapsed.current)}</span>
              <span className={styles.sep} aria-hidden="true" />
              <button type="button" className={styles.icon} onClick={() => user(phase === "paused" ? "recording" : "paused")} aria-label={phase === "paused" ? "Resume recording" : "Pause recording"}>
                <span className={styles.morph} data-play={phase === "paused" || undefined} aria-hidden="true"><i /><i /></span>
              </button>
              <button type="button" className={styles.icon} onClick={() => user("saved")} aria-label="Stop and save">
                <span className={styles.stop} aria-hidden="true" />
              </button>
            </>
          ) : (
            <>
              <button type="button" className={styles.link} onClick={() => setCopied(true)}>
                <svg viewBox="0 0 16 16" aria-hidden="true">
                  {copied ? <path d="m3.5 8.5 3 3 6-7" /> : <path d="M6.5 9.5l3-3M7 4.5l1-1a2.5 2.5 0 0 1 3.5 3.5l-1 1M9 11.5l-1 1A2.5 2.5 0 0 1 4.5 9l1-1" />}
                </svg>
                <span key={String(copied)} className={styles.linkText}>{copied ? "Link copied" : `Saved, ${clock(length)}`}</span>
              </button>
              <button type="button" className={styles.icon} onClick={() => user("recording")} aria-label="Record again">
                <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8a5 5 0 1 0 1.5-3.5M3 2.5v2.5h2.5" /></svg>
              </button>
            </>
          )}
        </div>
      </div>
      <p className={styles.sr} role="status">{phase === "saved" ? `Recording saved, ${clock(length)}` : phase === "paused" ? "Recording paused" : "Recording"}</p>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; position: relative; width: 100%; height: 100%; box-sizing: border-box; background: var(--sand);
  overflow: hidden;
}
.sr { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* A stand in screen being captured: a dashed accent frame shows while recording. */
.screen {
  position: absolute; inset: 18px; margin: auto; max-width: 680px; max-height: 400px; border-radius: 14px; outline: 2px dashed transparent; outline-offset: 5px;
  transition: outline-color .4s var(--out);
}
.root[data-phase="recording"] .screen { outline-color: var(--accent); }
.root[data-phase="paused"] .screen { outline-color: var(--line); }
.window {
  position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px; padding: 0 22px; box-sizing: border-box;
  border-radius: 14px; border: 1px solid var(--line); background: var(--white); overflow: hidden;
}
.bar { display: flex; gap: 5px; margin: 0 -22px 6px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
.bar i { width: 8px; height: 8px; border-radius: 50%; background: var(--line); }
.heading { width: 46%; height: 12px; border-radius: 6px; background: var(--ink); opacity: .8; }
.line { width: 82%; height: 7px; border-radius: 4px; background: var(--line); }
.short { width: 58%; height: 7px; border-radius: 4px; background: var(--line); }
.tiles { flex: 1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; min-height: 0; max-height: 150px; margin: 6px 0 22px; }
.tiles i { border-radius: 10px; background: var(--surface); border: 1px solid var(--line); }
.tiles i:first-child { background: var(--accent-pale); border-color: var(--accent-soft); }
.cam {
  position: absolute; left: 14px; bottom: 14px; width: 54px; height: 54px; border-radius: 50%;
  background:
    radial-gradient(19% 19% at 50% 44%, var(--sand) 96%, transparent 100%),
    radial-gradient(24% 23% at 50% 38%, var(--ink) 96%, transparent 100%),
    radial-gradient(40% 30% at 50% 100%, var(--accent-text) 97%, transparent 100%),
    linear-gradient(160deg, var(--accent-pale), var(--accent-soft));
  box-shadow: 0 0 0 3px var(--white); transition: box-shadow .4s var(--out);
}
.root[data-phase="recording"] .cam { box-shadow: 0 0 0 3px var(--white), 0 0 0 5px var(--accent); }

/* The pill floats over the screen; its width is animated from JS when its contents change. */
.pill {
  position: absolute; left: 50%; bottom: 12px; translate: -50% 0; display: flex; align-items: center; gap: 4px;
  height: 48px; padding: 0 6px 0 16px; box-sizing: border-box; border-radius: 999px; overflow: hidden; white-space: nowrap;
  background: var(--ink); color: var(--white); box-shadow: 0 16px 34px -14px color-mix(in srgb, var(--ink) 60%, transparent);
  transition: width .5s var(--out), padding .5s var(--out);
}
.root[data-phase="saved"] .pill { padding-left: 6px; }

.dot { position: relative; flex: none; width: 10px; height: 10px; margin-right: 6px; border-radius: 50%; background: var(--accent); transition: background-color .3s, scale .3s var(--out); }
.dot::after { content: ""; position: absolute; inset: 0; border-radius: 50%; background: var(--accent); animation: ping 1.4s var(--out) infinite; }
.root[data-phase="paused"] .dot { background: var(--muted); scale: .8; }
.root[data-phase="paused"] .dot::after { animation: none; opacity: 0; }
@keyframes ping { from { opacity: .7; scale: 1; } to { opacity: 0; scale: 2.8; } }

.time { min-width: 34px; font-size: 14px; font-weight: 650; font-variant-numeric: tabular-nums; }
.root[data-phase="paused"] .time { animation: blink 1.2s steps(2, jump-none) infinite; }
@keyframes blink { 50% { opacity: .35; } }
.sep { width: 1px; height: 20px; margin: 0 6px 0 4px; background: color-mix(in srgb, var(--white) 22%, transparent); }

.icon {
  flex: none; display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border-radius: 50%; border: 0;
  background: transparent; color: var(--white); cursor: pointer; transition: background-color .2s, transform .18s var(--out);
}
.icon:hover { background: color-mix(in srgb, var(--white) 14%, transparent); }
.icon:active { transform: scale(.9); }
.icon:focus-visible, .link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.icon svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }

/* Pause morphs into play: each bar's polygon becomes one half of the triangle. */
.morph { position: relative; width: 14px; height: 14px; }
.morph i { position: absolute; inset: 0; background: currentColor; transition: clip-path .35s var(--out); }
.morph i:first-child { clip-path: polygon(8% 0, 38% 0, 38% 100%, 8% 100%); }
.morph i:last-child { clip-path: polygon(62% 0, 92% 0, 92% 100%, 62% 100%); }
.morph[data-play] i:first-child { clip-path: polygon(12% 0, 56% 25%, 56% 75%, 12% 100%); }
.morph[data-play] i:last-child { clip-path: polygon(56% 25%, 100% 50%, 100% 50%, 56% 75%); }
.stop { width: 12px; height: 12px; border-radius: 3px; background: var(--accent); }

.link {
  display: inline-flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 10px; border-radius: 999px; border: 0;
  background: transparent; color: var(--white); font: inherit; font-size: 14px; font-weight: 650; cursor: pointer;
  animation: chip-in .45s var(--out) .1s backwards;
}
.link:hover { background: color-mix(in srgb, var(--white) 10%, transparent); }
.link svg { flex: none; width: 16px; height: 16px; fill: none; stroke: var(--accent); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.linkText { text-decoration: underline; text-decoration-color: var(--accent); text-decoration-thickness: 1.5px; text-underline-offset: 4px; animation: chip-in .3s var(--out); }
@keyframes chip-in { from { opacity: 0; translate: 0 4px; } }

@container (max-height: 270px) {
  .screen { inset: 12px; }
  .pill { bottom: 8px; height: 44px; }
  .tiles { display: none; }
  .cam { width: 42px; height: 42px; left: 10px; bottom: 10px; }
  .window { gap: 8px; }
}
@container (max-width: 380px) {
  .cam { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .dot::after, .root[data-phase="paused"] .time, .link, .linkText { animation: none; }
  .pill, .morph i, .screen, .cam { transition: none; }
}

Use it for

  • Screen, voice and video recorders
  • Hero demos for async video and meeting tools
  • Any long running capture that needs pause and stop close at hand

Avoid

  • Short actions with no running time
  • Pages that already show a full player, where a second control set confuses

How it works

A requestAnimationFrame loop adds frame time only while recording and on screen and writes the timer through a ref, so pausing simply stops the count. The pause icon is two clip-path polygons that transition into the two halves of a play triangle. Stop measures the pill, swaps its contents and animates width from the old size to the new natural size, so the controls collapse into the Saved chip.

Accessibility

Pause, resume, stop, copy link and record again are real labelled buttons, the timer has the timer role and a status line announces recording, paused and saved. The demo pauses off screen and auto play stops after the first click. Reduced motion opens on the saved chip with no pulse, blink or width morph.

Found on