Dev.to WebDev 🛠 Dev 👁 1 📖 7 min read

Building an RTL-first Arabic dashboard in Vue: 6 problems I didn't see coming

Most "RTL support" I've seen in the wild is an English layout with dir="rtl" slapped on top. It mostly works — until a price shows up as 220$, a chart's time axis runs the wrong way, or a sentence says "11 طلب" when any

Building an RTL-first Arabic dashboard in Vue: 6 problems I didn't see coming

Most "RTL support" I've seen in the wild is an English layout with dir="rtl" slapped on top. It mostly works — until a price shows up as 220$, a chart's time axis runs the wrong way, or a sentence says "11 طلب" when any Arabic speaker would write "11 طلباً".

I wanted to build the opposite: an admin dashboard that is Arabic-first, designed right-to-left from the ground up. So I built a full sales & operations dashboard for a fictional e-commerce store — 11 pages, three user roles, inventory, coupons, reviews, reports — with Vue 3, Vite and Chart.js, no UI framework.

👉 Live demo: anasaboali.github.io/arabic-dashboard (demo accounts for every role are on the login page)
💻 Code: github.com/anasAboAli/arabic-dashboard

Overview page of the Arabic sales dashboard

Here are the six problems that took the most thought — and how I solved each one.

1. Numbers and dates need one source of truth

Arabic users don't all write numbers the same way. Some prefer Latin digits (1,234), others Arabic-Indic (١٬٢٣٤). Some businesses think in the Gregorian calendar, others in Hijri. I wanted both to be a single setting that updates the whole UI — tables, KPI cards, chart axes, tooltips, everything.

The trick is the Unicode extension keys in Intl locale tags:

  • -u-nu-latn / -u-nu-arab → the numbering system
  • -u-ca-islamic-umalqura → the Umm al-Qura Hijri calendar

Every formatter in the app goes through one module. Creating Intl objects is relatively expensive, so they're cached per settings combination:

// utils/format.js
const cache = new Map()

function formatters() {
  const key = `${settings.numerals}|${settings.calendar}`
  if (!cache.has(key)) {
    const nu = settings.numerals                        // 'latn' | 'arab'
    const num = `ar-u-nu-${nu}`
    const date = `ar-u-ca-${settings.calendar}-nu-${nu}` // 'gregory' | 'islamic-umalqura'
    cache.set(key, {
      number: new Intl.NumberFormat(num),
      date: new Intl.DateTimeFormat(date, { day: 'numeric', month: 'short', year: 'numeric' }),
      // ...percent, compact, relative time, etc.
    })
  }
  return cache.get(key)
}

export const fmtNumber = (n) => formatters().number.format(Math.round(n))
export const fmtDate = (d) => formatters().date.format(d)

Because settings is a Vue reactive() object and every formatter reads it when called, Vue tracks the dependency automatically. Change the setting → every component that formatted something re-renders. No event bus, no manual refresh.

Chart.js is the exception — it formats ticks inside its own canvas, outside Vue's reactivity — so the chart wrapper watches those settings and redraws.

2. The $ that kept jumping to the wrong side

This one surprised me. Standalone amounts in table cells looked fine. But inside an Arabic sentence, like a notification:

كريم المصري · عطر عود × 2 · $220

…the browser rendered it as 220$.

The culprit is the Unicode Bidirectional Algorithm. $ is a "weak" character — it has no direction of its own and gets resolved from its surroundings. Surrounded by Arabic (strong right-to-left) text, it ends up on the wrong side of the number.

I didn't trust my eyes on screenshots, so I measured it: a Range around the $ character and one around the first digit, compare their getBoundingClientRect().left. Sure enough — the dollar sign was to the right of the digits.

The fix is to isolate the amount as its own left-to-right run, using the invisible isolate characters LRI (U+2066) and PDI (U+2069):

const withSymbol = (text) => {
  const c = currency()
  // Arabic currencies go after the number; prefix symbols get isolated
  return c.suffix ? `${text} ${c.symbol}` : `⁦${c.symbol}${text}⁩`
}

Signed percentages had the same issue (+5.3% rendered as 5.3%+). For those I used FSI (U+2068, first strong isolate), which picks the direction from the content — so it stays correct whether digits are Latin or Arabic-Indic:

export const fmtSignedPct = (n, digits = 1) =>
  `⁨${n > 0 ? '+' : n < 0 ? '−' : ''}${fmtPct(Math.abs(n), digits)}⁩`

Isolates (unlike the older embedding marks) don't leak into the surrounding text, which makes them safe to use everywhere a formatted value might land.

3. Arabic plurals aren't "N + word"

English has two forms: 1 order, 2 orders. Arabic counted nouns change with the number:

Count Form
1 طلب واحد
2 طلبان (dual — no number at all)
3–10 3 طلبات (plural)
11+ 11 طلباً (singular, accusative)

