Skip to content

fix: map text-align start/end to left/right for RN compatibility - #397

Open
mubeess wants to merge 3 commits into
nativewind:mainfrom
mubeess:fix/text-start-end-logical-values
Open

fix: map text-align start/end to left/right for RN compatibility#397
mubeess wants to merge 3 commits into
nativewind:mainfrom
mubeess:fix/text-start-end-logical-values

Conversation

@mubeess

@mubeess mubeess commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Tailwind CSS v4 generates text-align: start and text-align: end for the text-start and text-end utility classes. These are CSS logical values that depend on writing direction.

React Native's textAlign prop does not support start or end values — only auto, left, right, center, and justify. Previously, these values were silently dropped (returned undefined with a warning).

Fix

  • start → mapped to left
  • end → mapped to right

This matches the LTR default behavior and is a pragmatic fallback since React Native doesn't support direction-aware text alignment.

Files Changed

File Change
src/compiler/declarations.ts Add startleft and endright mapping in parseTextAlign
src/__tests__/vendor/tailwind/typography.test.tsx Add text-start and text-end tests

Testing

  • yarn test: 1053 passed (1051 existing + 2 new)
  • yarn lint: clean
  • yarn typecheck: clean

Checklist

  • Tests pass
  • Lint passes
  • Typecheck passes
  • No breaking changes

@YevheniiKotyrlo YevheniiKotyrlo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mapping is right, but the description undersells it — and "pragmatic fallback" invites a follow-up that would break it.

RN's left/right are already logical on both native platforms; physical alignment is not expressible at all. Links pinned at v0.86.0:

Android <Text>TextLayoutManager.getTextAlignment branches only on "center" and "right":

var alignment =
    if (swapNormalAndOpposite) Layout.Alignment.ALIGN_OPPOSITE
    else Layout.Alignment.ALIGN_NORMAL

if (alignmentAttr == null) return alignment

if (alignmentAttr == "center") {
  alignment = Layout.Alignment.ALIGN_CENTER
} else if (alignmentAttr == "right") {
  alignment =
      if (swapNormalAndOpposite) Layout.Alignment.ALIGN_NORMAL
      else Layout.Alignment.ALIGN_OPPOSITE
}

"left" has no branch — it falls through to ALIGN_NORMAL (start), same as auto. swapNormalAndOpposite also covers a paragraph whose script direction disagrees with its layout direction.

Android <TextInput> — different path, same conclusion: "left" -> if (isRTL) Gravity.RIGHT else Gravity.LEFT.

iOSRCTTextAttributes.effectiveParagraphStyle:

if (_layoutDirection == UIUserInterfaceLayoutDirectionRightToLeft) {
  if (alignment == NSTextAlignmentRight) {
    alignment = NSTextAlignmentLeft;
  } else if (alignment == NSTextAlignmentLeft) {
    alignment = NSTextAlignmentRight;
  }
}

_layoutDirection comes from YGNodeLayoutGetDirection in ParagraphShadowNode::getContent.

Closing the two obvious objections: TextAlignment really is Natural | Left | Center | Right | Justified, so an alias is the only route short of an RN change — and neither swap is gated by I18nManager.swapLeftAndRightInRTL, which has exactly two consumers, both layout (L597, L717).

So start -> left / end -> right is exact, RTL included — not an LTR default. (The docs list the accepted values but not this behaviour.)

Two suggestions:

  1. Put this in a code comment. The natural "RTL fix" someone sends later is I18nManager.isRTL ? "left" : "right" for end — wrong on both platforms: under RTL it yields left, which Android resolves to ALIGN_NORMAL and iOS flips back to Right, both visually start.
  2. An RTL test asserting startleft and endright would make that fail loudly instead of silently inverting.

Aside: auto is in the allowed set but unreachable — it is not valid CSS, so lightningcss never delivers it as a bare string.

@mubeess

mubeess commented Jul 31, 2026

Copy link
Copy Markdown
Author

2. An RTL test asserting startleft and endright would make that fail loudly instead of silently inverting.

i did the 2nd suggestion, kindly check

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

Ran this on a device (RN 0.86, Android 10, Mi MIX 3). The mapping behaves as expected, and one caveat came out of it that is worth documenting.

Case handling needs nothing, and that is worth a comment. lightningcss normalises the parsed text-align enum, so uppercase input arrives lowercase and the two === comparisons are sufficient:

start / START / Start  ->  {"textAlign":"left"}    warnings: []
end   / END            ->  {"textAlign":"right"}   warnings: []

Worth noting because #391's color: inherit fix, in this same file, does need toLowerCase() — that value arrives through the unparsed-token path, which preserves author casing. The asymmetry is correct but reads like an oversight, and someone will eventually "fix" it.

