Getting started
MathInput is a web component. It works in any framework, or none.
Install
npm install @mathinput/element
# React apps also:
npm install @mathinput/react
Use it in plain HTML or any framework
<script type="module">
import "@mathinput/element/define"; // registers <math-input>
import "@mathinput/element/mathinput.css"; // default styles
</script>
<math-input subject="maths" label="Your answer" submit-on-enter></math-input>
<script type="module">
const input = document.querySelector("math-input");
input.addEventListener("submit", (e) => {
const answer = e.detail; // see Output formats
console.log(answer.latex); // x=\frac{1}{2}
console.log(answer.text); // x = 1/2
});
</script>
Give every field an accessible name with label, aria-label or
aria-labelledby.
Use it in React
import { MathInput } from "@mathinput/react";
import "@mathinput/element/mathinput.css";
export function Answer() {
return (
<MathInput
subject="chemistry"
label="Balanced equation"
submitOnEnter
onSubmit={(v) => saveAnswer(`$${v.latex}$`)}
/>
);
}
Pass value and onInput for a controlled field; it never resets the caret
while the learner types.
Show a stored answer
<math-input readonly latex="\frac{-b\pm\sqrt{b^{2}-4ac}}{2a}"></math-input>
Read-only fields have no caret or keypad and are read out by screen readers.
One answer at a time
Each field holds one expression. To let learners show their working, keep
the list of steps in your app: on submit, store e.detail, show it
read-only, and call input.clear().
Fonts
The expression uses STIX Two Text if it is available, then Cambria Math
and Times. Load STIX Two from your own assets (for example the
@fontsource/stix-two-text package) or set another font with the
math-font attribute or --mi-font-math.
API reference
Attributes
| Attribute | Values | Default | Meaning |
|---|---|---|---|
subject |
maths, chemistry, physics |
maths |
Typing rules, keypad and output style. |
latex |
LaTeX | Initial content. Unsupported LaTeX fires parse-error. |
|
label |
text | Accessible name (or use aria-label / aria-labelledby). |
|
placeholder |
text | Enter your answer |
Shown when empty. |
keypad |
auto, always, never, collapsed |
auto |
auto: open on touch devices, behind a toggle with a mouse. |
keypad-container |
element id | Render the keypad inside another element. | |
submit-on-enter |
boolean | off | Enter and the ↵ key fire submit. |
readonly |
boolean | off | Display only. |
disabled |
boolean | off | |
autoreplace |
false to turn off |
on | Keyboard shortcuts such as typing sqrt or pi. |
theme |
auto, light, dark |
auto |
Built-in colour scheme. |
math-font |
font family | STIX Two Text | Font for the expression. |
Properties
| Property | Type | |
|---|---|---|
value |
MathDocument |
The expression tree. Setting it replaces the content and clears undo. |
latex |
string |
Current LaTeX; setting it parses LaTeX. |
subject, readOnly, disabled |
Mirror the attributes. | |
keypadLayout |
KeypadLayout | KeypadPatch |
Replace or trim the keypad; see Keypad configuration. |
keypadOpen |
boolean |
Open or close the keypad. |
editor |
Editor |
The headless editor, for advanced use. |
Methods
focus(), blur(), clear(), undo(), redo(), selectAll(),
commit() (fires change), getValue(), setValue(doc),
insert(nodeOrNodes), execute(command) where command is one of
moveLeft, moveRight, moveUp, moveDown, moveHome, moveEnd,
moveToNextPlaceholder, moveToPreviousPlaceholder, exitTemplate,
deleteBackward, deleteForward, selectAll, clear, undo, redo.
Events
All events bubble and cross shadow roots.
| Event | detail |
When |
|---|---|---|
input |
value | Every edit. |
change |
value | On leaving the field after edits, or commit(). |
submit |
value | Enter or ↵, with submit-on-enter. Not fired when empty. |
parse-error |
{ source, input, message } |
Pasted text or the latex attribute could not be read. |
keypad-toggle |
{ open } |
The keypad was opened or closed. |
The value is described in Output formats.
Keyboard
| Keys | Action |
|---|---|
/ |
Fraction (the number or term before the caret becomes the top) |
^ _ |
Power, subscript |
( ) [ ] |
Brackets, one side at a time: type ( before existing work and ) after it |
| |
Modulus |
<= >= != -> ~ |
≤ ≥ ≠ → ≈ |
sqrt pi theta sin … |
Replaced as you type (turn off with autoreplace="false") |
| Arrows, Home, End | Move; with Shift, select |
| Tab | Next empty box, then leave the field |
| Ctrl/⌘ + Z, Y, A, C, X, V | Undo, redo, select all, copy, cut, paste |
In chemistry, digits after an element become subscripts (H2O), and a
two-letter element is recognised as you type (Cl).
Packages
| Package | Contents |
|---|---|
@mathinput/core |
Tree, headless editor, serialisers, parsers, keypad presets. No DOM; usable on a server. |
@mathinput/element |
<math-input>, keypad, mathinput.css, tailwind.css. |
@mathinput/react |
<MathInput> for React 18 and 19. |
@mathinput/presets-uk |
Optional keypads for UK GCSE Foundation, GCSE Higher and A-level. |
Theming MathInput
MathInput ships a default look that works in light and dark mode. Everything visual can be changed without touching its source, in increasing depth:
- Tokens — set
--mi-*custom properties. Enough for most apps. - Classes — style the public
mi-*classes in your own CSS. - Keypad — change keys, tabs and labels (see keypad-config.md).
- Replace the stylesheet — skip
mathinput.cssand style the class contract from scratch.
Loading the styles
import "@mathinput/element/define"; // registers <math-input>
import "@mathinput/element/mathinput.css"; // default styles
All default rules are inside @layer mathinput, so any ordinary
(unlayered) rule of yours wins without !important.
With Tailwind CSS v4
Import MathInput's Tailwind entry before Tailwind:
@import "@mathinput/element/tailwind.css";
@import "tailwindcss";
This fixes the layer order (theme, base, mathinput, components, utilities)
so Tailwind utilities override the defaults, and exposes the tokens as theme
values: bg-mi-key, text-mi-field-fg, bg-mi-key-template,
rounded-mi-key, font-mi-math and so on. Consumers without Tailwind never
need it.
Tokens
Set tokens on math-input (or any ancestor):
math-input {
--mi-field-border-focus: #7c3aed;
--mi-key-template-bg: #f3e8ff;
--mi-key-template-fg: #5b21b6;
--mi-key-variant-indicator: #db2777;
--mi-key-radius: 6px;
}
| Group | Tokens |
|---|---|
| Fonts | --mi-font-ui, --mi-font-math (or the math-font attribute) |
| Field | --mi-field-bg, --mi-field-fg, --mi-field-border, --mi-field-border-focus, --mi-field-ring, --mi-field-radius, --mi-field-size, --mi-field-min-height, --mi-field-padding, --mi-muted |
| Editing | --mi-caret, --mi-row-active-bg, --mi-selection-bg, --mi-placeholder-border, --mi-placeholder-border-active, --mi-placeholder-bg-active, --mi-ghost-opacity, --mi-ghost-color, --mi-danger |
| Keypad | --mi-keypad-bg, --mi-keypad-border, --mi-keypad-gap, --mi-keypad-radius |
| Keys | --mi-key-bg, --mi-key-fg, --mi-key-shadow, --mi-key-radius, --mi-key-font-size, --mi-key-size-phone, --mi-key-size-tablet, --mi-key-size-desktop, --mi-key-operator-bg, --mi-key-template-bg, --mi-key-template-fg, --mi-key-primary-bg, --mi-key-primary-fg, --mi-key-pressed-bg |
| Key labels | --mi-key-placeholder (boxes), --mi-key-placeholder-active (the box the caret lands in) |
| More options | --mi-key-variant-indicator |
| Tabs, sheets | --mi-tab-fg, --mi-tab-selected-bg, --mi-tab-selected-fg, --mi-sheet-bg, --mi-sheet-shadow |
| Motion | --mi-motion (reduced-motion is respected automatically) |
Keep text at 4.5:1 contrast and boxes and indicators at 3:1; the default tokens are checked in CI.
Light and dark
theme="auto" (default) follows the operating system; theme="light" or
theme="dark" forces one. To follow your app's own theme switch, set the
attribute from your theme state, or define your own token values under your
theme selector.
The "more options" indicator
Keys with extra options (hold, right-click, or Alt+↓) show a filled corner in
--mi-key-variant-indicator. Change the shape with
data-variant-indicator="dot" or "bar" on the element.
Classes
Structural classes are public API and are listed in the spec (§10.2); for
example .mi-field, .mi-key[data-kind="template"], .mi-frac__bar,
.mi-fence__side--ghost. State is exposed as attributes on the element:
data-focused, data-readonly, data-disabled, data-form
(phone|tablet|desktop), data-keypad (open|closed), data-subject.
Try the skins
Pick a skin to restyle the field below. The CSS it needs is shown underneath; copy it into your own stylesheet.
Configuring the keypad
Attributes
| Attribute | Values | Effect |
|---|---|---|
subject |
maths · chemistry · physics |
Picks the preset and the typing rules. |
keypad |
auto (default) · always · never · collapsed |
auto opens on touch devices and sits behind a toggle with a mouse. |
keypad-container |
element id | Renders the keypad inside your own element, for a bottom sheet or side panel. |
submit-on-enter |
boolean | Enter and the ↵ key fire submit. Without it the ↵ key becomes "next box". |
el.keypadOpen = true opens it from code; the keypad-toggle event reports
changes.
Each subject's keypad offers every key it has. To fit it to a course, remove keys by topic or by id.
Changing keys
Patch the preset:
el.keypadLayout = {
removeTabs: ["letters"],
removeKeys: ["letter-t", "const-pi"],
addKeys: {
algebra: [{ id: "k", label: { text: "k" }, aria: "k", action: { type: "k" } }],
},
};
A patch always starts from the preset for the field's subject. Removed keys also disappear from long-press menus, and a tab left empty is dropped.
Or replace it entirely with a full KeypadLayout ({ numberPad, tabs, navigation }); start from keypadPreset(subject) in @mathinput/core.
Key ids are stable. List them with:
import { keypadPreset } from "@mathinput/core";
const l = keypadPreset("maths");
console.log([l.numberPad, ...l.tabs].flatMap((t) => t.keys.map((k) => k.id)));
Removing topics
Keys for more advanced topics carry tags. Remove a whole topic with
removeTags:
el.keypadLayout = { removeTags: ["calculus", "logarithms"] };
| Tag | Keys |
|---|---|
column-vectors |
column vector |
logarithms |
ln, log, log to a base (the chemistry log key for pH is untagged) |
exponentials |
e, eˣ |
infinity |
∞ |
calculus |
d/dx (with dy/dx), ∫ |
series |
Σ |
vector-notation |
vector arrow, hat |
reciprocal-trig |
sec, cosec, cot |
proof |
∴, ≡ |
Your own keys can carry tags too (tags: ["my-topic"]).
Curriculum presets
Ready-made key sets for a curriculum are optional packages built on the
same patches. @mathinput/presets-uk covers GCSE Foundation, GCSE Higher
and A-level in England:
npm install @mathinput/presets-uk
import { ukKeypadPatch } from "@mathinput/presets-uk";
el.keypadLayout = ukKeypadPatch("gcse-higher");
// Combine with your own changes:
el.keypadLayout = { ...ukKeypadPatch("gcse-foundation"), removeTabs: ["letters"] };
| Level | Hides |
|---|---|
gcse-foundation |
everything gcse-higher hides, plus column vectors |
gcse-higher |
logarithms, e and eˣ, ∞, calculus, Σ, vector arrows and hats, sec cosec cot, ∴, ≡ |
a-level |
nothing |
ukKeypad(subject, level) returns the full trimmed layout, for example to
list its key ids. In React, pass the patch as keypadLayout.
Key definitions
interface Key {
id: string;
aria: string; // accessible name, e.g. "fraction"
label: { text: string } // plain text
| { tree: Row, active?: Position } // a mini expression, drawn like the field
| { icon: "left" | "right" | "backspace" | "enter" | "shift" | "keypad" | "table" }
| { html: string }; // your own markup (you are responsible for it)
action: { type: string } // as if typed on a keyboard (subject rules apply)
| { insert: Node | Node[] }
| { template: Template, absorb?: boolean, prefix?: Node[] }
| { command: CommandName } // e.g. "moveLeft", "undo"
| { bracket: "open" | "close" | "wrap", char: "(" | "[" | "{" | "|" }
| { ui: "submit" | "shift" | "periodic-table" | "keypad-toggle" };
variants?: Key[]; // shown on long press; the key gets the indicator
kind?: "digit" | "operator" | "template" | "letter" | "function" | "nav" | "primary" | "word";
}
Use tree labels for template keys so the key shows exactly what it
inserts, with active marking the box the caret will land in. The tree
builders in @mathinput/core (sup, frac, sqrt, …) make this short:
import { frac, sup } from "@mathinput/core";
const cubed = { id: "cubed", aria: "cubed", label: { tree: [sup("3")] }, action: { template: sup("3") } };
Output formats
Every event (input, change, submit) carries a value with:
| Field | Example (x = ½ or x = 3) |
Use |
|---|---|---|
doc |
{ version: 1, subject: "maths", root: [...] } |
Store it; set it back with el.value = doc. The only lossless format. |
latex |
x=\frac{1}{2}\text{ or }x=3 |
Render with KaTeX/MathJax; send to an LLM. Chemistry: \ce{…} (mhchem). |
text |
x = 1/2 or x = 3 |
Logs, search, a second reading for an LLM. |
spoken |
x equals 1 over 2 or x equals 3 |
Screen readers (already used by the element). |
mathml |
<math …>…</math> |
Native rendering, assistive technology. |
isEmpty, hasPlaceholders, hasUnbalancedBrackets |
Decide whether to accept the answer. |
Formats are computed when you first read them.
Sending answers to an LLM
Wrap the LaTeX in $…$ (the host adds delimiters; MathInput never does):
const v = event.detail;
const prompt = `Student answer: $${v.latex}$ (read as: ${v.text})`;
Empty boxes appear as \square so the model can see something is missing,
and brackets the learner left open are closed in the output but reported in
hasUnbalancedBrackets.
Reading formats back
@mathinput/core parses its own output and common variants:
import { fromLatex, fromText } from "@mathinput/core";
el.value = fromLatex("\\frac{x+1}{2}", "maths");
el.value = fromText("Mg + 2HCl -> MgCl2 + H2", "chemistry");
The latex attribute does the same for an initial value. Unsupported LaTeX
raises ParseError (or a parse-error event from the element).
Without the element
All formats are pure functions in @mathinput/core, usable on a server:
toLatex(doc), toText(doc), toSpoken(doc), toMathML(doc).