Right to Left

NEW
One dir attribute and the whole page mirrors: the sides are logical, the arrow keys follow the reading order, and the components come with it. No second stylesheet, and nothing re-renders.

The direction is one attribute, and the browser does the rest

dir is an attribute, so it goes in props — on the <html> element for a whole locale, or on any subtree for one panel of it. Everything below is inside a single wrapper carrying it, and the switch on this page changes nothing else: no state reaches a style, no class is rebuilt, no component is told. The logical props — ps/pe, ms/me, bs/be, insetStart/insetEnd, borderRadiusStart/borderRadiusEnd — are resolved by the browser from that one attribute, which is why a translation costs no CSS.
every demo on this page is inside it
A card that reads both ways
Your order is on its way — مرحبا
JSX
import { ArrowRight } from 'lucide-react';

<Flex props={{ dir }} ai="center" gap={3} ps={4} pe={3} py={3} bs={4} borderStyle="solid" borderColor="indigo-500" borderRadiusEnd={2}>
  <Box flex1 fontSize={14}>
    Your order is on its way
  </Box>
  {/* The one thing a logical property cannot say: which way the arrow points. */}
  <Box color="indigo-500" rtl={{ flip: 'xAxis' }}>
    <Icon size={5}>
      <ArrowRight />
    </Icon>
  </Box>
</Flex>

The form controls mirror, including the parts the page does not draw

A field's icon, the room made for it, the gap between a checkbox and its label: all of it is declared on the inline axis, so all of it swaps. The interesting one is the switch. Its thumb is a ::before that travels, and travel is a translateX — the one thing no logical property can express. So the built-in style says the distance once and rtl says it again with the other sign, which is what the two direction keys are for.
Fields, boxes and switches
Currency
JSX
import { Search } from 'lucide-react';

<Flex props={{ dir }} d="column" gap={4}>
  <Box position="relative" width={70}>
    {/* Both the icon and the room made for it are on the inline axis, so both swap. */}
    <Flex position="absolute" insetStart={3} top="1/2" translateY="-1/2" color="slate-400" pointerEvents="none">
      <Icon size={4}>
        <Search />
      </Icon>
    </Flex>
    <Textbox placeholder="Search invoices" ps={10} width="fit" />
  </Box>
  <Checkbox label="Email me a copy" defaultChecked />
  <Switch label="Send reminders" defaultChecked />
  <RadioGroup label="Currency" defaultValue="usd" orientation="horizontal">
    <RadioGroup.Item value="usd" label="USD" />
    <RadioGroup.Item value="egp" label="EGP" />
    <RadioGroup.Item value="ils" label="ILS" />
  </RadioGroup>
</Flex>

The arrow keys follow the reading order, not the screen

This is the half no prop can fix. APG says the arrows move in the reading order, so in a right-to-left list ArrowLeft goes to the next item and ArrowRight goes back. Nothing in the markup says which: useRovingFocus asks the element for its resolved direction the moment a sideways arrow arrives, so a dir anywhere above it is enough and a vertical list pays nothing. The radio group above and the grid below both go through it. Tab, Home and End never flip — Home is the first item in the reading order either way.
Arrow through it in both directions
JSX
// Inside useRovingFocus: the direction is a state of the element, so it is read, not configured.
const forward = (event.key === 'ArrowRight') !== isRtl(event.currentTarget);

move(step(activeIndex, forward ? 1 : -1, count, loop, isDisabled), 'keyboard');

A popup carries the direction out of the tree with it

Overlay renders into a portal container that is a child of the body, so nothing of the direction the layer was declared in reaches it by inheritance — a tooltip on an Arabic paragraph would have come out reading left to right. So the layer measures the direction it was declared in and writes it back on as dir. Everything built on it — Tooltip, the Dropdown popup, the grid's column menu — inherits the fix.
A tooltip and a dropdown, mirrored
Country
JSX
<Flex props={{ dir }} gap={4} ai="center">
  <Dropdown label="Country" defaultValue="eg">
    <Dropdown.Item value="eg">Egypt</Dropdown.Item>
    <Dropdown.Item value="ma">Morocco</Dropdown.Item>
    <Dropdown.Item value="jo">Jordan</Dropdown.Item>
  </Dropdown>
  <Tooltip content="Sends the invoice and marks it open">
    {(trigger) => <Button {...trigger}>Send</Button>}
  </Tooltip>
</Flex>

A pinned column belongs to the reading order too

A column is pinned to the START or the END of the inline axis, not to the left or the right of the screen — so the invoice number below stays where the reading begins under either direction, and the total stays where it ends. LEFT and RIGHT are the older spelling and still mean those two. Three more things follow the reading order with it: align: 'end' on a numeric column, the resize handle (drag it — it grows the column towards the reading end in both directions), and the column menu, whose Pin Left says Pin Right when that is the side it would pin to. Scroll it sideways: the pinned columns hold their edges.
A grid with both edges pinned
JSX
<Box props={{ dir }}>
  <DataGrid
    data={invoices}
    def={{
      columns: [
        { key: 'id', header: 'Invoice', pin: 'START', width: 120 },
        { key: 'customer', header: 'Customer', width: 200 },
        { key: 'country', header: 'Country', width: 160 },
        { key: 'status', header: 'Status', width: 140 },
        { key: 'items', header: 'Items', width: 120, align: 'end' },
        { key: 'total', header: 'Total', pin: 'END', width: 130, align: 'end' },
      ],
      rowHeight: 40,
      visibleRowsCount: 6,
      sortable: true,
      resizable: true,
    }}
  />
</Box>

What stays physical, and why

Three things are deliberately not mirrored, because the reading order is not what they are about. Overlay positions its layer with a transform in page coordinates measured off the anchor, so its box is anchored at the page's own origin in both directions — a measured pixel has no reading order. The grid's loading bar sweeps the same way either way, because a sweep is not a sentence. And left, right, pl, mr and the rest of the physical props are still there and still physical: when you mean the screen side — a shadow the light casts, a decoration in a corner — say so.

Making your own components mirror

  • Reach for ps/pe, ms/me, bs/be, insetStart/insetEnd and borderRadiusStart/borderRadiusEnd by default; the axis shorthands px, mx and insetX have always been logical.
  • textAlign="start" rather than "left", and jc="start"/"end" rather than "left"/"right".
  • For what is left — a rotation, a translation, a gradient direction — nest it under rtl. Remember ltr matches a document with no dir at all, since left to right is the initial value.
  • Read a direction, never store one: getComputedStyle(el).direction is the only answer that accounts for a dir="auto" or a <bdi> above you.