Split Arabic letters respecting their shape
Unlike Latin characters Arabic is inherently cursive. A single letter can have up to four different shapes depending on where it sits in a word: Isolated, Initial, Medial, or Final. The problem is that if words are splitted every character is assumed to be isolated because it looses the context of the surrounding letters, which makes the text look broken.
This helper function solves that without the need of any extra work:
function splitArabicText(target, config = {}) {
let type = config.type || "chars,words,lines",
splitWords = type.includes("words"),
splitLines = type.includes("lines"),
userOnSplit = config.onSplit;
return new SplitText(target, {
...config,
type: "words" + (splitLines ? ",lines" : ""),
onSplit(self) {
let ZWJ = "\u200D",
nonJoinAfter = /[اأإآ دذرزوؤءة]/,
diacritics = /[\u064B-\u065F\u0670]/;
self.chars.length = 0;
self.words.forEach((wordEl) => {
let segs = [],
chars = Array.from(wordEl.textContent),
i = 0;
while (i < chars.length) {
let c = chars[i];
if (c === "\u0644" && i + 1 < chars.length) {
let j = i + 1,
d = "";
while (j < chars.length && diacritics.test(chars[j])) d += chars[j++];
if (j < chars.length && /[\u0622\u0623\u0625\u0627]/.test(chars[j])) {
c += d + chars[j];
i = j;
}
}
diacritics.test(c) && segs.length ? (segs[segs.length - 1] += c) : segs.push(c);
i++;
}
wordEl.textContent = "";
segs.forEach((seg, si) => {
let connects = (s) => !nonJoinAfter.test(s.replace(diacritics, "").slice(-1)),
s = seg;
if (si && connects(segs[si - 1])) s = ZWJ + s;
if (si < segs.length - 1 && connects(seg)) s += ZWJ;
let el = document.createElement(config.tag || "div");
config.tag === "span" || (el.style.display = "inline-block");
config.charsClass && (el.className = config.charsClass);
el.textContent = s;
wordEl.appendChild(el);
self.chars.push(el);
});
});
console.log("chars", self.chars);
if (!splitWords) {
self.words.forEach((w) => w.replaceWith(...w.childNodes));
self.words.length = 0;
}
userOnSplit?.(self);
},
});
}
Usage
const split = splitArabicText("#my-target", {
type: "chars",
charsClass: "prox-char",
});
Simple Demo
<div class="container">
<div class="header">
<h1>Arabic Split Helper</h1>
<p>Ligatures and Cursive connections</p>
</div>
<section class="test-box">
<p class="label">Method 1: Standard SplitText (Incorrect ligatures)</p>
<p id="method-standard" class="arabic">مرحباً بالعالم</p>
</section>
<section class="test-box">
<p class="label">Method 2: splitArabicText() helper</p>
<p id="method-hook" class="arabic">مرحباً بالعالم</p>
</section>
<button id="replay">Run Stagger Animation</button>
</div>@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@900&display=swap');
.container { max-width: 900px; margin: 0 auto; display: flex; align-items: center; justify-content: center; flex-direction:column}
.header { margin: 40px; text-align: center}
.test-box {
background: #111;
padding: 40px;
margin-bottom: 20px;
border-radius: 8px;
}
.label {
font-size: 10px;
text-transform: uppercase;
opacity: 0.5;
margin-bottom: 20px;
display: block;
}
.arabic {
font-family: 'Cairo', sans-serif;
font-size: 10vw;
direction: rtl;
white-space: nowrap;
}
/* The "Behind the text" Boxes */
.arabic div, .prox-char {
display: inline-block !important;
position: relative;
line-height: 2;
/* No margins or padding that would separate the letters */
margin: 0;
padding: 0;
border: 0.5px dashed var(--color-surface-white);
background-image: var(--color-text-gradient);
background-size: 100%;
-webkit-background-clip: text;
-moz-background-clip: text;
-webkit-text-fill-color: transparent;
-moz-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}
button {
display: inline-block;
margin: 0 auto;
}gsap.registerPlugin(SplitText);
console.clear();
// --- 1. Standard ---
const split1 = new SplitText("#method-standard", { type: "chars" });
// --- 2. PrepareText ---
const split2 = splitArabicText("#method-hook", { type: "chars", charsClass: "prox-char" });
function splitArabicText(target, config = {}) {
let type = config.type || "chars,words,lines",
splitWords = type.includes("words"),
splitLines = type.includes("lines"),
userOnSplit = config.onSplit;
return new SplitText(target, {
...config,
type: "words" + (splitLines ? ",lines" : ""),
onSplit(self) {
let ZWJ = "\u200D",
nonJoinAfter = /[اأإآدذرزوؤءة]/,
diacritics = /[\u064B-\u065F\u0670]/;
self.chars.length = 0;
self.words.forEach((wordEl) => {
let segs = [],
chars = Array.from(wordEl.textContent),
i = 0;
while (i < chars.length) {
let c = chars[i];
if (c === "\u0644" && i + 1 < chars.length) {
let j = i + 1, d = "";
while (j < chars.length && diacritics.test(chars[j])) d += chars[j++];
if (j < chars.length && /[\u0622\u0623\u0625\u0627]/.test(chars[j])) { c += d + chars[j]; i = j; }
}
diacritics.test(c) && segs.length ? segs[segs.length - 1] += c : segs.push(c);
i++;
}
wordEl.textContent = "";
segs.forEach((seg, si) => {
let connects = (s) => !nonJoinAfter.test(s.replace(diacritics, "").slice(-1)),
s = seg;
if (si && connects(segs[si - 1])) s = ZWJ + s;
if (si < segs.length - 1 && connects(seg)) s += ZWJ;
let el = document.createElement(config.tag || "div");
config.tag === "span" || (el.style.display = "inline-block");
config.charsClass && (el.className = config.charsClass);
el.textContent = s;
wordEl.appendChild(el);
self.chars.push(el);
});
});
console.log("chars", self.chars);
if (!splitWords) {
self.words.forEach((w) => w.replaceWith(...w.childNodes));
self.words.length = 0;
}
userOnSplit?.(self);
}
});
}
const allTargets = [
...split1.chars,
...split2.chars,
];
let tl = gsap.timeline()
.set(allTargets, { y: 60, opacity: 0, rotateX: -90 })
.to(allTargets, {
y: 0,
opacity: 1,
rotateX: 0,
stagger: 0.03,
duration: 0.8,
ease: "back.out(2)",
});
replay.addEventListener("click", (e) => {
tl.play(0)
})