Skip to content

RTL tooltips and popovers

Position RTL tooltips and popovers with logical alignment, accurate pointer arrows, collision handling and keyboard and touch behavior.

By , Founder of Ritla

Guide13 min read

A popup can contain right-aligned Arabic text and still attach to the wrong edge of its trigger. It can also attach correctly, then leave its pointer arrow behind when collision handling moves it. Text direction, preferred placement and final geometry are separate decisions.

Start by defining the relationship you want: below the trigger, aligned with its reading start, with room to remain visible. That is more precise than asking every popup to “open left in Arabic.” A physical side can be intentional, and a collision-driven change of side can be the correct result.

The examples belong to a ceramics studio's kiln-booking calendar. A tooltip explains an availability control; a separate popover presents booking terms and a policy link. Those two pieces of content need different interactions even when they share a positioning library.

Decide whether the content is a tooltip or a popover#

A tooltip supplies a short, noninteractive description of another control. Focus remains on that control. A panel containing a policy link, checkbox or close button needs an interaction model that allows visitors to reach those elements; it is not a tooltip simply because it is small.

Content in this calendarSuitable interactionFocus behavior
Explanation of the availability controlTooltip on hover and keyboard focusStays on the described control
Booking terms with a policy linkClick or tap popoverCan move into the revealed content
A decision blocking further bookingModal dialogEnters the modal and is contained there

MDN's tooltip role reference describes its noninteractive relationship to the trigger. Do not put focusable links into an element with that role and then trap focus there to make them reachable.

A popover is a presentation mechanism, not one universal ARIA widget. Its content might be ordinary help, a menu or another component. Choose roles and keyboard behavior from what it does. A group of policy text and a link does not acquire menu semantics just because it appears in an overlay.

Translate the intended relationship into a placement#

Library placement names have different jobs. In Floating UI, the main side in left or right is physical. For bottom-start and bottom-end, the alignment suffix follows writing direction. Its computePosition documentation explicitly describes those alignments as logical.

For the booking terms, the preferred placement is below the trigger at reading start. In horizontal English, that aligns their left edges; in Arabic, it aligns their right edges. The preference remains bottom-start rather than switching to an unrelated side placement.

If the requirement is instead “beside the trigger at inline end,” map that relationship to a physical side for the library you use. In horizontal RTL, inline end is left; in LTR, it is right. Do not assume a library accepts inline-end as a placement string without checking its API.

Changing alignment is not the same as reflecting the whole panel. Keep Arabic text readable, technical values isolated where needed and artwork oriented according to its meaning. A map preview or studio logo inside a popover should not inherit a blanket negative scale.

Record both the requested placement and the resolved placement during debugging. A request for bottom-start can legitimately resolve above the control when space below is insufficient. Treat the output as evidence of the collision policy, not automatic proof that RTL handling failed.

Establish direction on the rendered floating element#

The trigger can live under an Arabic application wrapper while its popup is portaled under the document body. That changes the DOM ancestors from which direction and typography are inherited. Inspect the actual floating panel, not just the trigger's dir attribute.

Prefer root-level language and direction for an Arabic page. Where the application uses local boundaries, pass the intended values to the popup root through the component's supported configuration. A framework's locale context and HTML direction inheritance are different mechanisms.

Also inspect styles scoped beneath the original wrapper. A portaled panel can lose a font or width rule even after you supply dir="rtl". That can change its size and therefore its collision result. Correct geometry depends on measuring the panel with its final content and styling.

Do not put dir="auto" on the popup root merely because one description can contain a foreign studio name. That makes the component's direction depend on its first strong letter. A popover beginning with a Latin studio name can resolve to LTR even though the surrounding booking interface is Arabic. Keep the outer direction tied to the UI's alignment policy, and isolate an individual metadata value inside it when needed. A nested bdi boundary can accommodate that value without making the whole popup's reading start change with its contents. Recalculate size after inserting the real label; isolation does not reserve space for a longer name.

Native popovers enter the browser's top layer without being reparented in the DOM. Do not confuse that rendering behavior with a framework portal. MDN's Popover API guide explains the native mechanism and its nonmodal behavior.

Build a nonmodal booking-terms popover#

This example uses native popover controls for showing and dismissing the content. The JavaScript that follows positions it; it does not implement a second click controller or focus trap. The reserved policy URL represents the studio's own destination.

