Percentages in RTL interfaces
Learn why percent signs move in Arabic text, how to format percentages with Intl.NumberFormat, and how to keep one consistent display policy.
By Omran Khleifat, Founder of Ritla
Guide13 min read
A storage administration panel shows the same utilisation value in an Arabic sentence and a standalone metric. The source contains 73.625% in both places, but the Arabic-labelled version displays as %73.625. Someone moves the sign to the beginning of the stored text to compensate. The value now depends on where it is rendered.
Do not decide that the left-hand sign is wrong from its position alone. Percentage presentation depends on the chosen locale and numeric convention as well as the surrounding bidi context. The engineering requirement is a readable, consistent percentage whose underlying value survives changes of layout and language.
Keep three decisions separate: what the numeric value means, how the locale formats it, and which directional boundary contains the complete result. Fixing one by changing another creates defects that look like RTL problems but are actually data or formatting errors.
Reproduce the contextual change before changing the sign#
These two snippets contain the same literal percentage:
<p lang="ar" dir="rtl">استخدام التخزين: 73.625%</p><p dir="rtl">73.625%</p>After the Arabic label, the displayed numeric sequence is %73.625. Alone in the RTL paragraph, it remains 73.625%. The full stop between digit groups keeps the decimal number together; the percent sign is the part whose position changes.
The browser patterns used here were measured in Chromium 151 on September 24, 2026. Firefox and Safari were not included in those measurements, so verify the actual fixtures in your supported browsers.
Arabic text before Western digits changes their treatment during bidi resolution. That affects how the adjacent percent sign joins the directional context. It does not change the stored digits into Arabic-Indic characters or alter the numeric value. The governing rules belong to the Unicode Bidirectional Algorithm.
A plain span around the literal supplies no isolation:
<p lang="ar" dir="rtl"> استخدام التخزين: <span>73.625%</span></p>This still leaves the percentage in the Arabic paragraph's bidi context. Reproducing that difference is useful evidence. It does not yet establish which visible order your product should choose.
Define the convention instead of imposing one sign position everywhere#
An Arabic reader should see a percentage that follows the interface's reviewed numeric convention. The sign can appear to the left of the digits in Arabic presentation. An explicitly LTR technical percentage can instead retain its sign on the right. Neither placement is a complete product specification by itself.
For the storage panel, document the locale, digit system, percentage precision and treatment of the complete formatted value. Have an Arabic reviewer approve that output in a labelled sentence and in the standalone metric. Those are the two contexts that exposed the original inconsistency.
Locale data distinguishes percentage patterns, symbols and numbering systems. It is not a single instruction to reverse an English string. Unicode's number-formatting specification describes those pieces. Use a locale-aware formatter as the starting point rather than inventing their order independently in each component.
If the product deliberately standardizes a technical display on Western digits with a trailing ASCII percent sign, protect that entire LTR value. If it adopts an Arabic locale's percentage pattern, preserve that formatter's complete result and review it in the intended Arabic context. Do not turn the first choice into a rule that all Arabic percentages must be forced LTR.
One policy should cover equivalent values across the storage summary, quota settings and detail panel. Different precision can be justified by different tasks, but it should be explicit. An unexplained change of digit family or sign treatment between those views makes comparison harder.
Pass a ratio to percent formatting#
Suppose the API defines storage utilisation as a ratio. The value 0.73625 represents 73.625%. With style: "percent", the formatter scales the input by one hundred for presentation.
Do not pass 73.625 to a percent formatter unless the intended result really is one hundred times larger. That input represents a different ratio. Moving the sign or changing direction cannot correct a scaling error.
const usageRatio = 0.73625;const technicalPercent = new Intl.NumberFormat("en-GB", { style: "percent", numberingSystem: "latn", minimumFractionDigits: 3, maximumFractionDigits: 3}); console.log(technicalPercent.format(usageRatio)); // "73.625%"The percent scaling is defined by the ECMAScript internationalization specification. Keep the API's unit in its schema and variable names. usageRatio is clearer than a generic field named percentage when callers need to know whether to divide by one hundred.
If an existing API returns percentage units rather than ratios, convert at one documented boundary before calling the formatter. Do not divide sometimes in the component and sometimes in a shared helper. That creates correct-looking percentages for one response shape and mis-scaled percentages for another.
Retain the unformatted number for calculations and thresholds. The visible percentage is a presentation derived from it, not the new source of truth.
Configure the Arabic formatter deliberately#
For this panel, assume the product has chosen Egyptian Arabic, Arabic-Indic digits and one decimal place for utilisation summaries:
const storagePercent = new Intl.NumberFormat("ar-EG", { style: "percent", numberingSystem: "arab", minimumFractionDigits: 1, maximumFractionDigits: 1}); const formattedUsage = storagePercent.format(0.73625);console.log(storagePercent.resolvedOptions());This is an explicit product choice, not a recommendation that every Arabic market use those settings. A product choosing Western digits can request numberingSystem: "latn" with its supported locale. Test the complete result, including its symbol and literals, rather than replacing only the digits afterward.
Set the numbering system when it is part of your design contract. Locale defaults come from runtime locale data and can change. Also set both precision bounds when a fixed number of decimal places matters. MDN's constructor reference documents these options.
Inspect resolvedOptions() in the runtime that renders the interface. It reports the locale, numbering system and precision settings the formatter selected from your request and the runtime's supported locale data. Inspect these settings on both server and client when both contribute visible content.
Do not assemble the result by formatting a decimal number and appending a hard-coded sign. Percent style owns the scale, percent symbol and locale pattern together. Treating them as separate responsibilities is how one panel ends up with a different convention from another.
Preserve every part of the formatted result#
An Intl.NumberFormat result can contain characters that do not look like part of the number: spacing and directional literals, for example. They can affect the string's behavior when inserted into surrounding text.
Inspect the parts without relying on a bidi-rendered console preview. Run this after the Arabic formatter example:
const usageParts = storagePercent.formatToParts(0.73625); console.table(usageParts.map(part => ({ type: part.type, codePoints: Array.from(part.value, character => "U+" + character.codePointAt(0) .toString(16).toUpperCase().padStart(4, "0") ).join(" ")})));In a string inspection using Node 24.16.0 with ICU 78.3, these options produced integer, decimal, fraction and percent-sign parts followed by a literal containing U+061C, ARABIC LETTER MARK. The percent-sign part used U+066A, ARABIC PERCENT SIGN. That observation describes the generated string, not a browser rendering test.
The ASCII sign % and the Arabic sign ٪ are different characters. Do not swap them independently after formatting and assume the result still follows the selected locale's pattern.
If the design needs separate styling for the symbol, use formatToParts(). Keep all parts, including literals, in their original sequence inside one outer value boundary. Filtering the parts down to digits and percentSign can discard the context that the formatter supplied.
For ordinary display, inserting format() directly is simpler. MDN notes that formatted output can include non-breaking spaces or bidi controls. A blanket whitespace or invisible-character cleanup is therefore a change to the formatted output, not harmless housekeeping.
Style the symbol without rebuilding its order#
A metric design may use a smaller percent symbol beside a larger number. That visual hierarchy does not require a separate layout item for the symbol. Use formatter parts to add styling hooks while retaining the complete sequence.
With the storagePercent formatter and storage-usage markup shown in the next section, this is an alternative to assigning the whole formatted string. Run it after both are available:
const usageOutput = document.getElementById("storage-usage");if (!usageOutput) throw new Error("Storage usage element is missing"); usageOutput.replaceChildren( ...storagePercent.formatToParts(0.73625).map(part => { const span = document.createElement("span"); span.dataset.part = part.type; span.textContent = part.value; return span; }));Every part gets a node, including literals. The ordinary inline spans remain inside the one outer isolate. A stylesheet can select the percent-sign part without moving it:
#storage-usage [data-part="percentSign"] { font-size: 0.875em;}Do not sort the parts by type, filter out literals or move the sign with CSS order. The array already represents the formatter's sequence. A smaller font is a styling decision; rearranging the sequence is a formatting decision that this component should not make independently.
Avoid adding a fresh directional boundary around every span. The goal is one formatted percentage, not several isolated fragments. Check the resulting child styles if a shared utility adds dir, an override or a different display mode to numeric fragments.
Test this styled version against the unstyled format() version in the same paragraph. Their joined text should be identical, and their visible reading should follow the same approved convention. That comparison catches a design-system regression without freezing the percentage to a hand-written string that may omit locale literals.
Keep the complete percentage inside one boundary#
The Arabic label should remain part of the RTL interface. The formatted percentage should be one value within it:
<p lang="ar" dir="rtl"> استخدام التخزين: <bdi id="storage-usage"></bdi></p>Run this JavaScript after the markup and the storagePercent declaration above:
function renderStorageUsage(ratio) { if (!Number.isFinite(ratio) || ratio < 0 || ratio > 1) { throw new RangeError("Expected a storage utilisation ratio from zero to one"); } const output = document.getElementById("storage-usage"); if (!output) throw new Error("Storage usage element is missing"); output.textContent = storagePercent.format(ratio);} renderStorageUsage(0.73625);The bdi isolates the complete formatted value from the label. Verify its automatic direction with the formatter's actual output. Directional literals inside that output can contribute strong direction; do not assume that every formatted percentage consists only of digits and punctuation.
This validation is specific to a bounded utilisation ratio. It deliberately rejects negative values and values above one. Other percentage metrics, such as growth, can legitimately exceed those bounds and need a different validation contract.
Do not place the numeric part in one isolate and the sign outside it. Do not use a flex row or reversed DOM order to position the sign visually. Those approaches separate characters that represent one numeric value. W3C's inline bidi guidance explains why phrase boundaries should contain the complete unit.
If your reviewed policy explicitly requires the technical ASCII form, use a known LTR boundary instead:
<p lang="ar" dir="rtl"> استخدام التخزين: <span dir="ltr">73.625%</span></p>That displays 73.625% as one LTR value. It implements the chosen technical convention; it does not establish that the Arabic locale's alternative presentation was defective.
Rounding changes what the percentage claims#
The summary formatter rounds the underlying utilisation to one decimal place. A detail view may legitimately expose more precision, but both views must derive from the same ratio. Do not round to an integer first and then format that rounded result with a decimal place.
Consider a value close to full capacity. Rounding can make a non-full store appear full. Decide whether a summary may show that rounded result, whether it needs more precision near the boundary, or whether accompanying status text should explain the remaining capacity. That decision concerns the product's meaning, not the percent sign's location.
Keep alert comparisons on the raw ratio. A warning threshold should not turn on or off because a display formatter changed its number of decimal places. Likewise, a capacity meter should receive the underlying value, even if its neighbouring label is rounded.
Test exact zero, exact capacity and values immediately below any important display or warning boundary. Include missing data separately. An unavailable measurement is not zero utilisation and should have a translated unavailable state rather than a fabricated numeric percentage.
For ratios computed from counts or byte totals, handle an absent or zero denominator before formatting. A formatter can represent non-finite values, but a storage panel should decide what those values mean before passing them to a numeric display component.
Ranges need explicit endpoints and units#
A storage policy can show an acceptable utilisation interval. The interval has two endpoints and a reading order; it is not one percentage whose digits should be reversed to match the page.
For the deliberately LTR technical convention, a fully labelled Arabic range can isolate each complete endpoint:
<p lang="ar" dir="rtl"> النطاق المقبول: من <span dir="ltr">26.375%</span> إلى <span dir="ltr">73.625%</span>.</p>The Arabic words identify the lower and upper bounds. Each endpoint owns its sign. For the localized policy, replace each endpoint with a complete output from the same configured percent formatter, preserving its literals and the reviewed value boundary.
Do not assemble a range by joining two unisolated numbers with a hyphen and appending one loose percent sign. That leaves both the unit's scope and the bidi grouping implicit. A compact range may be a valid design, but it needs a defined locale pattern and a visual fixture showing which endpoint the reader encounters first.
Tables and chart axes can instead put the unit in a heading and show numbers without a repeated symbol. That is a different presentation contract. Keep the unit visible and unambiguous, and make sure a detached tooltip or copied value does not lose the context that explains its scale.
When testing ranges, verify the actual bounds against the raw ratios as well as their presentation. A plausible sign position can conceal swapped endpoints, double scaling or inconsistent precision. Treat those as data and formatting failures even when the range reads naturally in Arabic.
Percentage changes and percentage points are different metrics#
A utilisation level, a change in that level and a relative growth rate can all appear near a percent sign. They are not interchangeable.
If two utilisation ratios are subtracted, the difference multiplied by one hundred is a change in percentage points. Relative change divides that difference by the earlier ratio before converting to percent. Use labels that tell the reader which comparison is being shown.
Do not feed an already-scaled percentage-point number through the same ratio formatter by accident. Give it a separate formatting path and translated unit label. Also avoid using the bounded utilisation renderer for a signed change, since that renderer correctly rejects negative input for its own contract.
Signed percentages deserve visual fixtures of their own. Format the whole signed value, including the minus or plus sign, rather than adding a sign before an already-formatted percentage. The number formatter can own sign presentation, while the application owns whether a sign is useful for the metric.
Do not predict every signed percentage's visible arrangement from the unsigned example. Inspect the exact formatter output and review the full label in the browser. A storage decrease and a storage level share a domain, but they require different numeric and explanatory contracts.
Use the ratio directly for a capacity meter#
A storage meter represents a scalar level within a known range. Its numeric value should not be parsed from the localized percentage label:
<div lang="ar" dir="rtl"> <label for="archive-capacity">استخدام مساحة الأرشيف</label> <meter id="archive-capacity" min="0" max="1" value="0.73625"></meter></div>The HTML meter element represents a measurement within a known range. A task's ongoing completion progress has different semantics. Choose the element for the measurement, then inspect its appearance and accessible presentation in supported browsers.
In a live component, update the meter and neighbouring formatted label from the same ratio. The attribute value remains a machine-readable number even when the visible percentage uses Arabic-Indic digits. Do not put the localized display string into the meter's numeric value.
A changed digit system or sign placement should have no effect on the measured fill level. Test that invariant while switching languages. Review the accessible label and value as well, but keep the visual check of the percent text separate: correct numeric accessibility data does not prove that its visible sign stayed with the digits.
Keep presentation separate from sorting, inputs and exports#
Store the utilisation ratio as a number and format it at the display boundary. Sorting a utilisation column should compare those numeric ratios, not localized strings containing digits, symbols and directional literals.
A quota editor also needs an explicit unit contract. If its editable value is in percentage units, convert that value to the API's ratio at the defined boundary. Do not parse a display label by removing whichever percent sign happens to be present. Intl.NumberFormat supplies formatting, not a complete localized input parser.
For an export intended for calculations, provide the raw ratio with a clear column definition or a documented numeric percentage unit. For a human-readable export, apply the requested locale's format and test that destination's handling of the resulting characters. An HTML isolate does not travel automatically into a plain-text file.
Keep the same distinction in copy actions. Copying the visible label can be useful for a report; copying a raw numeric value serves a different task. Decide which the action promises. Do not reverse a displayed string and then use that changed string as a machine-readable value.
If a copied percentage looks different after pasting into another application's paragraph, first compare the stored characters and the destination direction. A new visual order does not by itself prove that the source data changed.
Find why equivalent percentages disagree#
When the storage summary and detail panel disagree, inspect the raw ratio first. A scaling or rounding mismatch can coexist with a bidi mismatch, and changing direction will only address the latter.
Then compare each formatter's resolvedOptions() and complete output. Check locale, numbering system, fraction digits and whether one component appends its own sign. Inspect formatToParts() for discarded literals or manually moved symbols.
Finally compare directional context. The same unisolated ASCII string can behave differently after an Arabic label and alone in a metric. Inspect the value wrapper itself, not just the page's dir. A correctly RTL page can still contain a deliberately isolated numeric value.
Server-rendered and client-updated percentages need the same review. Use the same locale and formatting policy on both sides, and record runtime differences when their strings disagree. Explicit options stabilize your intent, but do not make every runtime's locale data identical.
Keep a reviewed screenshot alongside the numeric fixture and formatter settings. DOM text assertions establish characters, scaling and rounding. They cannot establish visual order. If the sign is present in the string but absent on screen, inspect clipping and the available width before treating it as reordering.
Test one percentage policy in every display context#
Use the storage ratio fixture across the summary sentence, standalone metric, capacity meter label and detail panel. Keep the underlying ratio identical while changing only the context. Then repeat with zero, full capacity, a small nonzero value and the boundaries where rounding or alerts matter.
| Test layer | What to verify |
|---|---|
| Data | Ratio versus percentage units and valid measurement bounds |
| Formatting | Locale, digit system, precision, sign and retained literals |
| Rendering | Complete value remains readable beside Arabic and on its own |
| Interaction | Updates and language switches retain the wrapper and policy |
| Meaning | Rounded label, capacity meter and warning state agree with the raw metric |
Include signed changes as separate fixtures if the panel offers them. Review missing measurements independently from zero. Run visual checks in supported browsers and at narrow widths, including the longest formatted label allowed by your precision policy.
Have an Arabic reviewer approve the chosen convention once in representative contexts, then use those fixtures to catch inconsistent implementations. Do not ask a test to require the same physical sign position for every locale and metric. Require the approved policy and the correct numeric meaning.
A free Ritla scan can report : Percent/unit ordering inconsistencies. Ratio scaling, rounding thresholds and preservation of formatter literals still need the targeted checks above. Continue with bidi isolation in HTML for value boundaries or how to test RTL across breakpoints when a percentage fails only in a compact layout.