Regex for HTML tags: what the preset matches and what breaks it
A tag pattern finds things that look like tags. It cannot track nesting, cannot see inside comments or scripts, and stops at the first greater than sign even inside a quoted attribute.
At a glance
- Built in preset
- HTML Tag
- Preset pattern
- </?[a-zA-Z][a-zA-Z0-9]*(?:\s[^>]*)?/?>
- Preset flags
- g and i
- Matches
- opening, closing and self closing tags with or without attributes
- Breaks on
- a greater than sign inside a quoted attribute value
- Also invisible to it
- comments, script contents and character data sections
In detail
For a known, well formed snippet the preset does exactly what it says. Run over a small fragment it returns every opening tag with its attributes, every closing tag and every self closing tag, which is a perfectly reasonable way to count elements or find every image reference in a template you wrote yourself.
The failure is structural rather than incidental. The attribute portion is [^>]*, so the match ends at the first greater than sign regardless of quoting. An attribute value containing that character, which is legal, cuts the tag in half: the pattern returns a truncated opening tag and then finds the closing tag separately, and the output looks plausible enough to go unnoticed.
Everything else that goes wrong follows from the same limitation. A tag inside a comment is matched as though it were live. Text inside a script element that happens to contain angle brackets is matched as markup. Nesting is invisible, so there is no way to ask which closing tag belongs to which opening one, and that is not a gap in this pattern but a property of regular expressions in general.
The practical line is worth drawing clearly. Use a pattern like this to search, count or highlight in content you control, and use a real parser for anything where being wrong matters: extracting data from a page you did not write, rewriting markup, or making any security decision at all.
Examples
const tag = /<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s[^>]*)?\/?>/gi;
'<div class="container"><p>Hello</p><br/><img src="pic.png" /></div>'.match(tag);
// ['<div class="container">', "<p>", "</p>", "<br/>",
// '<img src="pic.png" />', "</div>"]const tag = /<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s[^>]*)?\/?>/gi;
'<a title="x>y">link</a>'.match(tag);
// ['<a title="x>', "</a>"]The opening tag is truncated at the greater than sign inside the quoted value, and nothing about the result signals that it went wrong.
Things that catch people out
- A greater than sign inside a quoted attribute value ends the match early, producing a truncated tag that still looks like a tag.
- The pattern cannot tell a tag inside a comment or a script from a real one, so a search and replace built on it will edit text that was never markup.
- Nesting is not expressible, so there is no way to pair an opening tag with its closing one. That is a limit of the tool, not of this particular pattern.
Try it: Regex Tester
Type a pattern and a test string, watch every match highlight as you go, read a plain English breakdown of each token, and check the numbered and named groups it captured.
Regex Tester
LOCAL · Browser-OnlyTest and debug regular expressions with live matching and highlights
Related Regex syntax
All regex syntax →Regex for URLs: finding links without overreaching
The built in URL preset needs a scheme and a dotted host, which rules out localhost and bare ports. It also swallows a full stop at the end of a path.
[^abc]: negated character sets that quietly cross newlines
A negated class matches one character that is not in the set, including line terminators. That is why a quoted string pattern runs across lines.
Lazy quantifiers: *?, +?, ?? and {n,m}?
A trailing question mark makes a quantifier prefer the shortest match. It fixes the overreaching tag pattern, and a negated class often fixes it better still.
Regex for numbers: integers, decimals and negative signs
The integer preset splits 3.14 into two matches and the float preset ignores whole numbers. Here is the combined pattern and what it still leaves out.
Regex to extract the text between two delimiters
A lazy quantifier is the usual answer and a negated character class is the better one. Neither can handle nesting, and that limit is worth knowing early.
The g flag: global matching and the lastIndex trap
What the g flag changes in a JavaScript regex, why a reused global pattern skips matches through lastIndex, and when matchAll is the safer choice.
Tools for this
- Regex Tester Free online regex tester - test and debug regular expressions with live matching and highlights
- Regex Playground Regex playground with live match highlighting, named groups, find and replace, and shareable links