html
<button id="kiln-terms-trigger" type="button"        popovertarget="kiln-terms" lang="ar" dir="rtl">  شروط حجز الفرن</button> <div id="kiln-terms" class="kiln-popover" popover="auto"     lang="ar" dir="rtl">  <h3>قبل تأكيد الحجز</h3>  <p>يظهر موعد الاستلام بعد اعتماد الحجز.</p>  <a href="https://kiln.example/ar/booking-policy">قراءة سياسة الحجز</a>  <button type="button" popovertarget="kiln-terms"          popovertargetaction="hide">إغلاق</button></div>

The trigger's native relationship supplies opening and closing behavior. An auto popover supports light dismissal; it does not make the background modal. MDN documents these states in its popover attribute reference.

Use this implementation in browsers supporting the native API, with a supported fallback in the application if needed. Do not leave a nonfunctional trigger as the only way to obtain booking terms. Inline terms or a policy destination can preserve access when the enhanced presentation is unavailable.

Reset the native centered positioning before applying library coordinates:

css
.kiln-popover {  position: fixed;  inset: auto;  margin: 0;  box-sizing: border-box;  inline-size: min(23rem, calc(100vw - 1rem));  max-block-size: min(28rem,    var(--available-height, calc(100dvh - 1rem)));  overflow: auto;  overflow-wrap: anywhere;  padding: 1rem;  text-align: start;} .kiln-popover:popover-open:not([data-positioned="true"]) {  visibility: hidden;}

The temporary visibility state prevents a centered frame appearing before coordinates arrive, while leaving the open element measurable. Closed native popovers are not measurable in the same way. Avoid overriding their display state merely to obtain dimensions.

Position only while the popover is open#

The following module assumes @floating-ui/dom is installed and resolved by your application's bundler. Run it after the markup exists. The example uses a horizontal writing mode, matching the calendar's layout.

js
import {  computePosition, autoUpdate, offset, flip, shift, size} from "@floating-ui/dom"; const kilnTrigger = document.getElementById("kiln-terms-trigger");const kilnPanel = document.getElementById("kiln-terms");let stopKilnUpdates;let kilnRevision = 0; async function positionKilnTerms() {  if (!kilnPanel.matches(":popover-open")) return;  const revision = ++kilnRevision;  const result = await computePosition(kilnTrigger, kilnPanel, {    placement: "bottom-start",    strategy: "fixed",    middleware: [      offset(8),      flip({ padding: 8 }),      shift({ padding: 8 }),      size({        padding: 8,        apply({ availableHeight, elements }) {          elements.floating.style.setProperty(            "--available-height", `${Math.max(0, availableHeight)}px`          );        }      })    ]  });   if (revision !== kilnRevision || !kilnPanel.matches(":popover-open")) return;  Object.assign(kilnPanel.style, {    left: `${result.x}px`,    top: `${result.y}px`  });  kilnPanel.dataset.placement = result.placement;  kilnPanel.dataset.positioned = "true";} kilnPanel.addEventListener("beforetoggle", event => {  kilnPanel.dataset.positioned = "false";  if (event.newState === "closed") {    ++kilnRevision;    stopKilnUpdates?.();    stopKilnUpdates = undefined;  }}); kilnPanel.addEventListener("toggle", () => {  stopKilnUpdates?.();  stopKilnUpdates = undefined;  if (kilnPanel.matches(":popover-open")) {    stopKilnUpdates = autoUpdate(kilnTrigger, kilnPanel, positionKilnTerms);  }});

The beforetoggle event resets readiness for the next presentation and stops updates during closure. Positioning begins after the popover is open and measurable. A revision check keeps an older asynchronous coordinate result from overwriting a more recent result or a closed state.

autoUpdate maintains the relationship while relevant scrolling, resizing or element layout changes occur. Stop it when the panel closes, and clean it up when the component unmounts. If the locale changes without a size change, explicitly request another position update as part of that change.

The library returns physical x and y coordinates for the configured positioning strategy. Applying those to left and top is intentional. Replacing left with inset-inline-start here would reinterpret the returned horizontal coordinate in RTL. Logical alignment belongs in the placement configuration; resolved physical coordinates belong in the coordinate application.

If you replace the positioning engine with your own measurements, keep the coordinate frame explicit. A trigger's viewport rectangle is not automatically an offset inside an absolutely positioned calendar card. Its containing block, borders, scrolling and transforms can change that relationship. Changing a physical property to a logical one cannot correct a measurement taken from the wrong origin.

Treat flipping, shifting and sizing as different decisions#

The middleware in the example solves several problems in sequence. flip can choose an alternative placement when the preferred one overflows. shift adjusts position to reduce clipping without simply declaring the opposite side. size exposes available space so the panel can constrain its content region.

