What You'll Build
A regex tester that accepts a pattern and a test string, then highlights every match in the text. It shows capture groups, lets you toggle regex flags (g, i, m, s), and displays match statistics.
- Pattern input and test string input
- Live highlighting of every match
- Flag toggles: g (global), i (case-insensitive), m (multiline), s (dotall)
- Capture group display
- Match count and execution time
- Copy matches button
🔍 Regex Tester
Test regular expressions with live highlighting
Capture Groups
What You'll Learn
- The RegExp constructor: building a regex from a string pattern
- Flags: how
g,i,m,schange matching behavior - exec() and lastIndex: iterating through matches in global mode
- Capture groups: extracting subparts of a match
- Highlighting: safely wrapping matched text with
<mark>tags - Performance timing: measuring code with
performance.now()
Why this is advanced: Regex has its own mini-language, and building a tool that highlights matches safely (without breaking HTML) requires careful string handling. The result is one of the most-used tools in every developer's bookmarks.
Build the HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Regex Tester</title>
</head>
<body>
<div class="card">
<h1>Regex Tester</h1>
<p class="sub">Test regular expressions with live highlighting</p>
<div class="field">
<label>Regular Expression</label>
<div class="regex-input-wrap">
<span class="slash">/</span>
<input type="text" id="pattern" value="\b\w+@\w+\.\w+\b" spellcheck="false">
<span class="slash">/</span>
<span class="flags-display" id="flagsDisplay">g</span>
</div>
</div>
<div class="flag-row">
<button class="flag-btn active" data-flag="g">g global</button>
<button class="flag-btn" data-flag="i">i ignore case</button>
<button class="flag-btn" data-flag="m">m multiline</button>
<button class="flag-btn" data-flag="s">s dotall</button>
</div>
<div class="field">
<label>Test String</label>
<textarea id="testString">Contact us at hello@makuistudio.com or support@example.org.</textarea>
</div>
<div class="error" id="errorMsg"></div>
<div class="stats">
<span>Matches: <strong id="matchCount">0</strong></span>
<span>Time: <strong id="matchTime">0 ms</strong></span>
</div>
<div class="output" id="output"></div>
<div class="groups">
<h3>Capture Groups</h3>
<div id="groupsList"></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
Style It with CSS
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f1f5f9;
padding: 20px;
color: #0f172a;
}
.card {
max-width: 580px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 10px 40px rgba(15,23,42,0.08);
}
h1 { font-size: 1.3rem; margin-bottom: 4px; }
.sub { color: #64748b; font-size: 0.85rem; margin-bottom: 16px; }
.field { margin-bottom: 10px; }
label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #334155;
margin-bottom: 4px;
}
/* Regex pattern input — styled to look like /pattern/flags */
.regex-input-wrap {
display: flex;
align-items: center;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 10px;
padding: 0 4px 0 12px;
transition: 0.2s;
}
.regex-input-wrap:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
}
.slash {
color: #3b82f6;
font-family: "Courier New", monospace;
font-weight: 700;
font-size: 1rem;
}
.regex-input-wrap input {
flex: 1;
border: none;
background: transparent;
padding: 10px 6px;
font-size: 0.9rem;
font-family: "Courier New", monospace;
outline: none;
}
.flags-display {
color: #64748b;
font-family: "Courier New", monospace;
font-weight: 700;
padding: 0 12px 0 4px;
}
/* Test string textarea */
textarea {
width: 100%;
min-height: 100px;
padding: 10px 12px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 0.85rem;
font-family: "Courier New", monospace;
outline: none;
resize: vertical;
line-height: 1.6;
}
textarea:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
}
/* Flag toggles */
.flag-row {
display: flex;
gap: 5px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.flag-btn {
background: #f8fafc;
border: 2px solid #e2e8f0;
color: #64748b;
padding: 5px 10px;
border-radius: 6px;
font-weight: 700;
font-size: 0.72rem;
cursor: pointer;
transition: 0.2s;
font-family: "Courier New", monospace;
}
.flag-btn:hover { border-color: #cbd5e1; }
.flag-btn.active {
background: #eff6ff;
border-color: #3b82f6;
color: #1d4ed8;
}
/* Error box */
.error {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.78rem;
font-family: "Courier New", monospace;
margin: 8px 0;
display: none;
}
.error.show { display: block; }
/* Stats */
.stats {
display: flex;
gap: 12px;
font-size: 0.72rem;
color: #64748b;
margin: 8px 0;
flex-wrap: wrap;
}
.stats strong { color: #334155; font-family: "Courier New", monospace; }
/* Output box — highlights matches with mark */
.output {
background: #fff;
border: 2px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
font-family: "Courier New", monospace;
font-size: 0.85rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
min-height: 100px;
max-height: 220px;
overflow-y: auto;
}
.output mark {
background: #3b82f6;
color: #fff;
padding: 1px 3px;
border-radius: 3px;
}
.output mark.alt {
background: #8b5cf6;
}
/* Capture groups */
.groups { margin-top: 12px; }
.groups h3 {
font-size: 0.78rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.group-item {
background: #f8fafc;
border-radius: 6px;
padding: 6px 10px;
font-family: "Courier New", monospace;
font-size: 0.75rem;
color: #334155;
margin-bottom: 4px;
}
.group-item .lbl {
color: #64748b;
font-weight: 700;
margin-right: 6px;
}
.group-item .val {
color: #1d4ed8;
}
.empty {
color: #94a3b8;
font-style: italic;
font-size: 0.85rem;
}
Write the JavaScript — Regex Engine
// ============================================================
// STATE + DOM
// ============================================================
var currentFlags = "g";
var patternEl = document.getElementById("pattern");
var testEl = document.getElementById("testString");
var outputEl = document.getElementById("output");
var errorEl = document.getElementById("errorMsg");
var matchCountEl = document.getElementById("matchCount");
var matchTimeEl = document.getElementById("matchTime");
var groupsEl = document.getElementById("groupsList");
var flagsDisplayEl = document.getElementById("flagsDisplay");
// ============================================================
// ESCAPE HTML — safety before injecting into innerHTML
// ============================================================
// If the test string contains < or >, we must escape them
// before wrapping matched parts in tags. Otherwise
// the browser would interpret user input as HTML.
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
// ============================================================
// RUN — the main function
// ============================================================
// 1. Read pattern and flags
// 2. Build a RegExp object
// 3. Find matches (with or without global flag)
// 4. Time it
// 5. Render highlights and capture groups
function run() {
var pat = patternEl.value;
var text = testEl.value;
errorEl.classList.remove("show");
flagsDisplayEl.textContent = currentFlags || "(none)";
// Empty pattern → show plain text
if (!pat) {
outputEl.textContent = text;
matchCountEl.textContent = "0";
matchTimeEl.textContent = "0 ms";
groupsEl.innerHTML = '<div class="empty">No matches yet</div>';
return;
}
// Try to compile the pattern. Bad patterns throw.
var regex;
try {
regex = new RegExp(pat, currentFlags);
} catch (e) {
errorEl.textContent = "❌ " + e.message;
errorEl.classList.add("show");
outputEl.textContent = text;
matchCountEl.textContent = "0";
matchTimeEl.textContent = "0 ms";
groupsEl.innerHTML = '<div class="empty">Fix the pattern to see matches</div>';
return;
}
// ---- Find matches ----
var t0 = performance.now();
var matches = [];
if (currentFlags.indexOf("g") !== -1) {
// Global mode — iterate with exec()
var iter = new RegExp(pat, currentFlags);
var m;
var safety = 0;
while ((m = iter.exec(text)) !== null && safety < 5000) {
matches.push({
index: m.index,
length: m[0].length,
full: m[0],
groups: m.slice(1)
});
// Handle zero-width matches to avoid infinite loops
if (m[0] === "") iter.lastIndex++;
safety++;
}
} else {
// Single mode — one match max
var single = regex.exec(text);
if (single) {
matches.push({
index: single.index,
length: single[0].length,
full: single[0],
groups: single.slice(1)
});
}
}
var t1 = performance.now();
// ---- Update display ----
matchCountEl.textContent = matches.length;
matchTimeEl.textContent = (t1 - t0).toFixed(2) + " ms";
renderHighlighted(text, matches);
renderGroups(matches);
}
Why new RegExp(pat, flags) instead of literal syntax? Regex literals like /\w+/g are known at code-time. But our pattern comes from user input at runtime, so we must use the constructor to build a regex from a string.
Zero-width match safety: Some patterns match an empty string (like \b alone). Without the if (m[0] === "") iter.lastIndex++ guard, the loop would never end. This is a classic regex pitfall.
Write the JavaScript — Rendering
// ============================================================
// HIGHLIGHT MATCHES
// ============================================================
// Build the HTML by walking through the text once, inserting
// <mark> tags around each match. Alternate colors help
// distinguish adjacent matches.
function renderHighlighted(text, matches) {
if (matches.length === 0) {
outputEl.textContent = text || "(empty string)";
return;
}
var html = "";
var cursor = 0;
for (var i = 0; i < matches.length; i++) {
var m = matches[i];
// Text before this match (escaped)
html += escapeHtml(text.slice(cursor, m.index));
// The match itself, wrapped in <mark>
var cls = i % 2 === 1 ? ' class="alt"' : '';
html += '<mark' + cls + '>' +
escapeHtml(text.slice(m.index, m.index + m.length)) +
'</mark>';
cursor = m.index + m.length;
}
// Remaining text after the last match
html += escapeHtml(text.slice(cursor));
outputEl.innerHTML = html;
}
// ============================================================
// RENDER CAPTURE GROUPS
// ============================================================
// A regex like (\d+)-(\d+) has two capture groups.
// matches[i].groups = [group1, group2, ...]
function renderGroups(matches) {
if (matches.length === 0) {
groupsEl.innerHTML = '<div class="empty">No matches yet</div>';
return;
}
var shown = matches.slice(0, 15);
groupsEl.innerHTML = shown.map(function(m, i) {
var groupHtml = m.groups.length > 0
? m.groups.map(function(g, gi) {
return '<span class="lbl">$' + (gi + 1) + ':</span>' +
'<span class="val">' + escapeHtml(String(g)) + '</span>';
}).join(" ")
: '<span class="lbl">(no capture groups)</span>';
return '<div class="group-item">' +
'<span class="lbl">Match ' + (i + 1) + ':</span>' +
groupHtml +
'</div>';
}).join("");
if (matches.length > 15) {
groupsEl.innerHTML += '<div class="group-item"><span class="lbl">…and ' + (matches.length - 15) + ' more</span></div>';
}
}
Write the JavaScript — Flags & Events
// ============================================================
// FLAG TOGGLES
// ============================================================
// Each flag button toggles its flag on/off. We keep the
// flags in a single string, and re-order them canonically
// so "gi" and "ig" are treated as the same.
document.querySelectorAll(".flag-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
var f = btn.dataset.flag;
if (currentFlags.indexOf(f) !== -1) {
currentFlags = currentFlags.replace(f, "");
btn.classList.remove("active");
} else {
currentFlags += f;
btn.classList.add("active");
}
// Normalize order
currentFlags = ["g", "i", "m", "s"].filter(function(x) {
return currentFlags.indexOf(x) !== -1;
}).join("");
run();
});
});
// ============================================================
// LIVE UPDATES
// ============================================================
patternEl.addEventListener("input", run);
testEl.addEventListener("input", run);
// Boot
run();
Save and open index.html. The default email pattern highlights two emails. Try different patterns: \d+ for numbers, [A-Z]\w+ for capitalised words, or (\w+)@(\w+) for capture groups.
Understand the Big Ideas
1. The RegExp constructor vs literal
/\d+/g is a literal regex — the pattern is fixed at code-time. new RegExp("\\d+", "g") is a runtime regex — the pattern is built from a string. When the pattern is user input, only the second option works.
Note the double backslash: in a string, \d would be interpreted as an escape sequence. \\d gives the regex engine the actual \d it needs.
2. exec() and lastIndex — how global matching actually works
When a regex has the g flag, calling .exec() repeatedly returns each match in sequence. The regex object remembers where it stopped via .lastIndex. When there are no more matches, .exec() returns null.
var re = /\d+/g; var text = "a1 b22 c333"; re.exec(text); // ["1"] lastIndex=2 re.exec(text); // ["22"] lastIndex=6 re.exec(text); // ["333"] lastIndex=12 re.exec(text); // null lastIndex=0
Without the g flag, .exec() always returns the first match and doesn't update .lastIndex. That's why the code branches based on whether g is set.
3. Capture groups
Parentheses in a regex create capture groups. For the pattern (\w+)@(\w+), matching "hello@example" produces:
match[0]— the full match: "hello@example"match[1]— group 1: "hello"match[2]— group 2: "example"
We store m.slice(1) as groups — an array of everything except the full match. That's what the capture groups panel shows.
4. XSS-safe highlighting
The most subtle piece of this project is escapeHtml(). If a user types <script>alert(1)</script> into the test string, we must NOT let the browser execute it. Escaping turns < into < so it displays as text instead of being parsed as a tag.
Rule to remember: Whenever you build HTML by concatenating strings, escape any user-controlled values first. This applies to every project — Todo lists, comments, chat apps.
Practice Challenges
🟢 Beginner
- Add a sample patterns dropdown — Email, URL, Phone, Date, IPv4
- Show the character index of each match
- Add a reset button to restore the default pattern
🟡 Intermediate
- Add replace mode — enter a replacement and see the result
- Add match list with indexes — table of match, position, length
- Add named capture groups support —
(?<name>...)
🔴 Advanced
- Add a regex cheat sheet sidebar with clickable patterns
- Add flags u and y — Unicode and sticky
- Add a share URL with the pattern encoded as query params
Compare your solution: We built the production version with more patterns, replace mode, and copy features — see the live Regex Tester.
Full Source Code
Common Mistakes & Fixes
❌ "Regex does nothing on 'g' flag off"
Without g, .exec() returns only the first match. If you expected all matches, either add the flag or use String.prototype.match() — but that behaves differently with/without g.
❌ "Infinite loop with empty matches"
Zero-width matches (like \b or a* matching empty) cause .exec() to return forever. Always increment lastIndex when m[0] === "".
❌ "Highlighting breaks on special characters"
User input with < or & gets interpreted as HTML. Always escape before wrapping in <mark> tags. The escapeHtml() function does this.
❌ "Colons or slashes in the pattern confuse the input"
Users might type /pattern/g including the slashes. Our input only wants the pattern part. Consider stripping leading/trailing slashes automatically.
❌ "The error message is scary"
Browser regex errors are technical ("Invalid regular expression: /(/..."). Consider adding a helper that translates common errors into friendly messages.