Regex Tester
A regex workbench: flag toggles, matches tinted in place, a group table, the engine's own error text, and a worker-backed budget that survives catastrophic backtracking.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/regex-tester.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RegexTester" component (lucide-react icons,
shadcn Label, no other dependency). Everything runs in the browser; nothing is sent
anywhere.
Contract
- Export a forwardRef <div> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">.
- Three independently controllable values, each with a controlled prop, a
default* prop and a change callback:
pattern / defaultPattern / onPatternChange — the source, without delimiters
flags / defaultFlags / onFlagsChange — a flag string such as "gim"
value / defaultValue / onValueChange — the test string
- availableFlags?: readonly ("g"|"i"|"m"|"s"|"u"|"y")[] (default all six). An empty
array hides the toolbar; flags outside the list (d, v) are still honoured if they
arrive in `flags`, they simply get no toggle.
- timeBudgetMs?: number (default 60) — how long one run may take before it is killed.
- maxMatches?: number (default 200) — how many matches one run may collect.
- rows?: number (default 6), label?: string, className.
- onResultChange?: (result: RegexEvaluation) => void fires after every completed
run, refusals included.
- Also export the type RegexEvaluation
{ status: "idle" | "ok" | "invalid" | "limit" | "timeout";
matches: { index: number; value: string;
groups: { label: string; value: string | null }[] }[];
message: string | null; guard: "worker" | "inline" }
and the REGEX_FLAGS table (flag, engine property name, one-line hint).
Behavior — the guard (this is the point of the component)
- A regular expression that backtracks catastrophically cannot be interrupted from
the inside: there is no callback, no yield point, no abort signal. The only real
cancel is a thread you are allowed to kill. So the scan runs in a worker built
from a Blob of its own source (new Worker(URL.createObjectURL(blob))), and the
main thread arms setTimeout(timeBudgetMs) next to it.
- Answer first: clear the timer, commit the result. Timer first: terminate() the
worker mid-exec, report status "timeout" with a message that names the budget and
explains what nested quantifiers cost. The next run builds a fresh worker.
- One run at a time. Starting a run while another is still out means the previous
one may never answer, and its corpse would block every message queued behind it —
so terminate before posting. Every request carries an incrementing job id and
every answer is matched against it; a late answer to an abandoned pattern is
dropped, never rendered.
- Compile once per change, not once per match: build the RegExp at the top of the
run and reuse it for the whole scan.
- If a worker cannot be created (a strict CSP blocks blob: workers) or its thread
errors, fall back to running the same function on the main thread, mark the result
guard: "inline", and say so under the summary. That fallback has no hard cancel —
be honest about it rather than pretending the guard is still armed.
- The scan function is stringified into the worker source, so it must close over
nothing in the module and must survive minification: no imports, no module
constants, everything through the request object.
Behavior — the scan
- Two compiles: the user's exact flags first, so an illegal flag is reported as an
illegal flag; then the scanning clone. Walking a string needs "g", so the clone
adds it when the user did not — without it exec would return the same match
forever. Whether the user asked for "g" decides the outcome: without it, keep
exactly one match, because that is what their code will get.
- Any compile failure returns the caught error's message verbatim. Wording differs
per engine — quote it, never paraphrase it, and never render a highlight from a
previous pattern next to it.
- Zero-length matches (lookarounds, \b, an empty alternative) leave lastIndex where
it is. Step over exactly one code point — two units for an astral character under
/u — or the loop never ends, and stepping a whole character would skip a position.
- Stop at maxMatches with status "limit" and say the rest was not scanned. Silently
truncating is a lie; scanning a million matches is a freeze.
- Per match, collect the numbered captures (a group that never participated is null,
distinct from a group that matched the empty string) and whatever the engine named.
- Group labels: walk the pattern source counting real capturing parens — skip
escapes, let a character class swallow its contents, ignore (?: (?= (?! (?<= (?<!
and inline modifiers — so the table can print "2 code" instead of "2". If that
count disagrees with the engine's, fall back to numbers plus the engine's own
named groups instead of mislabelling anything.
Behavior — highlighting
- The test string is a real <textarea>. A mirror div sits underneath it, absolutely
positioned, clipped, holding the same text with transparent colour and one <mark>
per match; the textarea keeps its own text and paints on top. Both layers share
one font, one padding, one line-height, whitespace: pre-wrap, overflow-wrap:
break-word and scrollbar-gutter: stable — a scrollbar on one layer only would wrap
the two at different columns.
- Pin the mirror with transform: translateY(-textarea.scrollTop) on the textarea's
scroll event, throttled through one requestAnimationFrame, and re-pin after any
change that moves the text without emitting a scroll event.
- Tints cycle through var(--chart-1..5), so adjacent matches never share one and
their edges stay readable. box-decoration-break: clone keeps a wrapped match
looking like one band per line.
- A zero-length match owns no characters, so it renders as a 2px caret inside a
position: relative inline span. Do not use inline-block: an atomic inline adds a
line-break opportunity the textarea does not have, and the two layers drift.
- Results arrive asynchronously, so for one frame the offsets can belong to the
previous keystroke. Clamp every slice into the current string and keep the old
bands rather than blanking them — highlights that blink off on every keystroke are
worse than highlights that lag one frame.
Behavior — keyboard and ARIA
- Flags are a role="toolbar" of aria-pressed toggle buttons with a roving tabindex:
one Tab stop for the whole group, ArrowLeft/ArrowRight wrap, Home/End jump to the
ends, and focus moves the tab stop with it.
- The match list is a role="grid" whose rows are the focus unit: aria-selected,
a single roving tab stop, ArrowUp/ArrowDown between rows, Home/End to the ends,
and selection follows focus. Clicking a row focuses it. The selected match tints
harder in the mirror and is scrolled into view by writing the textarea's own
scrollTop — never scrollIntoView, which drags the whole page.
- Rows can vanish under a focused user when the matches change. Track whether the
grid owns focus, and after each result, if focus has been dropped on <body>, move
it to the nearest surviving row, or to the test-string field when nothing survives.
Focus must never be left on <body>.
- The pattern field takes aria-invalid and aria-describedby pointing at the notice
when a run is refused. One role="status" aria-live="polite" line carries the
summary ("4 matches in 231 characters", "Invalid pattern.", "Stopped — the pattern
is too slow on this input.").
- Control characters are printed as glyphs (↵ ␍ ⇥) in the table, and an empty match
reads "∅ empty" — a blank cell looks like a bug.
Behavior — cleanup
- On unmount and before every new run: clear the pending timeout, terminate the
worker, revoke the object URL, cancel the pending animation frame. A leaked worker
is a leaked thread.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel with border, muted
labels and hints, bg-primary + text-primary-foreground for a flag that is on,
border-destructive + text-destructive for a refusal, bg-accent for the selected
row, ring-ring on focus-visible, var(--chart-1..5) for the match tints (mixed with
color-mix so text stays readable through them).
- Monospace at one size for the pattern, the test string and the flag chips; the
pattern sits between two slash glyphs with the live flag string after it, so the
field reads as the literal it is.
- transition-colors on the tints, chips and rows, every one paired with
motion-reduce:transition-none. Nothing in the result depends on a transition.
- Merge the consumer className with cn(); the panel is fluid and min-w-0, and the
match grid scrolls inside a max-height instead of stretching the card.
Customization levers
- Density: drop the flag hint line and the Range column for a toolbar-sized panel,
or raise rows and the grid's max-height for a full-page tool.
- Budget: timeBudgetMs and maxMatches are the two knobs that decide how patient the
tool is. A docs page can afford 20ms; an internal rule editor over big payloads
might want 500ms and 5000 matches.
- Flags: availableFlags trims the toolbar to the flags your target engine supports —
pass ["g","i","m"] for a backend that speaks RE2, and the panel stops advertising
what will not survive the round trip.
- Palette: swap MATCH_TONES for two alternating tokens if the chart ramp is loud, or
key the tone off a group index to colour by capture instead of by match.
- Extras worth adding on the same skeleton: a replacement field previewing
String.replace output, a "copy /pattern/flags" button, an explain-this-pattern
column, or a readOnly mode (fields readOnly, flag chips aria-disabled plus a
handler guard — never the native disabled attribute) for embedding a fixed example
in documentation.Concepts
- Kill switch, not a stopwatch — a catastrophic regex has no yield point, so measuring how long it has been running is useless; the only honest guard is running it on a thread you are allowed to terminate, which is why the scan lives in a worker built from a Blob of its own source.
- Job ids instead of cancellation — a worker answer that arrives after the pattern moved on is worse than no answer, so every request carries an id and every answer is matched against the current one before it is allowed to render.
- The g-flagged clone — scanning always adds
gbecause exec without it never advances, while the user's owngdecides whether to keep one match or all of them; a zero-length match steps exactly one code point, which is what makes lookaheads and\bterminate instead of spinning. - Mirror, not a re-render of the text — the tints are a transparent copy of the string sitting under a live textarea, pinned by transform to the textarea's own scrollTop, so typing, selecting and undo all stay native while the bands follow along.
- Refusals carry their evidence — an illegal pattern shows the engine's own SyntaxError, a capped scan says how many it stopped at, and a killed run names the budget it blew; none of them leave the previous highlight on screen pretending to still be true.
- Selection follows focus — the match grid is one Tab stop with arrow-key rows, and the focused row, the
aria-selectedstate and the darkened band in the text are the same fact rendered three ways.
Data List
A read-only key/value list that folds from two columns to stacked pairs on container width, with per-row copy, focus-revealed truncation and an explicit em dash for empty fields.
Theme Customizer
A live design-token editor: a radius slider plus hue and saturation per colour role, previewed light and dark side by side on real controls, and exported as the :root / .dark CSS you paste.