All three use the same boundary padding here. Mixing unrelated padding values can make a placement appear acceptable to one step and crowded to another. The gap supplied by offset is a separate relationship between the trigger and the panel.

The CSS caps the panel's inline size for the viewport and allows its contents to scroll vertically. The available-height variable further constrains that region. If almost no usable space remains, choose another presentation such as in-flow help or a larger dedicated overlay; a technically fitting strip is not necessarily readable.

Test the actual clipping context. An inline tooltip inside an overflow-hidden card can be clipped without causing the document to scroll horizontally. A top-layer popover has a different relationship to ancestor clipping, but still needs viewport collision handling. Raising z-index does not repair every clipping or sizing problem.

Requested placement is therefore a preference, not a permanent promise. In a phone viewport, a popup near the right edge may need a different alignment from the same popup in the middle. Evaluate the resulting attachment and readability instead of requiring identical physical coordinates in both locales.

Consider a simplified horizontal calculation with a 412-pixel viewport, eight pixels of boundary padding, a trigger spanning physical positions 332 to 380, and a panel 276 pixels wide. Before collision adjustments, the alignments produce different bounds:

AlignmentPanel leftPanel right
RTL below-start, matching right edges104380
LTR below-start, matching left edges332608
Panel shifted to the right boundary's padding128404

These are illustrative geometry values, not measured browser results. They show why a changed horizontal coordinate can be correct after shifting. The last row preserves the available boundary but no longer exactly matches either trigger edge. If the pointer arrow is present, its attachment must accommodate that change rather than pretending the panel stayed start-aligned.

Use a similar calculation when reviewing an edge-case screenshot: identify the trigger bounds, popup width and permitted boundary first. “It moved left” is insufficient evidence of an RTL defect. A more useful report states which edge relationship was requested and which collision constraint the final position violates.

Keep the overlay attached to the right calendar control#

A calendar refresh can replace or recycle the element used as the positioning reference. That is different from the same reference element moving during a scroll. A resize observer or positioning update can maintain geometry without knowing that the control now represents a different booking slot.

Bind the open overlay to a stable calendar-item identity as well as its current DOM reference. When a date filter, room selection or virtualization update removes that item, close the overlay or reconnect it through an explicit application policy. Do not leave the previous slot's explanation attached to whichever button inherited its position.

Refresh the description and positioning together when a reference legitimately changes. Stop observers and pending updates associated with the old reference, then measure the new one with the correct content. If focus was inside a popover whose trigger disappeared, choose a reachable calendar control or another meaningful destination instead of restoring focus to a detached element.

Test this while the terms panel is open: change the calendar's displayed period, remove the originating item and scroll through recycled rows. Confirm that the visible content still describes the control to which the panel points. Geometry alone cannot establish that association.

Point the arrow at the trigger after collision handling#

The booking example omits a pointer arrow. If the design adds one, calculate its attachment after the final placement and position are known. An arrow copied from the requested bottom-start state can point toward empty space after the panel flips above the control.

Floating UI's arrow middleware supplies the dynamic axis offset and documents using the returned placement for the other axis. Add it with the actual arrow element toward the end of the middleware pipeline, then render its result.

For an optional eight-pixel square arrow, this helper applies those returned offsets and clears obsolete physical edges. Its data argument is middlewareData.arrow, and placement is the resolved result, not the original request:

js
function positionPopupArrow(element, placement, data) {  const opposite = {    top: "bottom", bottom: "top", left: "right", right: "left"  };  const staticSide = opposite[placement.split("-")[0]];   for (const side of ["top", "right", "bottom", "left"]) {    element.style[side] = "";  }  if (data.x != null) element.style.left = `${data.x}px`;  if (data.y != null) element.style.top = `${data.y}px`;  element.style[staticSide] = "-4px";}

The element must be an absolutely positioned child of the floating panel, with the intended square size and diamond treatment supplied by its styles. Zero is a valid middleware offset; a truthiness test would discard it. Clear the previous static edge when changing sides so an old top or bottom offset does not continue to constrain the arrow.

Do not blindly mirror an arrow whose offsets already come from the positioning engine. This pointer expresses the relationship to the trigger, not the direction of a next-step action. Also keep it out of accessible names and the focus sequence.

The main example's scrolling panel would clip an arrow extending outside its boundary. To add that decoration, separate an unclipped outer positioning frame from an inner scrolling content region and update the sizing rules accordingly. Increasing z-index on a child does not undo its parent's clipping.

Make tooltip lifetime work for hover and keyboard focus#

For the availability control, a tooltip relationship can be expressed as follows. This fragment defines the description; the calendar's tooltip controller still supplies positioning and the visibility behavior described below.

