Componentmedia1.5 KB gzipZero dependencies

Combo Buttons

Timed sequence input with draining timer bar.

Preview

Live Preview

Combo input

ABX

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add combo-buttons

Or install the full npm package:

npm install bigbullui
import { ComboButtons } from "@/components/ui/combo-buttons";

<ComboButtons sequence={["A", "B", "X"]} onComplete={() => console.log("combo")} />

Usage guidance

Use Combo Buttons when a ticket stub surface needs Timed sequence input with draining timer bar. Keep props minimal and prefer composition with sibling stubs.

Avoid Combo Buttons for long form flows or dense data grids where a dedicated layout block fits better.

Source code

combo-buttons.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface ComboButtonsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onProgress"> {
  sequence?: string[];
  timeoutMs?: number;
  onComplete?: () => void;
  onProgress?: (hits: number, total: number) => void;
}

/** Combo buttons: input the shown sequence in order before the timer bar drains. */
export function ComboButtons({
  sequence = ["A", "B", "X"],
  timeoutMs = 4000,
  onComplete,
  onProgress,
  className,
  ...props
}: ComboButtonsProps) {
  const [hits, setHits] = React.useState(0);
  const [done, setDone] = React.useState(false);
  const [cycle, setCycle] = React.useState(0);

  React.useEffect(() => {
    setHits(0);
    setDone(false);
  }, [cycle, sequence.join(",")]);

  React.useEffect(() => {
    if (done) return;
    const t = setTimeout(() => setCycle((c) => c + 1), timeoutMs);
    return () => clearTimeout(t);
  }, [cycle, timeoutMs, done]);

  const press = (glyph: string) => {
    if (done) return;
    if (glyph === sequence[hits]) {
      const next = hits + 1;
      setHits(next);
      onProgress?.(next, sequence.length);
      if (next >= sequence.length) {
        setDone(true);
        onComplete?.();
      }
    } else {
      setHits(0);
      onProgress?.(0, sequence.length);
    }
  };

  const options = React.useMemo(() => Array.from(new Set(sequence)), [sequence]);

  return (
    <div className={cn("w-full max-w-xs touch-none select-none rounded-lg border-2 border-foreground bg-card p-3", className)} style={{ touchAction: "none" }} {...props}>
      <style>{`@keyframes comboDrain { from { width: 100%; } to { width: 0%; } }`}</style>
      <p className="font-mono text-[10px] uppercase tracking-[0.15em] text-muted-foreground">Combo input</p>
      <div className="mt-2 flex items-center gap-1.5" role="list" aria-label="Required sequence">
        {sequence.map((g, i) => (
          <span
            key={i}
            role="listitem"
            aria-label={`Step ${i + 1}: ${g}${i < hits ? ", complete" : ""}`}
            className={cn(
              "flex size-11 items-center justify-center rounded-md border-2 border-dashed font-mono text-sm font-black",
              i < hits
                ? "border-accent bg-accent/15 text-accent"
                : "border-border bg-secondary/40 text-muted-foreground"
            )}
          >
            {g}
          </span>
        ))}
      </div>
      <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary" aria-hidden="true">
        {!done && (
          <div
            key={cycle}
            className="h-full rounded-full bg-accent animate-[comboDrain_4s_linear_forwards] motion-reduce:animate-none"
            style={{ animationDuration: `${timeoutMs}ms` }}
          />
        )}
        {done && <div className="h-full w-full rounded-full bg-emerald-500" />}
      </div>
      <div className="mt-2 flex flex-wrap gap-1.5">
        {options.map((g) => (
          <button
            key={g}
            type="button"
            aria-label={`Press ${g}`}
            style={{ touchAction: "none" }}
            onClick={() => press(g)}
            className="flex min-h-11 min-w-11 touch-none items-center justify-center rounded-md border border-foreground bg-background px-3 font-mono text-sm font-bold transition-colors hover:bg-secondary active:bg-primary active:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
          >
            {g}
          </button>
        ))}
        <button
          type="button"
          onClick={() => setCycle((c) => c + 1)}
          className="ml-auto min-h-11 touch-none rounded-md px-3 font-mono text-[11px] uppercase text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          Reset
        </button>
      </div>
      {done && (
        <p className="mt-2 font-mono text-[11px] font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-400">
          Combo complete
        </p>
      )}
    </div>
  );
}

Props

PropTypeDescription
sequencestring[]Required input order (required).
timeoutMsnumberWindow before reset (default 4000).
onComplete() => voidCalled when the full sequence lands.