Intl.PluralRules('ar') exists and gives you the category (zero, one, two, few, many, other), but you still have to supply the words — and for UI copy, the rule I actually needed is simpler than CLDR's full set. So I wrote a tiny helper that takes the four forms:

export const fmtCount = (n, [one, two, few, many]) => {
  if (n === 1) return `${one} واحد`.replace('ة واحد', 'ة واحدة') // feminine agreement
  if (n === 2) return two
  if (n === 0) return `${fmtNumber(0)} ${one}`
  return `${fmtNumber(n)} ${n >= 3 && n <= 10 ? few : many}`
}

export const ORDER_WORDS = ['طلب', 'طلبان', 'طلبات', 'طلباً']
export const UNIT_WORDS = ['وحدة', 'وحدتان', 'وحدات', 'وحدة']

fmtCount(11, ORDER_WORDS) // "11 طلباً"

It's a small thing, but native speakers notice it immediately — it's the difference between "translated" and "written in Arabic".

4. Mirroring charts is more than dir="rtl"

dir="rtl" on the page does nothing for a <canvas>. Chart.js draws what you tell it to. For an Arabic reader:

  • Time flows right to left → reverse the x-axis.
  • The value axis belongs on the right (where the eye starts).
  • Horizontal bars grow from the right.
  • Tooltips need RTL text direction.
scales: {
  x: { reverse: true, /* ... */ },
  y: { position: 'right', beginAtZero: true, /* ... */ },
},
plugins: {
  tooltip: { rtl: true, textDirection: 'rtl', /* ... */ },
  legend: { display: false }, // see below
},

The legend was the one thing I couldn't get to look right inside Chart.js — with line-style markers in RTL mode, the markers and labels didn't lay out cleanly. Rather than fight it, I hid the canvas legend and rendered a tiny HTML legend above the chart. It's accessible, it follows the page direction for free, and it's styleable with the same CSS as the rest of the UI.

Reports page: day × hour heat map and city comparison

Colours come from CSS custom properties, read with getComputedStyle when the chart is built. Switching between light and dark mode simply rebuilds the chart config, so charts follow the theme like the rest of the page.

5. The Hijri calendar isn't just for display

Displaying Hijri dates is one line with Intl. But the dashboard has seasonal coupon campaigns, and one of them is Ramadan — whose Gregorian dates move ~11 days every year. Hard-coding them would break next year.

So the campaign window is computed: walk back from today, ask Intl which Hijri month each day falls in, and find the last run of month 9:

function lastRamadan() {
  const fmt = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura', { month: 'numeric' })
  let end = null
  for (let i = 1; i < 400; i++) {
    const ts = TODAY.getTime() - i * DAY
    const inRamadan = fmt.format(new Date(ts)) === '9'
    if (inRamadan && end === null) end = endOf(ts)
    if (!inRamadan && end !== null) return { start: ts + DAY, end }
  }
  return null
}

Using the en locale here is deliberate: it guarantees the month comes back as the plain string '9', regardless of the user's digit setting.

6. The mobile bug that wasn't about RTL at all

After I made table cells white-space: nowrap, the whole layout suddenly overflowed on phones — the page was wider than the screen, cards were cut off on one side.

The cause: in CSS Grid, 1fr actually means minmax(auto, 1fr). The auto minimum lets a column grow to fit its widest content — and a non-wrapping table is very wide. That column pushed the entire page past the viewport.

/* before: the column grows to fit the widest table */
.layout { grid-template-columns: 232px 1fr; }

/* after: the column can shrink; wide tables scroll inside their card */
.layout { grid-template-columns: 232px minmax(0, 1fr); }

Same fix on every nested grid. On small screens, wide tables then turn into labelled cards with a bit of CSS (data-label + ::before), and the sidebar becomes a tab bar with a "More" menu.

Mobile view: orders turn into labelled cards

A few smaller things worth mentioning

  • Use CSS logical properties (margin-inline-start, inset-inline-end, border-inline-start) instead of left/right. They flip automatically, and most "RTL bugs" in CSS disappear.
  • Respect prefers-reduced-motion everywhere — including Chart.js animations and JS-driven number counters, not just CSS transitions.
  • Be honest about demo auth. The dashboard has login and three roles (admin, sales, warehouse) driven by one permission map — but it's front-end only. It shapes what each role sees; real protection needs a server checking every request. I say so on the login page and in the README.

Wrapping up

The biggest lesson: RTL isn't a feature you add at the end. Direction touches text, numbers, dates, grammar, charts and layout — and the cleanest way to handle it is to route all of them through a few small, central pieces (one formatting module, one set of CSS tokens, one chart wrapper) instead of patching each page.

If you're building for Arabic (or Hebrew, Persian, Urdu) users, I hope some of this saves you a few hours. And if you spot something I got wrong — especially in the Arabic — I'd genuinely love to hear it in the comments.

🔗 Try it: anasaboali.github.io/arabic-dashboard
⭐ Code: github.com/anasAboAli/arabic-dashboard

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.