Display a complete Muṣḥaf page
Put a printed page on screen exactly as the muṣḥaf typesets it, and address every ayah on it. The page is one SVG file that already carries a transparent region per verse; you fetch it, put it in the document, and listen. For text you lay out yourself, see Display Qur'an text; for a single word on the page, this guide gets you the page and Make words clickable takes over.
By the end, a page of the Madinah muṣḥaf sits in a container of your choosing, scaled to fit, clicking any verse gives you its reference, and a function goes the other way, from a reference to the page it is printed on.
Fetch the page and put it in the document
There is no package to install. The pages are static files in the repository, served by
raw.githubusercontent.comwith open CORS, so a plainfetchworks from a browser. Folders aremushafs/<riwayah>/<publisher>/, pages are zero-padded to three digits.const RAW = "https://raw.githubusercontent.com/quran-ws/quran-svg/main/mushafs"; const pad = (n: number) => String(n).padStart(3, "0"); async function mountPage(host: HTMLElement, mushaf: string, page: number) { const res = await fetch(`${RAW}/${mushaf}/svg/${pad(page)}.svg`); if (!res.ok) throw new Error(String(res.status)); host.innerHTML = await res.text(); const root = host.querySelector("svg")!; root.removeAttribute("width"); root.removeAttribute("height"); root.setAttribute("style", "width:100%;height:auto;display:block"); root.querySelectorAll("#content, #ayah_markers").forEach((g) => { (g as SVGGElement).style.pointerEvents = "none"; }); return root; } const root = await mountPage(document.querySelector("#page")!, "hafs/kfqc", 42);Inline (
innerHTML, not animgtag) is the mode that gives you the ayah layer. An image element renders the same pixels and gives you nothing to address.Five muṣḥafs are vectorised:
hafs/kfqc,warsh/kfqc,qalon/kfqc,douri/kfqcandshubah/kfqc. Each is 604 pages. Thepointer-eventsline matters for four of them: in Ḥafṣ the ayah polygons are the last elements in the file and sit on top of the glyphs, but in the other four they come first, so without it a tap on a letter lands on the letter and never reaches the polygon.Fit the artwork to its ink
On most pages the declared
viewBoxis tight around the drawing. On the opening spread — pages 1 and 2 — it is not: page 1 of Ḥafṣ declares-53.3109 -198.4777 345 550and the drawing fills about a third of that. Measuring the rendered content once and writing the result back makes every page fill its container:requestAnimationFrame(() => { try { const b = (root as SVGGraphicsElement).getBBox(); if (b.width > 0 && b.height > 0) { const p = Math.max(b.width, b.height) * 0.04; root.setAttribute("viewBox", `${b.x - p} ${b.y - p} ${b.width + p * 2} ${b.height + p * 2}`); } } catch { /* keep the declared viewBox */ } });getBBox()needs the element to be laid out, so it waits a frame.Address every ayah on the page
Each verse on the page is one
path.ayahPolygon, and it carries its identity as attributes. To list what is on the page, read them off:const onPage = Array.from(root.querySelectorAll<SVGPathElement>(".ayahPolygon")).map((p) => ({ surah: Number(p.getAttribute("surah")), ayah: Number(p.getAttribute("ayah")), el: p, })); // page 1 of hafs/kfqc → [{ surah: 1, ayah: 1 }, … { surah: 1, ayah: 7 }]The same list is published beside the page as
json/042.json, one entry per ayah withsurahNumber,ayahNumberand the polygon — useful when you need to know what is on a page without loading its artwork.Keep
surahandayah. The element also carriesid="verse-N"; do not parse it — it is numbered globally across all five muṣḥafs, so the same verse has a differentidin each.Turn a tap into a reference
for (const { surah, ayah, el } of onPage) { el.style.cursor = "pointer"; el.addEventListener("click", () => open(surah, ayah)); // your tafsīr, audio, bookmark… }fill: none silently stops the tapsThe polygons ship as
fill-opacity="0". If you hide the layer with.ayahPolygon { fill: none }instead, it looks identical, but under SVG’s defaultpointer-events: visiblePainteda shape with no fill is not a hit target, so every tap falls through to the page behind. Hide with opacity; keep the fill.Show the selection
Raise the polygon’s opacity. Nothing else on the page needs to change.
const select = (el: SVGPathElement, on: boolean) => { el.setAttribute("fill", "#15705D"); el.setAttribute("fill-opacity", on ? "0.35" : "0"); };A polygon is usually several rectangles: an ayah starts and ends mid-line, so its region is a stack of line-strips, and the highlight follows the verse where a bounding box would not. Highlight an ayah covers the same operation across every block.
Go the other way: which page is 2:255 on?
json/surah.jsonin each muṣḥaf lists all 114 surahs with the page each one starts on, and the per-page JSON files are tiny — the Ḥafṣ median is 1,679 bytes. A bounded binary search over them settles any reference in at most six requests, with no index to build:interface PolyEntry { surahNumber: number; ayahNumber: number; x: number; y: number; polygon: string } interface SurahEntry { number: number; pageNumber: number; ayahCount: number; nameEnglish: string } const surahs: SurahEntry[] = await fetch(`${RAW}/hafs/kfqc/json/surah.json`).then((r) => r.json()); async function pageOf(surah: number, ayah: number) { let lo = surahs[surah - 1].pageNumber; let hi = surah < 114 ? surahs[surah].pageNumber : 604; while (lo <= hi) { const mid = Math.floor((lo + hi) / 2); const entries: PolyEntry[] = await fetch(`${RAW}/hafs/kfqc/json/${pad(mid)}.json`).then((r) => r.json()); if (entries.some((e) => e.surahNumber === surah && e.ayahNumber === ayah)) return mid; const below = entries.some((e) => e.surahNumber < surah || (e.surahNumber === surah && e.ayahNumber < ayah)); if (below) lo = mid + 1; else hi = mid - 1; } return null; } await pageOf(2, 255); // 42Then
mountPage(host, "hafs/kfqc", 42)andselect()the polygon whose attributes match.Get the words of the ayah that was tapped
The page is glyph outlines. There is no text inside the file, so the tap gives you a reference and nothing more. Quran Text turns the reference into words, and because
hafs/kfqcfollows the same numbering as the bundled Ḥafṣ edition, the two agree ayah for ayah:import { Mushaf } from "quran-text"; const m = await Mushaf.hafs(); function open(surah, ayah) { const a = m.ayah(surah, ayah); caption.textContent = a.render({ marks: true, ayahMarks: true }); }For
warsh/kfqc, load the Warsh edition instead; its numbering matches that muṣḥaf, and Support multiple riwayat explains why the two cannot be mixed.
Click any ayah on any of the 604 pages, in five riwayat, against the files fetched live from the repository.
All five muṣḥafs have 604 pages, so page numbers look interchangeable. They are not: each
riwayah divides its verses differently and is typeset separately, so page
42 of Ḥafṣ opens at 2:253 and page 42 of Warsh opens at 2:251. Persist the muṣḥaf key beside every
page number you store, and re-resolve the page with pageOf() when the reader changes reading.
When this is the wrong block
- You need to address a word or a diacritic, not an ayah. Use Quran SVG Elements — it ships whole displayable pages as well, split down to the word and the mark, but only for the muṣḥafs that have been split.
- The target is a phone. A page is a few hundred kilobytes of path data, and scrolling a muṣḥaf of them in a mobile webview is where SVG stops performing. That is the case Quran Engine exists for.
- You are typesetting your own page and want the ornaments — the surah header band, the ayah roundel, the border. Those are Quran Assets.
How this data is made
Pages start as the publisher's own vector edition, so the letter shapes are the printed ones rather than a trace of a scan. The ayah layer is derived from each page's own ayah medallions and audited against the ink: every polygon in a muṣḥaf accounts for exactly one of its ayat, 6,236 in the 604 Ḥafṣ pages.