Direction provider
Sets reading direction for components that position popups and sliders.
- Status
- Experimental
- Level
- Utility
- Category
- Utilities
- Adoption
- Not used yet
import { DirectionProvider } from "@oration/canon/components/direction";packages/canon/src/components/direction.tsximport { Button } from "@oration/canon/components/button";import { DirectionProvider } from "@oration/canon/components/direction";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Tabs, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import { toast } from "@oration/canon/components/toast";import { ArrowRightIcon, ChevronDownIcon } from "lucide-react";import * as React from "react";export function Hero() { const [direction, setDirection] = React.useState<"ltr" | "rtl">("rtl"); const runs = ["Friday, Oct 2", "Tuesday, Oct 6", "Friday, Oct 9"]; return ( <div className="flex w-full max-w-md flex-col gap-4"> <SegmentedControl<"ltr" | "rtl"> label="Reading direction" value={direction} onValueChange={setDirection} options={[ { value: "ltr", label: "Left to right" }, { value: "rtl", label: "Right to left" }, ]} /> <div dir={direction} className="flex flex-col gap-4 rounded-xl bg-card p-4 shadow-border" > <DirectionProvider direction={direction}> <Tabs defaultValue="invoices"> <TabsList aria-label="Supplier record"> <TabsTrigger value="invoices">Invoices</TabsTrigger> <TabsTrigger value="payments">Payments</TabsTrigger> <TabsTrigger value="remittances"> Remittances </TabsTrigger> </TabsList> </Tabs> <div className="flex items-center justify-between gap-3"> <span className="flex items-center gap-2 text-13 text-foreground"> <ArrowRightIcon aria-hidden="true" className="size-4 text-muted-foreground rtl:-scale-x-100" /> INV-20944 to the next run </span> <DropdownMenu> <DropdownMenuTrigger render={ <Button type="button" variant="outline" size="sm" /> } > Actions <ChevronDownIcon data-icon="inline-end" aria-hidden="true" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-44"> <DropdownMenuItem onClick={() => toast.add({ type: "success", title: "INV-20944 approved", }) } > Approve </DropdownMenuItem> <DropdownMenuSub> <DropdownMenuSubTrigger> Move to run </DropdownMenuSubTrigger> <DropdownMenuSubContent side="inline-end"> {runs.map((run) => ( <DropdownMenuItem key={run} onClick={() => toast.add({ title: `INV-20944 moved to ${run}`, }) } > {run} </DropdownMenuItem> ))} </DropdownMenuSubContent> </DropdownMenuSub> </DropdownMenuContent> </DropdownMenu> </div> </DirectionProvider> </div> </div> );}Usage#
DirectionProvider tells Base UI components which way text reads, so arrow-key navigation, logical popup sides (inline-start, inline-end) and scroll area scrollbars follow right-to-left languages. It's a straight re-export of Base UI's provider and useDirection hook. It doesn't set the dir attribute: put dir="rtl" on the same subtree so the browser's layout and logical classes (ms-*, ps-*, start-*, text-start) flip too. Oration ships in English only today and nothing renders it.
When to use
- Wrapping the app, or a region, when it renders a right-to-left locale such as Arabic or Hebrew, alongside
dir="rtl". - Previewing a component in RTL while building it, to check its keyboard and popup behavior.
- Inside a custom component that needs the direction for its own logic, such as which arrow key means next, through
useDirection().
When not to use
- Flipping layout and spacing. That's the
dirattribute plus logical classes, which work without the provider. Use Layout - A single paragraph of right-to-left text inside an English page, such as a supplier's note in Arabic. Set
dir="auto"on that element. - Mirroring an icon. Use
rtl:-scale-x-100on direction-bearing icons only. Use Iconography
Examples#
Logical classes
The same markup in both directions. border-s, ps-3, ms-auto and text-start follow the reading side with no provider at all; <bdi> keeps the invoice ID and amount reading left to right.
Priya Raman
Approved INV-20931
Priya Raman
Approved INV-20931
export function LogicalLayout() { return ( <div className="grid w-full max-w-xl gap-4 sm:grid-cols-2"> {(["ltr", "rtl"] as const).map((dir) => ( <div key={dir} className="flex flex-col gap-1.5"> <span className="text-xs text-muted-foreground"> {dir === "ltr" ? "Left to right" : "Right to left"} </span> <div dir={dir} className="flex items-center gap-3 rounded-xl bg-card p-3 shadow-border" > <span aria-hidden="true" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground" > PR </span> <div className="min-w-0 flex-1 border-s border-border ps-3 text-start"> <p className="text-13 font-medium text-foreground"> Priya Raman </p> <p className="text-xs text-muted-foreground"> Approved{" "} <bdi className="font-mono">INV-20931</bdi> </p> </div> <span className="ms-auto text-13 text-foreground tabular-nums"> <bdi>$18,420.00</bdi> </span> </div> </div> ))} </div> );}States#
| State | Treatment |
|---|---|
| Left to right | The default, "ltr". |
| Right to left | "rtl": horizontal arrow keys swap in tabs, toolbars and radio groups, submenus open with ArrowLeft, inline-* popup sides flip, sliders run from the right, and scroll area scrollbars move to the left. |
Behavior#
directiondefaults to"ltr". Nested providers override outer ones for their subtree.- Base UI reads it for keyboard navigation (in RTL, ArrowLeft moves to the next tab or item), for logical popup sides and alignment, and for scrollbar placement.
- Physical props don't flip: a popup with
side="right"opens to the right in both directions. Useside="inline-end"for submenus and side panels that should follow reading direction. - Popups portal to
<body>, outside adirset on a region, so their text runs left to right unlessdiris on<html>. Set it on the document for a real RTL locale. useDirection()returns the nearest provider's direction, or"ltr"with none.
Do and don't#
dir and the provider together on the same root, ideally <html dir> plus a provider at the app root.Approved INV-20931
ms-2, ps-3, border-s, start-0, text-start.Approved INV-20931
ml-2, pl-3, border-l, left-0, text-left) for anything that should follow reading direction. They stay put in RTL.Content#
- Mirror icons that point along the reading direction (back, forward, next, reply, indent). Don't mirror logos, checkmarks, media controls, clocks or charts.
- Numbers, amounts, IDs and code stay left to right inside right-to-left text; wrap them in
<bdi>ordir="ltr"when they sit next to RTL words.
Accessibility#
- Pair
dirwith the rightlangon the same element so screen readers pick the right voice and reading order. - Under RTL, arrow-key meaning flips in Base UI components; custom keyboard handling should read
useDirection()and flip the same way. - Focus order follows the DOM, which doesn't change with direction; keep the DOM in logical reading order.
| Keys | Action |
|---|---|
| ArrowLeft | In a horizontal Base UI tab list or menu bar: previous item in LTR, next item in RTL. |
| ArrowRight | Next item in LTR, previous item in RTL. |
API reference#
DirectionProvider
From @oration/canon/components/direction, re-exported from @base-ui/react/direction-provider.
Other props spread onto Nothing; only these props.
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "ltr" | "rtl" | "ltr" | The reading direction for Base UI components inside. |
children | ReactNode | No default | The subtree. |
useDirection
useDirection(): "ltr" | "rtl". Takes no arguments.
No props of its own.
Returns
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "ltr" | "rtl" | No default | The nearest provider's direction, "ltr" without one. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Not used anywhere: Oration has no right-to-left locale, so no component has been checked in RTL.
53 of 133 files in packages/canon/src/components use physical spacing or position classes (ml-*, pl-*, left-* and their pairs), which won't flip.
DropdownMenuSubContent defaults to side="right" and the sub-trigger draws ChevronRightIcon with ml-auto, so submenus open and point the wrong way in RTL unless you pass side="inline-end".
useOverflowFade assumes left to right, so scrollable tab rows fade the wrong edges in RTL.