html
<button id="kiln-availability" type="button"        aria-describedby="kiln-availability-tip" lang="ar" dir="rtl">  أول موعد متاح</button><span id="kiln-availability-tip" role="tooltip" lang="ar" dir="rtl" hidden>  يعتمد الموعد على فترة التبريد المحددة للفرن.</span>

The visible button names the action; the tooltip adds a description rather than replacing that name. The APG tooltip guidance describes this relationship while noting that the pattern remains work in progress. Test the completed controller rather than treating these attributes as an interaction implementation.

A referenced description can be available to assistive technology even when the description element is visually hidden. MDN's aria-describedby reference covers that relationship. Do not assume that a closed visual tooltip must produce no description when its trigger receives focus, or add a live-region announcement that repeats the same explanation unnecessarily.

Keep the description associated with the correct trigger when reusing one tooltip element across calendar items. Update the text and ID relationships as one state change. A shared tooltip that shows the new Arabic explanation while another control still references it can describe the wrong booking action to a screen reader.

Support keyboard focus as well as pointer hover, and let Escape dismiss the tooltip without moving focus. After dismissal, do not reopen it immediately merely because the same trigger still has focus or hover. Define when a new interaction resets that dismissed state.

Allow the pointer to reach the tooltip without it disappearing halfway across a gap. A trigger-only mouse-leave handler can hide the text before a visitor can read or magnify it. Treat the trigger and tooltip as a related hover region, using your library's supported interaction handling where available.

The WCAG guidance for content on hover or focus explains dismissibility, hoverability and persistence, including its exceptions. A short timeout alone does not establish that content remains available long enough to perceive.

On touch devices, provide a way to obtain information that does not require hover. If the explanation is needed to complete booking, visible help or an explicit disclosure may suit it better than a tooltip. Do not hide the only explanation of an unavailable booking state behind an unreachable hover interaction.

Keep popover focus and dismissal appropriate to its content#

Opening the booking terms should not trap focus in a small nonmodal help panel. The policy link and close button must be reachable, and the visitor can continue to other page controls. Verify the native invoker relationship and the actual sequential focus behavior in supported browsers.

For a custom popover, synchronize its expanded state with the panel's real visibility, not just the last trigger click. Escape, outside interaction and another opened panel can all change the state. Avoid announcing an expanded panel that has already been dismissed.

Restore focus according to how dismissal occurred. Closing from inside can call for returning to the invoker. Clicking another control outside the popup should not automatically have its new focus stolen back. Removing the trigger during a calendar refresh needs a meaningful fallback rather than focus on a detached node.

Within a modal booking dialog, inspect popup placement and focus scope together. A custom portal rendered into the inert background can appear styled correctly but fail interaction. Use the component library's supported modal integration; a higher z-index is not a substitute for the right interaction boundary.

Test the attachment under changing content and space#

Use matching Arabic and English calendar fixtures. Compare the intended trigger relationship rather than demanding a pixel reflection: different label widths and collision results can be legitimate.

  1. Open near every viewport edge and inside a scrolling calendar container. Record the requested placement, resolved placement and final panel bounds.
  2. Compare trigger and panel direction at their rendered nodes. Check portaled typography, translated labels and accessible names separately.
  3. Replace the short terms with an approved longer Arabic explanation. Resize, enlarge text and delay font loading. Confirm repositioning and a usable scrolling region. Switch locale while the same popover remains open.
  4. If an arrow is present, force flips and alignment changes. Check its attachment, stale edge styles and zero-offset cases after each change.
  5. Use keyboard focus, Escape and pointer hover for tooltips. Move the pointer onto the tooltip and confirm it remains available; repeat after explicit dismissal.
  6. Open the popover by touch and keyboard. Reach its policy link and close action, then test outside dismissal, focus restoration and any surrounding modal.

Inspect visibility directly as well as document scroll width. A clipped popup or a wrongly attached arrow can fail without producing document-level overflow. Repeat with the browsers and assistive technology the product supports, and include the open-state transition in the failure report.

A free Ritla scan can report direction-sensitive physical properties without an RTL override with R030, and document-level horizontal overflow with R040. Those checks do not establish a library placement string's meaning or tooltip lifetime; for a misplaced attachment, continue with absolute positioning in RTL, or use Arabic text overflow when the panel fits but its contents do not.

Checks in this guide

See what your Arabic pages are hiding

Paste a URL. Ritla renders the page on desktop and mobile, runs every check, and shows the top issues with screenshot evidence.

Scan a page freeBrowse the checks