Case study · 09 / 11
Building right-to-left in from the first line
A case study — a bilingual EN/AR intranet
Summary
| | | |---|---| | **Problem** | A bilingual English/Arabic SharePoint intranet where Arabic is a first-class language, not a translation layer bolted onto an English design. | | **Constraint** | RTL retrofitted is RTL done badly. Every physical `left`/`right` written on day one is a bug on day ninety. | | **Solution** | CSS logical properties exclusively, a two-layer token system feeding Fluent UI v9, locale-aware formatting driven by SharePoint's own page culture, and provisioning that keeps one list per content type across both languages. | | **Outcome** | Nine section web parts plus site chrome, thirteen brand presets validated against WCAG AA, provisioned and assembled from inside the solution. | | **Role** | Architecture, theme and i18n foundation, all web parts, chrome extension, provisioning, accessibility tooling. | | **Stack** | SPFx 1.21.1 · React 17 · TypeScript 5.3 · Fluent UI v9 · PnPjs v4 · Node 22 |
1. The problem
Bilingual intranets are usually built English-first and translated later. The result is recognisable: Arabic text in a layout that still reads left-to-right, icons pointing the wrong way, dates in Western numerals, and a language switch that reloads into a half-translated page.
The requirement here was Arabic as an equal language — full mirroring, native numerals, correct typography — on a modern SPFx intranet with a rebrandable visual identity.
2. The constraint
RTL cannot be added afterwards. Every margin-left, padding-right, text-align: left and border-left written during the English build is a bug once the page mirrors. There are hundreds of them in a normal codebase, they are invisible until you flip direction, and fixing them late means touching every stylesheet in the solution.
The only way this works is to make it impossible to write the wrong thing from the first component onward.
3. Architecture
Three foundations, built before any section web part existed:
The theme layer. Brand palettes → a Fluent v9 16-step ramp builder → a theme factory → a CSS-variable emitter → a provider that applies all of it plus text direction, scoped to each web part's own root.
The i18n layer. A shared string contract with English and Arabic bundles, Arabic-Indic numeral and date formatting, an LTR-isolation helper, and a locale context derived from the page culture.
The RTL layer. Direction resolution from culture, icon mirroring, gradient flipping.
Only then: nine full-width section web parts — welcome, hero, announcements, news, events, people moments, documents and leadership, applications, quick links — plus the site header, hub navigation and footer as a single Application Customizer, and an admin web part that provisions everything.
4. The interesting decisions
4.1 Logical properties, with no exceptions
Every stylesheet uses margin-inline, padding-inline, inset-inline, border-inline and text-align: start/end. No physical left/right anywhere. The browser then mirrors the entire layout from a single dir="rtl" on the web part root.
The rest of mirroring is handled the same way, deliberately rather than incidentally:
- Directional icons mirror via
scaleX(-1)— an arrow that means "next" must point the other way in Arabic; an icon that means "download" must not. - Gradients flip by angle, because a gradient with a fixed direction is a physical property that logical CSS does not cover.
- Technical tokens stay LTR. URLs, phone numbers, filenames, product names like Teams or Outlook, and
.docxextensions are wrapped in an isolation helper. Without it, a URL inside an Arabic sentence renders with its parts in the wrong order — technically correct bidi behaviour, visually broken. - The Arabic UI swaps typeface to a face designed for Arabic, with a separate display face for the wordmark. Latin fonts rendering Arabic is the typographic equivalent of stretching a logo.
4.2 Two token layers, one swappable
The design system is split so that brand and structure change independently.
Layer one is brand-neutral: warm-grey neutrals, a toned portal ground, 0px radius, 2px rules, ink-tinted shadows, the type scale, and the fixed announcement priority colours. This layer never changes between clients.
Layer two is the brand accent: thirteen presets, resolved centrally from a theme token rather than offered as a runtime picker.
That last point is a deliberate restriction. A runtime colour picker sounds like a feature and produces intranets where each page has drifted to a different accent. Resolving the palette centrally means the brand is a deployment decision, not a per-author one.
Fluent v9 and CSS variables together, not either/or. The theme factory builds a Fluent brand theme so built-in Fluent controls pick up the accent and lose their rounded corners; the variable emitter emits the same accent as custom properties for the components that are not Fluent. The provider applies both, plus direction, scoped to each web part's own root — so full-width parts stay self-contained and independently themeable rather than fighting over a global stylesheet.
4.3 No language switch — page variants instead
The obvious implementation of a language toggle is client-side: keep both string bundles, swap them in state, persist the choice.
This solution does not do that. Each web part reads the current page culture from the SPFx context, and the header's EN/العربية control navigates between published English and Arabic page variants using SharePoint's own multilingual pages feature.
The reason is that a client-side switch only translates the parts of the page your code owns. Search results, the suite bar, list views, Office previews and any out-of-the-box web part on the page stay in the site's language. The result is a page half in each language — worse than not offering the switch. Going with the platform's model means the whole page, including everything Microsoft renders, is in the right language.
4.4 One list per content type, not one per language
The provisioning model gives every content list a Language column and keeps one list per content type across both languages, rather than forking a parallel Arabic list.
Forked lists are the intuitive design and they fail slowly: the two drift, an item is updated in one language and not the other, permissions are applied to one and forgotten on the other, and every rollup web part has to query twice and merge. A Language column keeps one source of truth and turns language into a filter.
4.5 Provisioning and assembly from inside the solution
An admin web part provisions the columns, lists and content types, applies versioning and content approval where content publishes externally, and seeds bilingual sample content — with a live log showing each object as created, exists or updated. It is idempotent, so it doubles as a repair and upgrade action.
Two things it does that are less common:
- It registers the chrome extension itself. Rather than a manual
elements.xmldeployment step, the provisioning service registers the Application Customizer's UserCustomAction on the web, idempotently. - It assembles the home page. An "Assemble home page" action places the eight section web parts into full-width sections on the site home page programmatically, in order — replacing a fifteen-step manual setup that would otherwise have to be repeated identically on every deployment and would be wrong somewhere every time.
4.6 Accessibility as a test, not an intention
A contrast validator checks all thirteen brand presets against the key text and UI colour pairs for WCAG AA, runnable as an npm script. Thirteen presets is exactly the situation where eyeballing fails: the default gets checked, the other twelve get assumed.
Alongside it, a written test matrix covering WCAG 2.2 AA × English/Arabic × four breakpoints × keyboard navigation, per web part — so the coverage is a grid someone can walk rather than a claim.
4.7 Naming a refactor before it is needed
The shared data service memoizes one PnPjs client per SPFx context. The README flags it explicitly as the promotion point to an SPFx Library Component — the supported way to guarantee a single runtime instance shared across separate web part bundles — and notes that callers will not have to change when it happens.
Separate SPFx bundles each get their own copy of an imported module, so "one client" is true within a bundle and false across them. The current approach is correct for now; the note means the next person does not have to rediscover why it will eventually need to move.
5. Outcome
Nine section web parts, a chrome extension carrying header, hub navigation and footer, an admin provisioning web part, thirteen validated brand presets, and a documented multilingual content workflow including the translation → review → approve → publish flow.
Everything mirrors fully in Arabic. The only remaining out-of-the-box surface is the Microsoft 365 suite bar, which is tenant chrome rather than part of the build.
6. What I'd take from this
Some qualities have to be architectural. RTL, accessibility and theming cannot be retrofitted at acceptable cost. Building the token, i18n and direction layers before the first section web part meant that every component after them inherited all three for free.
Restrict the things that will drift. No runtime palette picker, no client-side language switch, one list per content type. Each of those removes a capability that sounds useful and prevents a mess that is expensive to clean up.
Use the platform's model for the platform's problems. SharePoint has a multilingual pages feature. Building a better language switch on top of it would have produced a worse page.