Add Tajwīd highlighting
Quran Tajweed publishes where every recitation rule applies as character positions, not markup. This guide takes those positions and puts colour on text you are already rendering, without inserting anything into the Qur’anic string and without running any Arabic analysis. The corpus is for Ḥafṣ ʿan ʿĀṣim only.
By the end, al-Fātiḥa is on screen coloured by rule topic, with a legend, from three files and no matching engine.
Install the three packages
Names are from each package's
package.json. The annotations are the precomputed spans, the rules are the corpus they index into, andcoreis the small library that resolves one to the other.npm i @quran.ws/tajwid @quran.ws/tajwid-rules @quran.ws/tajwid-annotationsPublishing is decided, not yet doneAt the commit this guide was read against, none of the
@quran.ws/tajwid-*names resolve on npm. What exists is the repository’s GitHub release with the annotation and rules JSON, and the edition file in the repository tree. The imports below are the ones the packages declare. They were@tajweed/*, then briefly@quran-ws/*, and are now@quran.ws/*, the scope that matches the domain and the spelling a repository and package name takes (Naming, rule 5.2). Nothing was ever published under any of the three, so no install is broken by the change; it was made before the first publish rather than after.Load the annotations, the corpus and the edition together
Three files are one unit. The annotations record which corpus version they were computed under and the SHA-256 of the exact text they were measured against; the corpus resolves a rule id to its ruling and topic; the edition is that exact text.
import corpus from "@quran.ws/tajwid-rules"; import annotations from "@quran.ws/tajwid-annotations"; import { unpack, assertEdition } from "@quran.ws/tajwid"; const edition = await (await fetch("/editions/uthmani-hafs.json")).json(); if (annotations.corpusVersion !== corpus.version) { throw new Error(`annotations are v${annotations.corpusVersion}, corpus is v${corpus.version}`); } await assertEdition(edition, annotations.edition.sha256); // throws EditionMismatchError otherwiseassertEditionrecomputes a digest over the edition's content — each reference and its text in muṣḥaf order, withU+0000between a reference and its text andU+0001between records — and compares it withannotations.edition.sha256. The two control characters are part of the contract; Pinning has the computation in full. Run against the shipped edition it computesb5d29736bb3ef49d9d331c4e60a59d83fe899b921e9e8dc35911bd4a18ce55f3, which is what the annotation set carries.Render the edition's text, not another dataset's
These offsets belong to one text, and it is not Quran Text'sThe spans are character offsets into
editions/uthmani-hafs.json. They are not offsets into Quran Text’s words, and joining those words with a space does not reproduce the edition: measured across the whole muṣḥaf, the join matches the edition in 319 of 6,236 ayahs. The differences are invisible (a shadda and its vowel in the other order, آ as one code point against two, waqf marks inline against a separate layer) and two of them change the length of the string, so every offset after them is shifted to the end of the ayah. In 1:7 the span[84, 88]is لِّي in the edition and ِّين in the joined Quran Text words. Our own demo shipped this bug. Renderedition.ayahs[ref].const ref = "1:1"; const text = edition.ayahs[ref]; // "بِسۡمِ ٱللَّهِ ٱلرَّحۡمَٰنِ ٱلرَّحِيمِ" — 38 code pointsIf your product's text comes from Quran Text, keep it for everything else (search, word identity, the other six riwayat) and take the displayed string of a tajwīd view from the edition. The reference is the join between them.
Unpack the spans for an ayah
Spans are packed as
[start, end, ruleIndex], where the third element indexesruleIds.unpackexpands them and resolves each rule to its ruling, category and topic:const spans = unpack(annotations, corpus, "1:1"); // [ // { start: 8, end: 10, ruleId: "mutamathilain-idgham-kamil.23", hukumId: "mutamathilain-idgham-kamil", categoryId: "mutamathilain-sagheer", topicId: "letter-relations" }, // { start: 17, end: 18, ruleId: "raa-tafkheem.1", hukumId: "raa-tafkheem", categoryId: "raa-rules", topicId: "tafkheem-tarqeeq" }, // { start: 22, end: 25, ruleId: "madd-tabee-kalimi.1", hukumId: "madd-tabee-kalimi", categoryId: "madd-tabee", topicId: "madd" }, // { start: 30, end: 31, ruleId: "raa-tafkheem.1", … topicId: "tafkheem-tarqeeq" }, // { start: 33, end: 36, ruleId: "madd-tabee-kalimi.3", … topicId: "madd" }, // ]startandendare half-open code-point offsets. All Arabic and all Qur’anic marks sit in the Basic Multilingual Plane, so for this text they work directly as JavaScript string indices:text.slice(8, 10)is لل, the two lāms the first ruling concerns.An ayah absent from
annotations.spanshas no annotation (one ayah in the muṣḥaf, 20:1) andunpackreturns[]for it.Flatten the overlaps, and colour by topic
Spans overlap: one letter can demonstrate more than one ruling, and 1:6 has eleven spans over its 36 characters, several stacked. For a single-colour reader flatten first;
resolveOverlapskeeps the earliest span, and the longest on a tie:import { resolveOverlaps } from "@quran.ws/tajwid"; const flat = resolveOverlaps(unpack(annotations, corpus, "1:6")); // 11 spans → 5: [5,8] madd · [11,14] tafkheem-tarqeeq · [14,17] madd · [17,19] tafkheem-tarqeeq · [29,32] maddThen colour by topic. There are seven in the whole corpus; colouring by rule would give you well over a hundred shades no reader can tell apart:
const COLOURS = { // this site's demo palette — see the note below "tafkheem-tarqeeq": "#C2543F", "letter-relations": "#3D74B6", "noon-tanween": "#7B5EA7", "meem-sakinah": "#2E8B74", "mushaddadatan": "#B8862B", "madd": "#C77D3A", "qalqalah": "#4A7C2F", };These colours are ours, and yours will be yoursThe palette above is the one this site’s demo uses, chosen only so seven topics can be told apart. It is not the colour scheme of any printed tajwīd muṣḥaf, and it carries no scholarly meaning. Publishers use different schemes, and a reader who learned one will read yours as if it were that one. Ship a legend, say which scheme it follows, and if it follows none, say that.
@quran.ws/tajwidexports a defaultTOPIC_COLORStoo; the same sentence applies to it.Paint by position, without wrapping the text
The text node stays exactly as the edition has it. The CSS Custom Highlight API paints ranges of an existing text node from JavaScript, so the DOM never gains a wrapper and the string is never cut:
const el = document.querySelector("#ayah"); // dir="rtl" lang="ar" el.textContent = text; // the edition's string, untouched const node = el.firstChild; const byTopic = new Map(); for (const s of flat) { const r = new Range(); r.setStart(node, s.start); // BMP text: code-point index === UTF-16 index r.setEnd(node, s.end); (byTopic.get(s.topicId) ?? byTopic.set(s.topicId, []).get(s.topicId)).push(r); } for (const [topic, ranges] of byTopic) { CSS.highlights.set(`tajweed-${topic}`, new Highlight(...ranges)); }::highlight(tajweed-madd) { color: #C77D3A; } ::highlight(tajweed-tafkheem-tarqeeq) { color: #C2543F; } /* … one rule per topic */Switching the layer off is
CSS.highlights.clear(), and the text is what it always was. This is a standard web API, not part of the package; check current browser support before relying on it alone.Where you cannot use it (server rendering, older engines, React Native) compute runs from the positions and emit one element per run:
const marks = new Array(text.length).fill(null); for (const { start, end, topicId } of flat) { for (let i = start; i < end && i < text.length; i++) marks[i] = topicId; } const COMBINING = /[\p{Mn}\p{Me}\u200D]/u; const runs = []; for (let i = 0; i < text.length; i++) { // A span ends on the base letter, so its marks would start the next run // with nothing to sit on. Break only before a base character. const topic = i > 0 && COMBINING.test(text[i]) ? runs[runs.length - 1].topic : marks[i]; const last = runs[runs.length - 1]; if (last && last.topic === topic) last.text += text[i]; else runs.push({ text: text[i], topic }); } // render each run; leave topic === null runs in the default colourThat is only half of it. Each run becomes its own element, and a browser shapes each element on its own — so the letters either side of every colour change are drawn in isolated or final form and the word visibly comes apart, even though the string is exactly right. Bridge each cut with a zero-width joiner:
const ZWJ = "\u200D"; // ا د ذ ر ز و ة and the alef family never join to the letter after them; ء joins // to neither side. A joiner after one of those asks for a medial form that does // not exist, and the shaper answers with a tatweel stub — broken a second way. const NO_JOIN_FORWARD = new Set([..."ءآأؤإاةدذرزوٱ"]); const base = (s, from) => [...s].filter((c) => !COMBINING.test(c)).at(from); for (let i = 0; i + 1 < runs.length; i++) { const before = base(runs[i].text, -1) ?? ""; const after = base(runs[i + 1].text, 0) ?? ""; const joins = before && after && !NO_JOIN_FORWARD.has(before) && after !== "ء" && /\p{Script=Arabic}/u.test(before) && /\p{Script=Arabic}/u.test(after); if (joins) { runs[i].text += ZWJ; runs[i + 1].text = ZWJ + runs[i + 1].text; } }The joiners are invisible and belong to the drawing, not to the text: offsets, counts and anything copied back out come from the original string.
So the run-based path has two hazards and both are silent. Split a letter from its marks and the browser draws them on a dotted circle; split a word between two joining letters and it comes apart. The
Highlightpath above has neither: it ranges over one text node and never cuts it, which is the real reason to prefer it.Either way the Qur’anic string is an input, never something you edit: no tag goes into it, no marked-up copy is stored, and the colouring can be regenerated from the positions at any time.
Use the React component, if you are in React
@quran.ws/tajwid-reactdoes the run-based rendering above and adds the things easy to get wrong: the text is emitted character for character; waqf marks inside a span keep the surrounding colour, because they instruct the reciter rather than belong to the letter; and every coloured stretch carries its ruling inaria-label, so colour is never the only thing carrying meaning.import corpus from "@quran.ws/tajwid-rules"; import annotations from "@quran.ws/tajwid-annotations"; import { unpack } from "@quran.ws/tajwid"; import { TajweedText, TajweedLegend } from "@quran.ws/tajwid-react"; <TajweedText text={edition.ayahs["1:1"]} corpus={corpus} spans={unpack(annotations, corpus, "1:1")} colors={COLOURS} /> <TajweedLegend corpus={corpus} colors={COLOURS} />Pass
spans. Left out, the component runs the matching engine on the text in the browser, which is what a playground needs and a product does not.
Pick an ayah of al-Fātiḥa, hover a coloured stretch and see the corpus name the ruling — real spans over unchanged text, in the demo’s own colours.
What the colouring does not say
Silence is not "no rule applies here". A stretch with no span means no rule in this corpus matched; the corpus goes deep in seven topics and says nothing outside them. The natural madd in the disjoined-letter names, qalqalah at a mid-ayah stop, the rulings of the basmalah and the isti'ādhah are all absent, and the repository lists them. Render the legend, and do not present the colouring as complete.
The corpus labels everything in Arabic only; no topic, ruling or rule carries an English label. If your interface is not in Arabic, keep a mapping keyed on the ids; they are stable slugs.
Related guides
| You want | Use instead |
|---|---|
| Tajwīd for Warsh, Qālūn or any other riwayah | Nothing here — the corpus and the tools are Ḥafṣ only |
| Colour a printed page rather than a string | Elements: style a mark by its name |
| The text of the Qur'an as data | Quran Text — Quran Tajweed ships one reference edition, not a text dataset |
How this data is made
The rules and the engine are a port of the tajwīd system behind tajweed.quranpedia.net, checked against that implementation over the whole muṣḥaf and frozen as a conformance suite. Matching runs on an internal normalised copy and maps every match back to positions in the original, so nothing strips a mark or reorders a code point. The annotation set is produced once over all 6,236 ayahs and published with the digest of the exact text it was measured against.