RTL support
Setting the direction
One prop. dir="rtl" on Root mirrors the lightbox’s behavior (scroll geometry, arrow keys, thumbnail-strip travel) and is written
to the Portal host as a dir attribute, so CSS direction follows it too. That matters because the host mounts on document.body: a dir on your own layout wrapper would style the page and never reach
the viewer.
Because the prop crosses the portal boundary on its own, it is also all you need to run a
single RTL lightbox inside an otherwise LTR page: no wrapper element, no portal container. The demo further down this page does exactly that, since these docs are LTR.
Behavior and layout both follow the prop. See the live gallery below.
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { images }: { images: { id: string; src: string; alt: string }[] } = $props();</script><Lightbox.Root dir="rtl"> <Lightbox.Portal> <Lightbox.Backdrop /> <Lightbox.Content aria-label="גלריית תמונות"> <Lightbox.Slides> {#each images as img, i (img.id)} <Lightbox.Slide> <Lightbox.Item index={i}> <Lightbox.Media> <img src={img.src} alt={img.alt} /> </Lightbox.Media> </Lightbox.Item> </Lightbox.Slide> {/each} </Lightbox.Slides> <Lightbox.Close aria-label="סגירה" /> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>Already-RTL apps
If the whole app is RTL (dir="rtl" on <html>), you can omit the prop and the lightbox will still look right: nothing is written to the portal host when the prop is absent, so it inherits
the document’s direction. Layout mirrors, and the page keeps control of it.
Pass the prop anyway. Behavior does not read your CSS, so it stays on its 'ltr' default, and RTL layout with LTR behavior is the one misconfiguration with no visible symptom. The
carousel still renders and still scrolls; it just resolves the wrong index for a scroll position,
so the active marker and the visible slide drift apart. In development the library compares the
two when Slides mounts and warns.
What mirrors
Not everything flips, and the split is not arbitrary. Moving through an ordered list follows reading order, so it mirrors. Manipulating a surface directly (panning a zoomed photo, dragging a sheet away) does not: a drag follows your hand, and mirroring it would fight the gesture. The arrow keys serve both, which is why they mirror while navigating and stay physical while zoomed.
| Surface | Under RTL | Why |
|---|---|---|
| Slide order & scroll | Mirrored | Index 0 sits at the inline-start (right) edge; the scroller runs right to left. |
| Horizontal arrow keys | Mirrored | ArrowLeft advances, ArrowRight goes back: travel follows reading order. |
| Home / End | Not mirrored | Home is always the first item, End the last. They address indices, not sides. |
| Thumbnail strip | Mirrored | Track, padding, and the active-thumb indicator all run on the inline axis. |
| Previous / Next | Mirrored position | Previous is the item nearer index 0, so it belongs on the inline-start side. |
| Zoomed panning | Not mirrored | Direct manipulation of a surface: ArrowRight pans right, as on a map. |
| Pull-to-dismiss | Not mirrored | The sheet follows your finger, which has no reading direction. |
| Morph / view transitions | Mirrored | The morph measures the trigger and the slide, so it follows whatever they do. |
Indices never mirror
The imperative API is index-based, and indices are not sides. createHandle()’s open(3) and goTo(3) land on the fourth item in either direction; what changes
is that RTL renders it further left and the track travels the other way to reach it. The same
holds for bind:index / onValueChange and for Trigger’s index.
The buttons inside the viewer below jump by index. Open it, then watch the strip: the photo you land on is the same one an LTR gallery would show.
גלריית תמונות · active index 0
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); const lightbox = Lightbox.createHandle();</script><button type="button" onclick={() => lightbox.open(0)}>פתיחת הגלריה</button><button type="button" onclick={() => lightbox.goTo(3)}>מעבר לתמונה 4</button><Lightbox.Root handle={lightbox} dir="rtl"> {@render children()}</Lightbox.Root>Styling for RTL
The library ships no styles, so mirroring your own chrome is your job, and logical properties
do nearly all of it. Reach for inset-inline-start / inset-inline-end over left / right, padding-inline over padding-left, and border-inline-start for a divider beside a panel that changes sides. In Tailwind those are start-*, end-*, ps-*, pe-*, ms-*, and text-start. Every preset
in this documentation is written this way.
Three things logical properties will not fix. Directional glyphs need flipping: .nav:dir(rtl) svg { transform: scaleX(-1); } turns a
chevron around, since mirroring only the button’s position leaves it pointing the wrong way.
Safe-area insets are physical, so pairing inset-inline-start with env(safe-area-inset-left) clears the notch on the wrong side; map them to the inline axis through a custom property that you
swap under :dir(rtl). And a counter like 1 / 7 is two digit runs around a neutral separator with nothing to anchor them, so an RTL base direction
reorders it into 7 / 1. Give Counter direction: ltr; unicode-bidi: isolate;.
Copy is yours
Direction is only half of internationalization. The other half is words, and the library ships none of them: not a label, not a role description, not a slide name. A headless library cannot know your language, and a string baked into a primitive is copy you never approved: an English “carousel” announced inside Hebrew speech is worse than no announcement at all.
So every string a screen reader reads comes from you. Two of them are easy to miss because
they are not visible on screen: aria-roledescription on Slides and Item, which replaces the word for the role, and
the slide’s aria-label, which is where the APG carousel pattern puts the position, because a group cannot carry aria-posinset. A slide’s caption does not name it either, so a
captioned slide with no label is still nameless. In development the library says so rather
than letting it ship silently.
Add a thumbnail strip and the naming moves: each item becomes a tabpanel named by its thumbnail tab, so label the thumbnails and leave the items to inherit it.
<script lang="ts"> import { Lightbox } from '@ramka/svelte'; let { items, t }: { items: { id: string; src: string; alt: string }[]; t: (key: string, vars?: Record<string, unknown>) => string; } = $props();</script><Lightbox.Root dir="rtl"> <Lightbox.Portal> <Lightbox.Content aria-label={t('viewer.dialog')}> <Lightbox.Slides aria-label={t('viewer.photos')} aria-roledescription={t('viewer.carousel')}> {#each items as item, i (item.id)} <Lightbox.Slide> <Lightbox.Item index={i} aria-roledescription={t('viewer.slide')} aria-label={t('viewer.position', { current: i + 1, total: items.length })} > <Lightbox.Media> <img src={item.src} alt={item.alt} /> </Lightbox.Media> </Lightbox.Item> </Lightbox.Slide> {/each} </Lightbox.Slides> <Lightbox.Counter> {#snippet children({ current, total })} {t('viewer.position', { current, total })} {/snippet} </Lightbox.Counter> <Lightbox.Previous aria-label={t('viewer.previous')} /> <Lightbox.Next aria-label={t('viewer.next')} /> <Lightbox.Close aria-label={t('viewer.close')} /> </Lightbox.Content> </Lightbox.Portal></Lightbox.Root>