Math Formula
A documented LaTeX subset rendered as MathML with a styled CSS fallback — inline and block display, an accessible sentence derived from the source, copy-as-LaTeX, and an explicit refusal for anything outside the subset.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/math-formula.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MathFormula" component: a small,
documented LaTeX subset compiled to MathML, with a CSS fallback for engines
that lack MathML. Dependencies: lucide-react icons, a shadcn Button, and a cn()
class merger. No KaTeX, no MathJax, no runtime dependency of any kind.
Contract
- forwardRef<HTMLSpanElement>; props extend
Omit<React.HTMLAttributes<HTMLSpanElement>, "children" | "onCopy"> and the
rest are spread on the root:
children: string — the LaTeX source, required;
display?: "inline" | "block" ("inline");
label?: string — overrides the derived accessible sentence;
onCopy?: (latex: string) => void — fires only after the clipboard write
resolved, never on the attempt;
copyable?: boolean (true) — drop the copy affordance in dense body copy;
renderer?: "auto" | "mathml" | "fallback" ("auto").
- The root is a <span> in BOTH displays — block styling is a class, not a
<div> — so a block formula can sit inside a <p> without an invalid-nesting
hydration error.
- Export the pure pieces next to the component: parseLatex(source) =>
{ ok: true, node } | { ok: false, error: { message, index } },
mathSpeech(node) => string, and MATH_SUBSET / MATH_NOT_SUPPORTED, the
published coverage lists — generate MATH_SUBSET from the parser's own tables
so the documentation cannot drift away from what is accepted.
The subset (be exhaustive in your own docs, this is the product)
- Structure: \frac{a}{b}, \sqrt{x}, \sqrt[n]{x}, ^, _, primes, {...} groups,
\left( ... \right) with ( ) [ ] | \{ \} \langle \rangle and . for "no
delimiter on this side". The delimiter commands (\langle \rangle \lbrace
\rbrace \vert \Vert \lvert \rvert) are also accepted as ordinary symbols
outside a fence — they are published as accepted spellings, and \langle a,b
\rangle is written far more often than the \left form.
- Greek (both cases; uppercase renders upright), relations and binary
operators (\times \cdot \pm \le \ge \ne \approx \equiv \to \Rightarrow \in
\subset \cup \cap \infty \partial \nabla \forall \exists \ldots),
upright function names (\sin \cos \log \ln \exp \det \gcd ...),
limit-carrying names (\lim \max \min \sup \inf),
large operators (\sum \prod \coprod \bigcup \bigcap \int \iint \oint),
accents (\vec \hat \tilde \bar \overline),
\text{...}, \mathrm{...}, \mathbb{...}, spacing (\, \: \; \quad \qquad, ~).
- \mathbb maps to real Unicode characters, not to mathvariant: MathML Core
kept only mathvariant="normal" and dropped double-struck with the rest.
Mathematical Alphanumeric Symbols is contiguous EXCEPT for seven capitals
encoded earlier in Letterlike Symbols — C H N P Q R Z — which are holes in
the block and must be mapped explicitly; everything else is offset
arithmetic from U+1D538 / U+1D552 / U+1D7D8.
- Out of scope on purpose, and refused by name: \begin{...} environments
(matrix, cases, aligned, array), & and \\, \mathbf / \mathcal / \mathfrak,
\newcommand and macros, \color / \href / \tag, $ ... $ delimiters,
\limits / \nolimits / \displaystyle.
Behavior — parsing and refusal
- Recursive descent over the raw string with one cursor, so every error
carries the character offset it stopped at. TeX's one-token rule for
arguments is kept: x^12 is (x^1)2, and \frac12 is one half.
- There is no third outcome between "rendered" and "refused". An unknown
command, an unbalanced brace, a missing argument, a stray } or \right, an
environment, an alignment & — each returns a message that names the
offending token. Rendering \begin{matrix} as the letters "beginmatrix"
would be worse than admitting the component does not do matrices.
- The refusal is a first-class visual state, not a thrown error: block display
shows the reason plus the source with a caret under the failing character
(compiler-diagnostic style); inline display keeps the sentence intact —
source in mono with a dashed destructive underline, reason in the accessible
text and in the title.
Behavior — rendering
- MathML by default: mn / mi / mo / mfrac / msqrt / mroot / msup / msub /
msubsup / munder / mover / munderover / mtext / mspace / mrow. \sum and
friends carry movablelimits, so their limits stack above and below in block
display and sit beside the sign inline — TeX's own rule — while integrals
keep side limits in both.
- MathML support is measured once per page, not sniffed: append a hidden
<math><mspace height="23px" width="77px"></mspace></math> and check the box
really is 23 by 77. Probe in a requestAnimationFrame after mount (cancelled
on unmount and before any re-run), and render MathML on the server and on the
first client paint so hydration matches byte for byte.
- The fallback draws the same tree with flex boxes: a stacked numerator and
denominator split by a border-t rule, a radical glyph plus a rule over the
radicand, sup/sub elements for scripts, a three-row column for stacked
limits. It is a layout imitation, not typesetting, and it says so.
- Copy hands back the exact source string, with a copied / failed state that
resets after 2s; the timer is cleared on unmount and before it is re-armed.
Reject rather than pretend when navigator.clipboard is missing (any insecure
origin), because a button that flashes "copied" over an untouched clipboard
is worse than one that admits it failed.
ARIA
- The accessible sentence is derived from the same tree that is drawn, so it
cannot describe a different formula: x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
announces as "x equals the fraction minus b plus or minus the square root of
b squared minus 4 a c, end root over 2 a, end fraction".
- Every grouping the eye gets from the layout has to be audible, so a fraction,
a root and a script each close themselves — "end fraction" / "end root" /
"end exponent" / "end subscript" — in two cases: when their content is
compound, and when they sit inside another fraction, root or script even if
their own sides are leaves. Both halves are load-bearing: without the first,
x^{a+b} + c and x^{a} + b + c are the same sentence; without the second,
\frac{\frac{a}{b}}{c} and \frac{a}{\frac{b}{c}} are. A \left( ... \right)
fence speaks its own delimiters, so nesting stops there and no extra bracket
is added inside it.
- Short forms win where they are unambiguous: x^2 is "squared", x^3 is "cubed",
^\circ is "degrees", \sum_a^b is "the sum from a to b of", \lim_{x \to 0} is
"the limit as x goes to 0 of".
- MathML path: the sentence is the aria-label on <math>, so the announcement is
deterministic instead of depending on how each screen reader walks the tree;
label overrides it whenever the domain says it differently.
- Fallback path: role="math" plus the label on the wrapper, with the whole
visual subtree aria-hidden — stacked boxes linearise into nonsense.
- The copy control is a real <button type="button"> (never a submit inside a
host form), reachable by keyboard at all times: in inline display it is
revealed by opacity on hover/focus and stays visible on coarse pointers,
because opacity:0 keeps it focusable while display:none would strand it.
Its result is announced through an aria-live="polite" status region that is
rendered next to the button and only with it — a copyable={false} formula
ships no live region, so a page of them does not fill the screen reader's
region list with regions whose content can never change.
Rendering & styling
- Semantic tokens only: bg-card and border for the block panel, destructive for
the refusal state, text-muted-foreground for the copy icon; the formula
itself inherits the surrounding colour and font size (em-based sizing
throughout, so it scales with the paragraph it sits in).
- cn() merges className into the root; data-slot="math-formula" plus
data-display and data-state="mathml" | "fallback" | "unsupported" let a
stylesheet reach the state without prop drilling.
- Motion: only the opacity fade of the inline copy button, disabled under
prefers-reduced-motion. Nothing about the formula animates.
Customization levers
- Panel or bare line: the block styling (border, bg-card, padding, my-4) lives
in one cn() branch — strip it for a naked centred equation, or add
equation numbering by rendering a sibling span with tabular-nums.
- Size: everything is em-based, so text-lg on the root scales the whole
formula; the block branch already bumps it to 1.15em.
- Copy affordance: copyable={false} for body copy, or keep the button and
point onCopy at a toast, an analytics event, or a "copied MathML instead"
variant that serialises the rendered tree.
- Renderer: pin renderer="fallback" for print stylesheets or an old embedded
WebView, renderer="mathml" in tests where you want the probe out of the way.
- Subset: the tables (GREEK, SYMBOLS, FUNCTIONS, BIG_OPERATORS, ACCENTS) are
plain objects — add \aleph or \hbar with one entry, and it appears in the
published MATH_SUBSET automatically. Adding a *structure* (matrices, cases)
means a new node type, a new renderer branch and a new speech branch: do
that deliberately, or keep refusing it.
- Speech: mathSpeech is a pure walk over the tree — localise the words, or
swap it for a MathSpeak-grade generator without touching the renderers.Concepts
- Documented subset, not best effort — the parser's dictionaries are the published coverage list (
MATH_SUBSETis generated from them), so "what it renders" and "what the docs claim" cannot drift apart across releases. - Refusal as a rendered state — anything outside the subset produces the source, the reason and a caret at the offending character instead of an exception or a mangled approximation; a formula that quietly renders
\begin{matrix}as letters is a bug that ships to readers. - Feature probe, not user-agent sniffing — MathML support is measured by laying out an
mspaceof a known size once per page;document.createElementNSanswers yes on engines that then draw the tree as unstyled text. - One tree, two renderers — MathML and the CSS fallback are two walks over the same parse tree, which is why the fallback can never show a different formula than the native path, only a plainer one.
- Derived speech, overridable — the accessible sentence is generated from the tree ("the fraction ... over ..., end fraction"), so it always matches what is drawn;
labelexists for the cases where a domain reads a formula its own way. - Movable limits —
\sumstacks its bounds above and below in block display and moves them beside the sign inline, because a two-line operator inside a paragraph wrecks the leading; integrals keep side limits in both, exactly as TeX sets them.
Text Clip Media
Display type used as a window onto media — a photo, a muted clip or a token gradient fills the glyphs, leans away from the pointer, and degrades to a solid token when the media never arrives.
Reading Meta
An article byline strip that derives its reading estimate from the word count and switches from the total to what is left once the reader has started.