●MAX — Rork Max builds native Swift apps instead of React Native, targeting the whole Apple ecosystem●DEVICES — From one description it spans iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It unlocks AR and LiDAR scanning, 3D games with Metal, Home Screen widgets, Dynamic Island, and on-device Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, joined by Peak XV and a16z Speedrun●PAPERLINE — Rork acquired the app builder Paperline and plans to stay acquisitive for engineering talent●MARKET — With 743,000+ monthly visits, and Gartner expects 75% of new apps to be low-code or no-code by end of 2026●MAX — Rork Max builds native Swift apps instead of React Native, targeting the whole Apple ecosystem●DEVICES — From one description it spans iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It unlocks AR and LiDAR scanning, 3D games with Metal, Home Screen widgets, Dynamic Island, and on-device Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, joined by Peak XV and a16z Speedrun●PAPERLINE — Rork acquired the app builder Paperline and plans to stay acquisitive for engineering talent●MARKET — With 743,000+ monthly visits, and Gartner expects 75% of new apps to be low-code or no-code by end of 2026
The Comma That Fell to the Start of a Line: Rork, Quote Cards, and Japanese Line Breaking
React Native's Text component does not guarantee Japanese line-breaking rules. Here are the violation rates I measured, a WORD JOINER implementation that survives copy and VoiceOver, an onTextLayout audit harness, and how Rork Max (SwiftUI) differs.
I picked up the iPhone SE and stopped halfway through the screen.
A quote card in one of my wallpaper apps. The second line began with a Japanese comma — the punctuation had fallen to the start of the line, which Japanese typography does not allow.
Rork had generated the screen from a plain request: show the quote large and centered. The code worked. The layout held. It simply was not set as Japanese text.
The iPhone 15 Pro simulator never showed it. Different width, different wrap points. I had only been lucky.
React Native's Text does not guarantee line-breaking rules
React Native's Text does not implement line breaking itself. On iOS it hands off to TextKit / CoreText. On Android it hands off to android.text.LineBreaker.
Wrap quality therefore depends on the platform and its locale resolution. That turned out to be the hard part.
Environment
Line-start rules
What I observed
iOS (default)
CoreText applies some rules
Commas and periods mostly avoided. Prolonged sound mark and small kana still fall to line start
iOS with lineBreakStrategyIOS="push-out"
Pushes the previous character down
Violations drop sharply, but line count can grow
Android (default simple)
Essentially none
Commas, periods, and closing brackets land at line start
Android with textBreakStrategy="balanced"
Evens out line lengths
Looks tidier, but the rules themselves are not guaranteed
I had assumed Android would apply Japanese rules whenever the device was set to Japanese. In practice the decision comes from the locale attached to the text, not from the user's system language.
My apps ship in 16 languages. A reader looking at Japanese quotes may well have an English device. I had missed that premise entirely.
I measured the violation rate first
Fixing by feel produces the feeling of a fix and nothing else. So I took numbers.
The sample: 120 quote strings used across six wallpaper apps, including the calm and affirmation titles. Four container widths: 320, 375, 393, and 430.
onTextLayout returns each line after layout resolves. If you can read the first character of a line, you can decide the violation directly.
// KinsokuAudit.tsx — detect line-start violations at runtimeimport { Text, type TextLayoutEventData, type NativeSyntheticEvent } from "react-native";// Characters that must not start a line: punctuation, small kana, prolonged sound markconst LINE_START_FORBIDDEN = "、。,.・:;?!ヽヾゝゞ々ー’”)〕]}〉》」』】…‥" + "ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ";type Violation = { lineIndex: number; head: string; lineText: string };export function auditLines( e: NativeSyntheticEvent<TextLayoutEventData>): Violation[] { const lines = e.nativeEvent.lines; const out: Violation[] = []; // Line 0 is a line start, but not one produced by wrapping. Skip it. for (let i = 1; i < lines.length; i++) { const text = lines[i].text ?? ""; const head = Array.from(text)[0]; if (!head) continue; if (LINE_START_FORBIDDEN.includes(head)) { out.push({ lineIndex: i, head, lineText: text }); } } return out;}export function QuoteCard({ quote, onAudit }: { quote: string; onAudit?: (v: Violation[]) => void }) { return ( <Text style={{ fontSize: 22, lineHeight: 36 }} onTextLayout={(e) => onAudit?.(auditLines(e))} > {quote} </Text> );}
Skipping line 0 keeps the number meaningful: I only want line starts that wrapping created. Source text rarely opens with a comma, but excluding it removes the ambiguity.
I ran all 120 strings through four fixed-width containers. The results:
Condition
Violations / wrapped lines (iOS)
Violations / wrapped lines (Android)
Plain Text (defaults)
31 / 664 (4.7%)
96 / 671 (14.3%)
lineBreakStrategyIOS="push-out" only
9 / 682 (1.3%)
96 / 671 (14.3%)
textBreakStrategy="balanced" only
31 / 664 (4.7%)
88 / 669 (13.2%)
Pre-binding (below)
0 / 689 (0%)
0 / 690 (0%)
Android's 14.3% is the number that landed. Roughly one wrapped line in seven broke in a way a Japanese reader would notice.
push-out helps a great deal on iOS, but the nine survivors were all prolonged sound marks and small kana. Leave the decision to the platform and it will not go that far for you.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Measured violation rates across 120 quotes and 4 device widths, plus the harness that produced them
✦A WORD JOINER pre-binding implementation that keeps copy, search, and VoiceOver intact
✦A CI audit that catches double-application, and where Rork Max (SwiftUI) behaves differently
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I stopped relying on platform settings. Instead I embed the instruction — do not break here — into the string.
The character for that is U+2060 WORD JOINER. Zero width, and it forbids a break at its position.
U+200B ZERO WIDTH SPACE is the opposite instruction (a break is allowed here), so it is simply wrong for this job. And (U+00A0) has width, which opens up the spacing before Japanese punctuation.
// kinsoku.tsconst WJ = ""; // WORD JOINER: zero width, break-forbiddingconst LINE_START_FORBIDDEN = "、。,.・:;?!ヽヾゝゞ々ー’”)〕]}〉》」』】…‥" + "ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ";const LINE_END_FORBIDDEN = "‘“(〔[{〈《「『【";// A long bound run becomes one unbreakable word and can overflow. Cap it.const MAX_BIND_RUN = 4;export function applyKinsoku(input: string): string { const chars = Array.from(input); // do not split surrogate pairs let out = ""; let run = 0; for (let i = 0; i < chars.length; i++) { const cur = chars[i]; const next = chars[i + 1]; out += cur; if (next === undefined) break; if (cur === "\n" || next === "\n") { run = 0; continue; } const bind = LINE_START_FORBIDDEN.includes(next) || LINE_END_FORBIDDEN.includes(cur); if (bind && run < MAX_BIND_RUN) { out += WJ; run += 1; } else { run = 0; } } return out;}
MAX_BIND_RUN came out of measurement. Without a cap, a passage combining an ellipsis, an exclamation, and a closing bracket bound seven characters into a single word and overflowed the narrow width. Four is the cap because the longest run in my 120 strings was three.
Array.from matters. Iterate with input.length and you will insert a WORD JOINER inside a surrogate pair, producing mojibake in any quote containing an emoji or a variation selector. I did exactly that once.
The step that keeps copy and VoiceOver intact
This was the real trap.
WORD JOINER is invisible. When a reader long-presses a card and pastes the line into a social post, the invisible characters travel with it. Harmless in most places — but paste it into a search field and it no longer matches.
VoiceOver also reads the string as given. The fix is to separate the display string from the meaning string.
// QuoteCard.tsximport { Text } from "react-native";import { applyKinsoku } from "./kinsoku";export function QuoteCard({ quote }: { quote: string }) { return ( <Text style={{ fontSize: 22, lineHeight: 36 }} lineBreakStrategyIOS="push-out" // safety net for anything pre-binding misses textBreakStrategy="balanced" // even out Android line lengths accessibilityLabel={quote} // hand the original to the screen reader > {applyKinsoku(quote)} </Text> );}
Passing the original to accessibilityLabel is one line. Before it, VoiceOver had unnatural pauses mid-sentence.
If you implement your own copy action, pass the original there too: Clipboard.setStringAsync(quote), never the rendered string.
Image export is a different case. My apps rasterize the card with react-native-view-shot, and that path is fine with the display string — the joiners are invisible in pixels. Only the paths where text leaves the app as text need the original.
Runtime or build time
applyKinsoku is linear in string length. For a 120-character quote I measured under 0.1ms, so calling it at render time causes no trouble.
I moved it to build time for two other reasons.
The quote data ships as JSON. Keeping the rule table on the device means an App Store review every time I want to adjust a character class. Waiting on review to fix punctuation is not a trade I want.
The second reason is the audit. If I can prove zero violations before shipping, I never discover one on a physical device again.
// scripts/kinsoku-build.mjs — apply to JSON before shipping, then verifyimport { readFileSync, writeFileSync } from "node:fs";import { applyKinsoku } from "../src/text/kinsoku.js";const WJ = "";const src = JSON.parse(readFileSync("data/quotes.ja.json", "utf8"));const out = src.map((q) => ({ ...q, // Keep both. The original serves copy, search, and screen readers. bodyRaw: q.body, body: applyKinsoku(q.body),}));// Idempotence: applying twice must not add joinersconst doubled = out.filter((q) => applyKinsoku(q.body) !== q.body);if (doubled.length > 0) { throw new Error(`Double application changed ${doubled.length} entries`);}// Surrogate-pair integrity: stripping WJ must return the original exactlyconst broken = out.filter((q) => q.body.replaceAll(WJ, "") !== q.bodyRaw);if (broken.length > 0) { throw new Error(`${broken.length} entries do not round-trip`);}writeFileSync("data/quotes.ja.json", JSON.stringify(out, null, 2));console.log(`Applied to ${out.length} entries`);
The idempotence check earned its place. When I reorganized the pipeline, the script ran twice and shipped data with doubled joiners. Nothing looks different, so nothing tells you. Assert that a second pass produces no diff and the build stops there instead.
The round-trip check does the same job from the other side. If body.replaceAll(WJ, "") === bodyRaw holds, the original text is provably intact.
What happens in Rork Max (SwiftUI output)
I built the same card with Rork Max's generated code.
SwiftUI's Text goes through TextKit 2, and it handles Japanese more gracefully than React Native does. Commas, periods, and closing brackets almost never land at a line start.
Almost. The prolonged sound mark still falls. Naming the strategy explicitly clears it.
// QuoteText.swift — state the push-out strategy explicitlyimport SwiftUIstruct QuoteText: View { let quote: String var body: some View { Text(attributed) .font(.system(size: 22)) .lineSpacing(14) .accessibilityLabel(quote) // screen reader gets the original } private var attributed: AttributedString { var a = AttributedString(quote) let style = NSMutableParagraphStyle() style.lineBreakStrategy = .pushOut // push the preceding character down style.lineBreakMode = .byWordWrapping a.paragraphStyle = style return a }}
.pushOut moves the preceding character to the next line when a forbidden character would open one. Line count can grow, so a fixed-height card needs minimumScaleFactor alongside it.
React Native and Rork Max behave differently here. That gap matters if you run both.
Aspect
React Native (standard Rork)
SwiftUI (Rork Max)
Default rule handling
Partial on iOS, essentially none on Android
Largely works
Prolonged mark and small kana
Fall to line start
Prolonged mark can still fall
Platform divergence
iOS and Android disagree
Apple-only, so no divergence
What I recommend
Pre-binding plus attribute settings, two layers
.pushOut is usually enough
Choosing Rork Max reduces the typography burden. Choosing the React Native path means assuming from day one that you will solve this on the string side.
The order I rolled it out
I did not push this to six apps at once. The sequence:
Ship the audit first — onTextLayout counting violations, console.warn in dev builds only. Do not implement anything until a number exists
Apply pre-binding to one app, Japanese only, and check all four widths on hardware
Add idempotence and round-trip checks to CI. Skip this and double application is invisible
Point accessibilityLabel and the copy path back at the original. Listen to one quote through VoiceOver
Roll out to the remaining five, watching the audit log for strings that hit MAX_BIND_RUN
Putting step 3 after step 2 was my mistake. Because I applied first, I shipped a batch of doubled-joiner data and only noticed once the audit existed. Verification belongs before application.
If you also ship Chinese or Korean
My apps cover 16 languages, so I checked this too.
Rule tables are language-specific. Apply the Japanese table to Chinese and you get unintended binding, because Simplified Chinese punctuation follows different width-adjustment rules.
I branch on the language code and pass everything else through untouched.
export function applyKinsokuFor(locale: string, input: string): string { // Everything but Japanese defers to the platform default if (!locale.startsWith("ja")) return input; return applyKinsoku(input);}
Korean puts spaces between words, so the wrap-point problem barely arises structurally. Chinese would need its own table, but my Chinese quotes are short enough that the audit found no violations, so it stays out of scope. If that changes, I will measure before adding anything.
The screen Rork produced worked correctly. This was not a bug.
Whether text reads as Japanese sits outside the framework's remit. Working and reading well are two separate standards.
Ship the generated code as-is, or look at it once with a Japanese reader's eye. On a screen where the characters are the product, that difference shows.
Add the onTextLayout audit to one screen and measure your own violation rate. Once a number exists, the decision makes itself.
Without that iPhone SE on my desk, I would not have noticed for a long while. I hope the notes help with your own implementation.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.