The caveat: text-end on a wrapper still does nothing on native. RN resolves textAlign from the paragraph's own text attributes and does not inherit it across a ViewText boundary. So this compiles, lands, and aligns nothing:

<View className="text-end">
  <Text>123 KB</Text>
</View>

Web inherits text-align from the wrapper; native does not. Measured in a real table cell, comparing values of different widths in the same column:

5-char 12 KB 6-char 123 KB
text-end on the wrapper View left 466, right 563 left 467, right 585
+ items-end on the wrapper left 517, right 614 left 496, right 614

Matching left edges is start alignment; matching right edges is end alignment. The class alone does not get there — flex alignment on the wrapper does.

That is RN behaviour and not something this PR should fix. But it does mean anyone applying text-end to a container rather than to the Text itself will conclude the fix does not work. One line in the docs or changelog — applies to the Text itself; a wrapper needs flex alignment on native — would save that round trip.

One test suggestion: a negative case pinning that the mapping does not over-match, e.g. text-align: match-parent must keep dropping with a warning. Cheap insurance if these two ifs ever become a lookup table.

Minor and pre-existing: const allowed = new Set([...]) reallocates on every call, once per text-align declaration in a build. A module-scope constant would be a clean drive-by.

- Hoist allowed Set to module scope (no per-call realloc)
- Comment lightningcss case normalization and nativewind#391 asymmetry
- Add negative test: match-parent drops with warning (no over-match)
- Document native View->Text inheritance caveat in README
@mubeess

mubeess commented Jul 31, 2026

Copy link
Copy Markdown
Author

Ran this on a device (RN 0.86, Android 10, Mi MIX 3). The mapping behaves as expected, and one caveat came out of it that is worth documenting.

Case handling needs nothing, and that is worth a comment. lightningcss normalises the parsed text-align enum, so uppercase input arrives lowercase and the two === comparisons are sufficient:

start / START / Start  ->  {"textAlign":"left"}    warnings: []
end   / END            ->  {"textAlign":"right"}   warnings: []

Worth noting because #391's color: inherit fix, in this same file, does need toLowerCase() — that value arrives through the unparsed-token path, which preserves author casing. The asymmetry is correct but reads like an oversight, and someone will eventually "fix" it.

The caveat: text-end on a wrapper still does nothing on native. RN resolves textAlign from the paragraph's own text attributes and does not inherit it across a ViewText boundary. So this compiles, lands, and aligns nothing:

<View className="text-end">
  <Text>123 KB</Text>
</View>

Web inherits text-align from the wrapper; native does not. Measured in a real table cell, comparing values of different widths in the same column:

5-char 12 KB 6-char 123 KB
text-end on the wrapper View left 466, right 563 left 467, right 585
+ items-end on the wrapper left 517, right 614 left 496, right 614
Matching left edges is start alignment; matching right edges is end alignment. The class alone does not get there — flex alignment on the wrapper does.

That is RN behaviour and not something this PR should fix. But it does mean anyone applying text-end to a container rather than to the Text itself will conclude the fix does not work. One line in the docs or changelog — applies to the Text itself; a wrapper needs flex alignment on native — would save that round trip.

One test suggestion: a negative case pinning that the mapping does not over-match, e.g. text-align: match-parent must keep dropping with a warning. Cheap insurance if these two ifs ever become a lookup table.

Minor and pre-existing: const allowed = new Set([...]) reallocates on every call, once per text-align declaration in a build. A module-scope constant would be a clean drive-by.

done

@YevheniiKotyrlo YevheniiKotyrlo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated locally, since CI has not run on this branch.

  • yarn jest src/__tests__/vendor/tailwind/typography.test.tsx101/101 pass, including all five new cases.
  • yarn typecheck — clean.
  • Mutation-tested the new tests to confirm they are load-bearing rather than decorative:
    • making the mapping over-match (any unknown value returns left) fails 3 tests, including the new match-parent guard;
    • inverting end to left — the exact "RTL fix" your comment warns against — fails text-end and the text-end is RTL-safe identity test.

One caveat for anyone repeating this: run with --no-cache. With a warm jest cache the inverted-end mutation showed only one failure and the identity test appeared to pass, which reads as a vacuous test. It is not — --no-cache fails both.

Looks good to me. One naming nit inline.

}
}

const TEXT_ALIGN_ALLOWED = new Set([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, non-blocking: the three Sets already at module scope in this file are camelCase — unparsedRuntimeParsing (L68), allowAutoProperties (L2652), namedColors (L3364). TEXT_ALIGN_ALLOWED is the first SCREAMING_SNAKE one, so the file ends up carrying both conventions.

textAlignKeywords (or allowedTextAlign) would keep it uniform. Worth settling now — the other nine parse functions in this file still build their allow-list Set per call, and whenever those get hoisted they will follow whichever convention is here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants