From d9454dc7904c6cf01ecea9676129d9829a4b3db1 Mon Sep 17 00:00:00 2001 From: harang Date: Tue, 27 Oct 2026 23:06:17 +0900 Subject: [PATCH 1/4] docs: add bilingual Rspress documentation --- .github/workflows/ci.yml | 16 + .oxfmtrc.json | 11 +- .oxlintrc.json | 2 +- README.ko.md | 2 + README.md | 2 + docs/.gitignore | 6 + docs/README.md | 45 + docs/docs/en/_meta.json | 4 + docs/docs/en/_nav.json | 5 + docs/docs/en/api/_meta.json | 32 + docs/docs/en/api/component-props.md | 85 + docs/docs/en/api/effects.md | 113 + docs/docs/en/api/recipe.md | 86 + docs/docs/en/api/renderers.md | 98 + docs/docs/en/api/splitters.md | 109 + docs/docs/en/api/timelines.md | 101 + docs/docs/en/guide/_meta.json | 4 + docs/docs/en/guide/getting-started/_meta.json | 1 + .../en/guide/getting-started/installation.md | 69 + .../docs/en/guide/getting-started/overview.md | 48 + .../en/guide/getting-started/quick-start.md | 86 + docs/docs/en/guide/usage/_meta.json | 1 + docs/docs/en/guide/usage/accessibility.md | 59 + docs/docs/en/guide/usage/examples.md | 153 ++ docs/docs/en/guide/usage/performance.md | 41 + docs/docs/en/guide/usage/playback.md | 140 + docs/docs/en/guide/usage/presets.md | 69 + docs/docs/en/guide/usage/recipes.md | 102 + docs/docs/en/guide/usage/segmentation.md | 53 + docs/docs/en/index.md | 37 + docs/docs/ko/_meta.json | 4 + docs/docs/ko/_nav.json | 9 + docs/docs/ko/api/_meta.json | 32 + docs/docs/ko/api/component-props.md | 85 + docs/docs/ko/api/effects.md | 113 + docs/docs/ko/api/recipe.md | 86 + docs/docs/ko/api/renderers.md | 98 + docs/docs/ko/api/splitters.md | 109 + docs/docs/ko/api/timelines.md | 101 + docs/docs/ko/guide/_meta.json | 4 + docs/docs/ko/guide/getting-started/_meta.json | 1 + .../ko/guide/getting-started/installation.md | 69 + .../docs/ko/guide/getting-started/overview.md | 48 + .../ko/guide/getting-started/quick-start.md | 86 + docs/docs/ko/guide/usage/_meta.json | 1 + docs/docs/ko/guide/usage/accessibility.md | 59 + docs/docs/ko/guide/usage/examples.md | 153 ++ docs/docs/ko/guide/usage/performance.md | 41 + docs/docs/ko/guide/usage/playback.md | 140 + docs/docs/ko/guide/usage/presets.md | 69 + docs/docs/ko/guide/usage/recipes.md | 102 + docs/docs/ko/guide/usage/segmentation.md | 53 + docs/docs/ko/index.md | 37 + docs/docs/public/logo.png | Bin 0 -> 184611 bytes docs/env.d.ts | 1 + docs/package.json | 22 + docs/rspress.config.ts | 47 + docs/scripts/check-docs.mjs | 124 + docs/theme/index.css | 15 + docs/theme/index.tsx | 27 + docs/tsconfig.json | 8 + package.json | 5 + pnpm-lock.yaml | 2261 ++++++++++++++++- pnpm-workspace.yaml | 1 + 64 files changed, 5567 insertions(+), 24 deletions(-) create mode 100644 docs/.gitignore create mode 100644 docs/README.md create mode 100644 docs/docs/en/_meta.json create mode 100644 docs/docs/en/_nav.json create mode 100644 docs/docs/en/api/_meta.json create mode 100644 docs/docs/en/api/component-props.md create mode 100644 docs/docs/en/api/effects.md create mode 100644 docs/docs/en/api/recipe.md create mode 100644 docs/docs/en/api/renderers.md create mode 100644 docs/docs/en/api/splitters.md create mode 100644 docs/docs/en/api/timelines.md create mode 100644 docs/docs/en/guide/_meta.json create mode 100644 docs/docs/en/guide/getting-started/_meta.json create mode 100644 docs/docs/en/guide/getting-started/installation.md create mode 100644 docs/docs/en/guide/getting-started/overview.md create mode 100644 docs/docs/en/guide/getting-started/quick-start.md create mode 100644 docs/docs/en/guide/usage/_meta.json create mode 100644 docs/docs/en/guide/usage/accessibility.md create mode 100644 docs/docs/en/guide/usage/examples.md create mode 100644 docs/docs/en/guide/usage/performance.md create mode 100644 docs/docs/en/guide/usage/playback.md create mode 100644 docs/docs/en/guide/usage/presets.md create mode 100644 docs/docs/en/guide/usage/recipes.md create mode 100644 docs/docs/en/guide/usage/segmentation.md create mode 100644 docs/docs/en/index.md create mode 100644 docs/docs/ko/_meta.json create mode 100644 docs/docs/ko/_nav.json create mode 100644 docs/docs/ko/api/_meta.json create mode 100644 docs/docs/ko/api/component-props.md create mode 100644 docs/docs/ko/api/effects.md create mode 100644 docs/docs/ko/api/recipe.md create mode 100644 docs/docs/ko/api/renderers.md create mode 100644 docs/docs/ko/api/splitters.md create mode 100644 docs/docs/ko/api/timelines.md create mode 100644 docs/docs/ko/guide/_meta.json create mode 100644 docs/docs/ko/guide/getting-started/_meta.json create mode 100644 docs/docs/ko/guide/getting-started/installation.md create mode 100644 docs/docs/ko/guide/getting-started/overview.md create mode 100644 docs/docs/ko/guide/getting-started/quick-start.md create mode 100644 docs/docs/ko/guide/usage/_meta.json create mode 100644 docs/docs/ko/guide/usage/accessibility.md create mode 100644 docs/docs/ko/guide/usage/examples.md create mode 100644 docs/docs/ko/guide/usage/performance.md create mode 100644 docs/docs/ko/guide/usage/playback.md create mode 100644 docs/docs/ko/guide/usage/presets.md create mode 100644 docs/docs/ko/guide/usage/recipes.md create mode 100644 docs/docs/ko/guide/usage/segmentation.md create mode 100644 docs/docs/ko/index.md create mode 100644 docs/docs/public/logo.png create mode 100644 docs/env.d.ts create mode 100644 docs/package.json create mode 100644 docs/rspress.config.ts create mode 100644 docs/scripts/check-docs.mjs create mode 100644 docs/theme/index.css create mode 100644 docs/theme/index.tsx create mode 100644 docs/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12084bf..8349eff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,19 @@ jobs: - name: Validate Expo config run: pnpm run example:build + + build-docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + + - name: Setup + uses: ./.github/actions/setup + + - name: Check documentation + run: pnpm run docs:check + + - name: Build documentation + run: pnpm run docs:build diff --git a/.oxfmtrc.json b/.oxfmtrc.json index ded30a7..93d871f 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,15 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["lib", "node_modules", "ios", "android", "dist", "**/*.md"], + "ignorePatterns": [ + "lib", + "node_modules", + "ios", + "android", + "dist", + "doc_build", + ".rspress", + "**/*.md" + ], "sortImports": { "groups": [ "type-import", diff --git a/.oxlintrc.json b/.oxlintrc.json index ff2449c..90d9ec5 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -58,5 +58,5 @@ "globals": { "__DEV__": "readonly" }, - "ignorePatterns": ["lib", "node_modules", "ios", "android", "dist"] + "ignorePatterns": ["lib", "node_modules", "ios", "android", "dist", "doc_build", ".rspress"] } diff --git a/README.ko.md b/README.ko.md index e550ee1..d0ae5f2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,5 +1,7 @@ # @react-native-motion-kit/text-motion +한영 Rspress 문서: [개발 및 빌드 안내](./docs/README.md). `pnpm docs:dev`로 로컬 문서를 실행할 수 있습니다. + React Native에서 고성능 split text animation을 만들기 위한 라이브러리입니다. Reanimated 기반으로 동작하고, 확장 가능한 renderer boundary를 염두에 두고 있습니다. English documentation: [README.md](./README.md) diff --git a/README.md b/README.md index 8319169..32f7da6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Korean documentation: [README.ko.md](./README.ko.md) Package documentation: [packages/text-motion/README.md](./packages/text-motion/README.md) +Bilingual Rspress documentation: [development and build instructions](./docs/README.md). Run `pnpm docs:dev` to browse locally. + This repository is a pnpm workspace for the `@react-native-motion-kit/text-motion` package and Expo example app. ## Package diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..c49e8cb --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +doc_build/ +.rspress/ +.cache/ +*.local +*.log diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..29969b5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,45 @@ +# React Native Text Motion documentation + +This pnpm workspace uses Rspress 2, following the structure of the Gesture Image +Viewer documentation. It documents the current public `0.1.0` API in English and +Korean, with one set of routes and no historical version selector. + +## Develop + +Run commands from the repository root: + +```sh +pnpm install +pnpm docs:dev +``` + +English is served at `/`; Korean is served at `/ko/`. Use the language menu to +switch to the corresponding page. The home page extends the default theme with +package-manager installation tabs. + +## Verify and build + +```sh +pnpm docs:check +pnpm docs:typecheck +pnpm docs:build +pnpm docs:preview +``` + +The static build is written to `docs/doc_build/`. A static host such as Cloudflare +Pages can use `pnpm docs:build` as its build command and `docs/doc_build` as its +output directory, with the repository root as the working directory. Deployment +and a production domain must be configured separately. + +## Maintain + +- Keep matching pages under `docs/en/` and `docs/ko/` in sync. `docs:check` checks + page parity, local links, and navigation metadata. +- Edit `_nav.json` for top navigation and `_meta.json` for sidebar ordering. +- Keep code samples aligned with the public exports and the Expo example app. +- Use `docs/public/` for shared static files and `theme/` for theme extensions. +- Rspress generates `llms.txt` and full documentation text for AI tools. +- Link to official setup instructions for Reanimated and Worklets; the package's + peer minimums are not a complete native dependency compatibility matrix. +- Hosted demos are not configured. The examples page provides working recipes + and instructions for running the local Expo app. diff --git a/docs/docs/en/_meta.json b/docs/docs/en/_meta.json new file mode 100644 index 0000000..9c1dd78 --- /dev/null +++ b/docs/docs/en/_meta.json @@ -0,0 +1,4 @@ +[ + { "type": "dir", "name": "guide", "label": "Guide" }, + { "type": "dir", "name": "api", "label": "API Reference" } +] diff --git a/docs/docs/en/_nav.json b/docs/docs/en/_nav.json new file mode 100644 index 0000000..80eacc6 --- /dev/null +++ b/docs/docs/en/_nav.json @@ -0,0 +1,5 @@ +[ + { "text": "Guide", "link": "/guide/getting-started/overview.html", "activeMatch": "/guide/" }, + { "text": "API", "link": "/api/recipe.html", "activeMatch": "/api/" }, + { "text": "Examples", "link": "/guide/usage/examples.html" } +] diff --git a/docs/docs/en/api/_meta.json b/docs/docs/en/api/_meta.json new file mode 100644 index 0000000..786ebf5 --- /dev/null +++ b/docs/docs/en/api/_meta.json @@ -0,0 +1,32 @@ +[ + { + "type": "file", + "name": "recipe", + "label": "Recipe" + }, + { + "type": "file", + "name": "splitters", + "label": "Splitters" + }, + { + "type": "file", + "name": "renderers", + "label": "Renderers" + }, + { + "type": "file", + "name": "timelines", + "label": "Timelines" + }, + { + "type": "file", + "name": "effects", + "label": "Effects" + }, + { + "type": "file", + "name": "component-props", + "label": "Component Props" + } +] diff --git a/docs/docs/en/api/component-props.md b/docs/docs/en/api/component-props.md new file mode 100644 index 0000000..541ea4e --- /dev/null +++ b/docs/docs/en/api/component-props.md @@ -0,0 +1,85 @@ +# Component Props + +Calling a recipe's `.component()` returns a `TextMotionComponent`. Its props are `TextMotionComponentProps`, shared by both built-in renderers. + +## Text and styling + +| Prop | Type | Behavior | +| --- | --- | --- | +| `children` | `string` | Required plain text. Nested elements, arrays, and rich text are not supported. | +| `style` | `StyleProp` | Text styling. `nativeText()` applies it to token text; `overlayText()` uses it for the measured paragraph. | +| `testID` | `string` | Identifies the parent container. | +| `nativeID` | `string` | Passed to the parent container. | +| `allowFontScaling` | React Native `TextProps['allowFontScaling']` | Forwarded to text rendering. | +| `maxFontSizeMultiplier` | React Native `TextProps['maxFontSizeMultiplier']` | Forwarded to text rendering. | + +The component requires a string at runtime as well as in TypeScript. To include authored newlines, use a string expression such as `{'First line\nSecond line'}`. Use a surrounding `View` for external layout constraints instead of assuming all `Text` or `View` props are forwarded. + +```tsx +import { + defineTextMotion, + fade, + nativeText, + words, +} from '@react-native-motion-kit/text-motion'; +import { View } from 'react-native'; + +const Title = defineTextMotion() + .split(words()) + .layout(nativeText()) + .effect(fade()) + .component(); + +export function Heading() { + return ( + + + Make room for readable motion + + + ); +} +``` + +## Playback + +| Prop | Type | Behavior | +| --- | --- | --- | +| `controls` | `TextMotionControls` | Event-driven `play`, `replay`, `reset`, and `stop`; internal playback uses the recipe's `.motion()` config. | +| `progress` | Reanimated `SharedValue` | App-owned whole-text progress; internal autoplay and `.motion()` do not drive that instance. | + +**`controls` and `progress` are mutually exclusive.** TypeScript rejects the combination and both renderers also throw at runtime. With neither prop, animated content autoplays on mount. + +`progress` is normalized: `0` is initial and `1` is final. Finite values outside that range are clamped, and non-finite values are treated as `0`. Timeline delays map it to per-unit progress, so it is not a visible-character percentage. See [Playback](../guide/usage/playback.md) for complete controls and progress examples. + +## Accessibility + +The supported accessibility props are: + +- `accessibilityLabel` +- `accessibilityHint` +- `accessibilityRole` +- `accessibilityState` +- `accessibilityValue` +- `accessibilityActions` +- `accessibilityLanguage` +- `onAccessibilityAction` + +The default parent label is the complete original string; `accessibilityLabel` overrides it. The policy owns whether the parent is accessible and whether decorative nodes are hidden. Configure it through `.accessibility(parentLabelPolicy(...))`, rather than passing an `accessible` prop. See [Accessibility](../guide/usage/accessibility.md). + +## Lifecycle + +Ordinary parent rerenders with the same text and recipe preserve in-flight playback. Text changes are enter-only: old content is not retained for an exit, crossfade, or token diff. In controls mode, later commands target the current text. In external-progress mode, the current shared value determines the new text's state. + +Changes to effect, timeline, or motion configuration can reset affected animations. Keep recipe components and function-valued motion options stable when playback should survive parent rerenders. + +## Deliberately narrow surface + +These components are not full React Native `Text` replacements. Props such as `numberOfLines`, `ellipsizeMode`, `lineBreakMode`, `onTextLayout`, `selectable`, text `onPress`, and nested styled text are outside the current contract. Put interaction or external layout on an appropriate surrounding component. + +`TextMotionComponent`, `TextMotionComponentProps`, `TextMotionComponentTextProps`, and `TextMotionComponentAccessibilityProps` are exported from the root for typed application code. Controls use `useTextMotionControls()` and the exported `TextMotionControls` type; there is no public component-ref or context/provider playback API. diff --git a/docs/docs/en/api/effects.md b/docs/docs/en/api/effects.md new file mode 100644 index 0000000..e2636fd --- /dev/null +++ b/docs/docs/en/api/effects.md @@ -0,0 +1,113 @@ +# Effects + +Effects describe the visual change of each motion unit. Import them from `@react-native-motion-kit/text-motion` and pass a compatible effect to `.effect(...)` after selecting a renderer. + +## Factories and defaults + +Every option is optional; `typewriter()` takes no options. All numeric effect inputs must be finite. Directional offsets can be negative. Offsets use React Native layout units, opacity is a scalar, and scale values are multipliers. + +| Factory | Options and defaults | Behavior | Renderer | +| --- | --- | --- | --- | +| `fade(options?)` | `from: 0`, `to: 1` | Changes opacity from `from` to `to` | Both | +| `rise(options?)` | `y: 12` | Starts offset downward by `y`, then moves into place | Both | +| `slide(options?)` | `x: 0`, `y: 12` | Starts at an x/y offset, then moves into place | Both | +| `scale(options?)` | `from: 0.96`, `to: 1` | Changes the scale multiplier | Both | +| `pulse(options?)` | `scale: 1.04` | Peaks around the midpoint, then returns to the composed final scale | Both | +| `shake(options?)` | `x: 4` | Starts with a horizontal offset for a shake-like entrance | Both | +| `lineReveal(options?)` | `y: 16`, `fromOpacity: 0` | Reveals rendered lines through masks | `overlayText()` | +| `typewriter()` | None | Hides a token until its local progress becomes positive | `nativeText()` | + +`shake()` is an entrance offset; it does not create a repeating oscillation. `pulse()` provides midpoint emphasis; use `scale()` when the starting or ending size should change. + +The root exports `FadeOptions`, `RiseOptions`, `SlideOptions`, `ScaleOptions`, `PulseOptions`, `ShakeOptions`, and `LineRevealOptions`. Their properties match the table above. + +## Compose with `.and(...)` + +```tsx +import { + defineTextMotion, + fade, + nativeText, + rise, + scale, + stagger, + words, +} from '@react-native-motion-kit/text-motion'; + +const HeroTitle = defineTextMotion() + .split(words()) + .layout(nativeText()) + .timeline(stagger(0.04)) + .effect(rise({ y: 14 }).and(fade()).and(scale({ from: 0.98 }))) + .component(); + +export function Heading() { + return A little motion, a clear message; +} +``` + +`.and(...)` returns a composed `TextMotionEffect`. Repeated `.effect(...)` calls also append effects. Transform offsets accumulate and scale multipliers multiply; opacity-setting effects are applied in composition order. Prefer one clear opacity effect when combining recipes. + +Effects require compatible renderer capabilities. `lineReveal()` cannot be used with `nativeText()`, and `typewriter()` cannot be used with `overlayText()`. TypeScript checks valid combinations; runtime checks also reject incompatible effects. + +## `lineReveal()` + +```tsx +import { + defineTextMotion, + lineReveal, + overlayText, + stagger, +} from '@react-native-motion-kit/text-motion'; + +const HeroLines = defineTextMotion() + .layout(overlayText()) + .timeline(stagger(0.08)) + .effect(lineReveal({ y: 18, fromOpacity: 0 })) + .motion({ kind: 'timing', options: { duration: 420 } }) + .component(); +``` + +Do not add a splitter. The renderer measures actual wrapped lines. The reveal offset moves the paragraph copy inside each mask, while opacity applies to the line frame. Composed `scale()` and `pulse()` transform the measured line frame. See [Renderers](./renderers.md) for layout and measurement behavior. + +## `typewriter()` + +Use this for fixed-layout character reveals. The splitter chooses the reveal unit, the timeline chooses its start, and `.motion()` chooses any subsequent movement. + +```tsx +import { + defineTextMotion, + graphemes, + nativeText, + parentLabelPolicy, + stagger, + typewriter, +} from '@react-native-motion-kit/text-motion'; + +const TypewriterText = defineTextMotion() + .split(graphemes()) + .layout(nativeText()) + .timeline(stagger(0.04)) + .effect(typewriter()) + .motion({ kind: 'timing', options: { duration: 0 } }) + .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' })) + .component(); + +export function Greeting() { + return 안녕 세계! 👩🏽‍💻; +} +``` + +The completed phrase's space and line breaks remain in place from the start. Visible graphemes get delay slots 40ms apart; whitespace and explicit newlines keep their layout without taking a slot. Punctuation takes a slot like other visible characters. The first character appears on the first playback update, and display timing follows device frames. + +`duration: 0` makes each reveal instant in autoplay or controls mode. To move a character after it appears, use a nonzero duration and compose an effect such as `typewriter().and(rise({ y: 6 }))`. Composing `fade()` intentionally makes appearance gradual. The visibility gate works in either composition order; adding `typewriter()` twice does not add another clock. + +Use [controls](../guide/usage/playback.md) for replay/reset/stop. `stop()` preserves the current state; a later `play()` does not promise exact continuation of the remaining typing schedule. To type a new phrase fully from its start, remount with a new React `key`. Appending text is not a streaming API. + +With external `progress`, `0` hides the animated characters and `1` shows the final state. **Progress is timeline progress, not the percentage of characters shown.** For maximum delay `D` and a character's delay `d`, local progress is `clamp(progress × (D + 1) - d, 0, 1)`. A character becomes visible when this is greater than zero. Decreasing progress past its start hides it again. `.motion()` is ignored in external-progress mode. + +This effect does not grow or delete the underlying string, provide a cursor, guarantee single-`Text` shaping, or implement streaming. Use it with short UI copy and measure larger workloads. + +## Public scope + +Effect handles expose `.and(...)`; internal `name`, `options`, and `requiredCapabilities` fields are not public. Custom effect factories, `wipe`, `scramble`, and Skia effects are outside the current API. diff --git a/docs/docs/en/api/recipe.md b/docs/docs/en/api/recipe.md new file mode 100644 index 0000000..7224e31 --- /dev/null +++ b/docs/docs/en/api/recipe.md @@ -0,0 +1,86 @@ +# Recipe API + +Import the public factories from `@react-native-motion-kit/text-motion`. Presets are exported separately from `@react-native-motion-kit/text-motion/presets`. + +## `defineTextMotion()` + +Starts a recipe builder. Every configuration method returns a new builder, so a base recipe can be reused without changing earlier recipes. + +```tsx +import { + defineTextMotion, + fade, + nativeText, + rise, + stagger, + words, +} from '@react-native-motion-kit/text-motion'; + +const base = defineTextMotion() + .split(words()) + .layout(nativeText()) + .timeline(stagger(0.04)); + +const FadeTitle = base.effect(fade()).component(); +const RisingTitle = base.effect(fade().and(rise({ y: 12 }))).component(); + +export function Heading() { + return Motion made for text; +} +``` + +Create recipe components outside the rendering function so ordinary rerenders preserve their React identity. + +## Builder methods + +| Method | Input | Behavior | +| --- | --- | --- | +| `.split(splitter)` | A [splitter](./splitters.md) | Chooses source tokens. Omit this for `overlayText()`. | +| `.layout(renderer)` | A [renderer](./renderers.md) | Selects how text is laid out and rendered. Required before `.component()`. | +| `.timeline(timeline)` | A [timeline](./timelines.md) | Chooses each visible motion unit's start delay. | +| `.effect(effect)` | A compatible [effect](./effects.md) | Appends an effect; repeated calls accumulate effects. | +| `.motion(config)` | `TextMotionMotionConfig` | Configures autoplay and controls-driven playback. | +| `.accessibility(policy)` | `TextMotionAccessibilityPolicy` | Configures parent labeling and reduced motion. | +| `.recipe()` | None | Returns a configuration snapshot; allowed before a renderer is chosen. | +| `.component()` | None | Creates a React component accepting [text-motion props](./component-props.md). | + +Repeated `.split()`, `.layout()`, `.timeline()`, `.motion()`, and `.accessibility()` calls replace that part of the new recipe. Select the renderer before adding effects so TypeScript can check its capabilities. A rendered-line recipe cannot use `.split(...)`; that combination is also rejected at runtime. + +With `nativeText()` and no splitter, the whole string is one token. With no timeline, all animated units start together. With no effects, their visual style stays unchanged. + +## `.motion(config)` + +```tsx +.motion({ kind: 'timing', options: { duration: 420 } }) +.motion({ kind: 'spring', options: { damping: 14, stiffness: 160 } }) +``` + +`TextMotionMotionConfig` is a discriminated union: + +```ts +type TextMotionMotionConfig = + | { kind: 'timing'; options?: TextMotionTimingOptions } + | { kind: 'spring'; options?: TextMotionSpringOptions }; +``` + +`TextMotionTimingOptions` follows Reanimated's `WithTimingConfig` and `TextMotionSpringOptions` follows `WithSpringConfig`, with `reduceMotion` excluded from both. Configure reduced motion through `.accessibility(...)`. The installed Reanimated version determines the remaining supported options. Numeric motion options must be finite. + +The default internal animation is timing with a **300ms** duration. Timeline inputs use **seconds**; `duration` uses **milliseconds**. Function-valued options such as `easing` are compared by reference: reuse the same function when a rerender should preserve playback. + +Passing a component an external `progress` shared value disables internal autoplay and ignores `.motion()` for that instance. See [Playback](../guide/usage/playback.md). + +## `.accessibility(parentLabelPolicy(options))` + +```tsx +import { parentLabelPolicy } from '@react-native-motion-kit/text-motion'; + +const policy = parentLabelPolicy({ reducedMotion: 'final-state' }); +``` + +`ParentLabelPolicyOptions` has one option, `reducedMotion?: 'system' | 'final-state'`, defaulting to `'system'`. The returned policy exposes one full-text accessible parent label and hides decorative token nodes. Both renderers use `parentLabelPolicy()` when no policy is supplied. See [Accessibility](../guide/usage/accessibility.md) for renderer behavior and recommended usage. + +## Public handles and types + +`TextMotionSplitter`, `TextMotionRenderer`, `TextMotionTimeline`, and `TextMotionEffect` are opaque handles returned by the public factories. Pass them to the builder; use `.and(...)` to compose effects. Their internal `kind`, `name`, `options`, `delayFor`, `Component`, and capability arrays are not a supported inspection or extension API. + +The root also exports `TextMotionRendererCapability`, `TextMotionRendererProps`, and `TextMotionToken` as types. Exported types do not make internal factory functions public. Custom effect factories, custom renderer factories, descriptor readers, and capability factories are outside the current root API. diff --git a/docs/docs/en/api/renderers.md b/docs/docs/en/api/renderers.md new file mode 100644 index 0000000..abcf552 --- /dev/null +++ b/docs/docs/en/api/renderers.md @@ -0,0 +1,98 @@ +# Renderers + +Choose a renderer with `.layout(...)` before calling `.component()`. Both factories return opaque handles, not React components. + +| Renderer | Motion unit | Splitter | Supported effects | +| --- | --- | --- | --- | +| `nativeText()` | Source word, grapheme, authored line, or custom token | Optional | `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `typewriter` | +| `overlayText()` | Actual React Native rendered line | Not allowed | `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `lineReveal` | + +## `nativeText(options?)` + +```ts +type NativeTextRendererOptions = { + testIDPrefix?: string; +}; +``` + +The stable default choice for short titles, labels, onboarding text, and product copy. It renders animated tokens inside animated `View` containers with inner `Text` nodes so transforms are visible. Static tokens remain plain text nodes. Explicit newlines preserve authored line breaks without consuming motion timing slots. + +```tsx +import { + defineTextMotion, + fade, + nativeText, + rise, + stagger, + words, +} from '@react-native-motion-kit/text-motion'; + +const WordReveal = defineTextMotion() + .split(words()) + .layout(nativeText({ testIDPrefix: 'title-word' })) + .timeline(stagger(0.04)) + .effect(fade().and(rise({ y: 12 }))) + .component(); + +export function Heading() { + return Make words move; +} +``` + +`testIDPrefix` has no default. It creates generated token test IDs; an animated token's ID belongs to its animated container. The component's `testID` identifies the parent. Typography props such as `allowFontScaling` reach the inner text. + +This renderer uses a wrapping token layout, so exact single-`Text` shaping, kerning, ligatures, and automatic line wrapping are not guaranteed. `textAlign` maps to token-row alignment; it does not turn the renderer into a full native paragraph implementation. Use `overlayText()` for actual rendered-line motion, and ordinary React Native `Text` for rich inline content or exact text-node behavior. + +## `overlayText(options?)` + +```ts +type OverlayTextRendererOptions = { + testIDPrefix?: string; +}; +``` + +Measures one native paragraph and animates masks for each actual rendered line. Use this when line breaks depend on available width, font, font scale, alignment, or language shaping. **Do not call `.split(...)`.** TypeScript and runtime checks reject that combination. + +```tsx +import { + defineTextMotion, + lineReveal, + overlayText, + scale, + stagger, +} from '@react-native-motion-kit/text-motion'; +import { View } from 'react-native'; + +const HeroLines = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'hero-line' })) + .timeline(stagger(0.08)) + .effect(lineReveal({ y: 18 }).and(scale({ from: 0.98 }))) + .motion({ kind: 'timing', options: { duration: 420 } }) + .component(); + +export function Hero() { + return ( + + + A responsive title that reveals one rendered line at a time + + + ); +} +``` + +Give the paragraph a meaningful available width. Measurement is based on native text layout, not a guessed character count. Changes to text or layout inputs trigger measurement updates. If usable line mapping is unavailable, the renderer keeps readable source text instead of animating guessed line geometry. When system reduced motion is enabled, it renders the final source paragraph directly. + +`testIDPrefix` has no default and prefixes generated overlay/line-mask IDs. `lineReveal()` moves the paragraph copy inside each mask while applying its opacity to the line frame. Other transform effects operate on the measured line frame, so a composed `scale()` or `pulse()` can grow around the line center without being clipped by the renderer's own reveal mask. + +This is rendered-line motion, not word-in-line or character-in-line mapping. It does not expose `onTextLayout`, `numberOfLines`, text selection, or inline links as component props. + +## Shared playback and accessibility + +Both renderers support autoplay, explicit [controls](../guide/usage/playback.md), and app-owned `progress`. Never pass both `controls` and `progress`. The default accessibility policy exposes the complete phrase once and hides decorative nodes; a component-level `accessibilityLabel` overrides the spoken label. + +Read [Component Props](./component-props.md), [Accessibility](../guide/usage/accessibility.md), and [Performance](../guide/usage/performance.md) before using many tokens or rows. Browser rendering is not evidence of iOS/Android layout or performance. + +## Renderer types + +The root exports `NativeTextRendererOptions`, `OverlayTextRendererOptions`, `TextMotionRenderer`, `TextMotionRendererCapability`, and `TextMotionRendererProps`. Renderer descriptors, implementation `Component` fields, and capability factories remain internal. A public renderer-extension SDK is outside the current API. diff --git a/docs/docs/en/api/splitters.md b/docs/docs/en/api/splitters.md new file mode 100644 index 0000000..9431039 --- /dev/null +++ b/docs/docs/en/api/splitters.md @@ -0,0 +1,109 @@ +# Splitters + +Splitters choose source-text tokens for `nativeText()`. They return an opaque `TextMotionSplitter` handle used by `.split(...)`. + +```tsx +import { + custom, + defineTextMotion, + fade, + graphemes, + lines, + nativeText, + stagger, + words, +} from '@react-native-motion-kit/text-motion'; + +const WordTitle = defineTextMotion() + .split(words()) + .layout(nativeText()) + .timeline(stagger(0.04)) + .effect(fade()) + .component(); +``` + +`overlayText()` owns actual rendered-line measurement and cannot be combined with any splitter. See [Renderers](./renderers.md). + +## `words()` + +Takes no options. Produces word, spacing, and punctuation tokens using `Intl.Segmenter` when available, with an internal fallback otherwise. Whitespace preserves layout but does not consume a motion index; visible punctuation can consume one. + +Words follow the runtime's segmentation rules. This is not a promise that every language separates words by spaces. The generated token metadata includes `isWordLike`. + +## `graphemes()` + +Takes no options. Produces grapheme-aware tokens for character-level motion. Prefer it over `text.split('')`, which can break surrogate pairs and combining sequences. + +The fallback handles common emoji sequences and decomposed Hangul such as `한` without normalizing the source, but does not implement every Unicode grapheme rule. Some complex-script conjuncts can still split. See [Segmentation](../guide/usage/segmentation.md) before relying on exact locale boundaries. + +## `lines()` — experimental + +Takes no options. Splits at explicit `\n` boundaries and preserves newline text in the token's source range. It **does not** measure automatic wrapping caused by width, fonts, or font scaling. + +```tsx +const AuthoredLines = defineTextMotion() + .split(lines()) + .layout(nativeText()) + .effect(fade()) + .component(); + +export function Heading() { + return {'First line\nSecond line'}; +} +``` + +Tokens have `metadata: { experimental: true, mapping: 'newline-only' }`. Use `overlayText()` and `lineReveal()` when you need the lines React Native actually rendered. + +## `custom(split)` + +```ts +type TextMotionCustomSplit = ( + input: string, + context?: { locale?: string | string[] }, +) => readonly TextMotionCustomSplitResult[]; + +type TextMotionCustomSplitResult = string | TextMotionCustomSplitToken; + +type TextMotionCustomSplitToken = { + text: string; + sourceRange?: { start: number; end: number }; + metadata?: Record; +}; +``` + +Return strings or token objects. String results get inferred source ranges by searching forward from the previous token's end. Return explicit ranges when matching is ambiguous or metadata matters. Ranges use JavaScript string indices (UTF-16 code units), with an exclusive `end`. + +```tsx +const phrases = custom((input) => { + const separator = input.indexOf(' / '); + if (separator < 0) return [input]; + + return [ + { text: input.slice(0, separator), sourceRange: { start: 0, end: separator } }, + { text: ' / ', sourceRange: { start: separator, end: separator + 3 } }, + { + text: input.slice(separator + 3), + sourceRange: { start: separator + 3, end: input.length }, + }, + ]; +}); +``` + +Preserve all characters and spacing that should remain visible. A custom splitter does not repair or validate product-specific segmentation decisions. The optional context is part of the callback shape; recipe components currently call splitters without passing a locale, so there is no component `locale` prop. + +## Token type + +`TextMotionToken` is exported for typed integrations: + +```ts +type TextMotionToken = { + id: string; + text: string; + index: number; + unit: 'grapheme' | 'word' | 'line' | 'custom'; + sourceRange: { start: number; end: number }; + metadata?: Record; +}; +``` + +A splitter handle does not expose a public `.split()` method or its internal descriptor. Pass the handle to the recipe builder instead. diff --git a/docs/docs/en/api/timelines.md b/docs/docs/en/api/timelines.md new file mode 100644 index 0000000..1cb8bc7 --- /dev/null +++ b/docs/docs/en/api/timelines.md @@ -0,0 +1,101 @@ +# Timelines + +Timelines assign start delays to visible motion units: source tokens for `nativeText()` and measured lines for `overlayText()`. **All timeline delays are in seconds.** Whitespace and non-animated layout units do not consume motion indices. + +```tsx +import { + defineTextMotion, + fade, + nativeText, + parallel, + sequence, + stagger, + wave, + words, +} from '@react-native-motion-kit/text-motion'; + +const Title = defineTextMotion() + .split(words()) + .layout(nativeText()) + .timeline(stagger(0.04, { from: 'center' })) + .effect(fade()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); +``` + +Here `0.04` means a 40ms delay step. The `300` motion duration is **milliseconds** per animated unit. Without a timeline, every unit has zero delay. + +## `stagger(step, options?)` + +```ts +type TextMotionStaggerFrom = 'start' | 'center' | 'end' | 'edges' | number; +type TextMotionStaggerOptions = { from?: TextMotionStaggerFrom }; +``` + +`step` is required, finite, and non-negative. `from` defaults to `'start'`. A numeric origin is a finite token index, not a percentage; it may be fractional. + +For unit index `i` and total count `n`, the delay is `step × distance`: + +| `from` | Distance | Pattern | +| --- | --- | --- | +| `'start'` | `i` | First to last | +| `'end'` | `n - 1 - i` | Last to first | +| `'center'` | `abs(i - (n - 1) / 2)` | Middle outward | +| `'edges'` | `min(i, n - 1 - i)` | Both ends inward | +| A number `k` | `abs(i - k)` | Outward from index `k` | + +```tsx +stagger(0.04); +stagger(0.04, { from: 'end' }); +stagger(0.04, { from: 'edges' }); +stagger(0.04, { from: 2 }); +``` + +## `wave(options?)` + +```ts +type TextMotionWaveOptions = { + amplitude?: number; + wavelength?: number; +}; +``` + +| Option | Default | Constraint | Meaning | +| --- | --- | --- | --- | +| `amplitude` | `0.08` | Finite, `>= 0` | Maximum delay in seconds | +| `wavelength` | `4` | Finite, `> 0` | Number of motion units per sine-wave cycle | + +The delay for index `i` is `((sin(i / wavelength × 2π) + 1) / 2) × amplitude`. The first unit therefore starts at half the amplitude; a wave does not promise left-to-right reveal order. + +```tsx +wave(); +wave({ amplitude: 0.06, wavelength: 5 }); +``` + +## `sequence(...timelines)` + +Accepts zero or more timeline handles and **adds their delays for each unit**. This composes delay patterns; it does not wait for one whole animation to finish before starting another. + +```tsx +sequence(stagger(0.02), wave({ amplitude: 0.04, wavelength: 4 })); +``` + +`sequence()` with no arguments produces zero delay. + +## `parallel(...timelines)` + +Accepts zero or more timeline handles and chooses the **earliest delay for each unit**. + +```tsx +parallel(stagger(0.08), wave({ amplitude: 0.05, wavelength: 3 })); +``` + +`parallel()` with no arguments produces zero delay. It does not create independent playback tracks. + +## Controlled progress + +In [external progress mode](../guide/usage/playback.md), timeline delays still shape each unit's local progress. The app controls the shared value's timing curve; `.motion()` does not run. A timeline is a delay pattern, not a separate playback controller. + +## Handles and validation + +The root exports `TextMotionTimeline`, `TextMotionStaggerFrom`, `TextMotionStaggerOptions`, and `TextMotionWaveOptions`. Numeric factory inputs are validated as described above. Returned timelines are opaque handles: `delayFor`, `name`, and `options` are not public properties. Pass timelines to `.timeline(...)`, `sequence(...)`, or `parallel(...)`. diff --git a/docs/docs/en/guide/_meta.json b/docs/docs/en/guide/_meta.json new file mode 100644 index 0000000..6dea2c3 --- /dev/null +++ b/docs/docs/en/guide/_meta.json @@ -0,0 +1,4 @@ +[ + { "type": "dir", "name": "getting-started", "label": "Getting Started" }, + { "type": "dir", "name": "usage", "label": "Usage" } +] diff --git a/docs/docs/en/guide/getting-started/_meta.json b/docs/docs/en/guide/getting-started/_meta.json new file mode 100644 index 0000000..4b83f28 --- /dev/null +++ b/docs/docs/en/guide/getting-started/_meta.json @@ -0,0 +1 @@ +["overview", "installation", "quick-start"] diff --git a/docs/docs/en/guide/getting-started/installation.md b/docs/docs/en/guide/getting-started/installation.md new file mode 100644 index 0000000..b74488b --- /dev/null +++ b/docs/docs/en/guide/getting-started/installation.md @@ -0,0 +1,69 @@ +# Installation + +Text Motion uses React Native Reanimated and React Native Worklets. Set up compatible versions of these dependencies in your app before rendering animated text. + +## Requirements + +The package declares these peer dependency minimums: + +| Dependency | Declared range | +| --- | --- | +| `react` | `>=18.0.0` | +| `react-native` | `>=0.76.0` | +| `react-native-reanimated` | `>=4.0.0` | +| `react-native-worklets` | `>=0.5.0` | + +These ranges are not a compatibility matrix. Choose a React Native, Reanimated, and Worklets combination supported by the [Reanimated compatibility table](https://docs.swmansion.com/react-native-reanimated/docs/guides/compatibility/). Reanimated 4 requires the React Native New Architecture. + +## Install the packages + +For an existing React Native app: + +```sh +pnpm add @react-native-motion-kit/text-motion react-native-reanimated react-native-worklets +``` + +With npm or Yarn, use the equivalent `npm install` or `yarn add` command. + +For an Expo app, let Expo choose the Reanimated and Worklets versions for your SDK: + +```sh +npx expo install react-native-reanimated react-native-worklets +pnpm add @react-native-motion-kit/text-motion +``` + +Check that your SDK's selected versions satisfy the peer ranges above. Follow the [Expo Reanimated guide](https://docs.expo.dev/versions/latest/sdk/reanimated/) for the setup required by your SDK and build type. + +## Configure Worklets + +Follow the official [Reanimated getting-started guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/) and [Worklets getting-started guide](https://docs.swmansion.com/react-native-worklets/docs/fundamentals/getting-started/) for your environment. + +In a bare React Native app using Babel, add the Worklets plugin as the **last** plugin in the existing configuration: + +```js +module.exports = { + presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-worklets/plugin'], +}; +``` + +Preserve any other presets and plugins your app needs. Expo's Babel preset may configure this for your SDK; use the SDK-specific setup rather than adding a duplicate plugin. + +After adding native dependencies, install iOS pods through your app's existing CocoaPods workflow and rebuild the native app. Restart Metro with its cache reset if the Babel configuration changed. Text Motion itself does not require an additional Babel plugin, provider, or native configuration. + +## Import from the public entrypoints + +```tsx +import { + defineTextMotion, + fade, + nativeText, + stagger, + words, +} from '@react-native-motion-kit/text-motion'; +import { editorialRise } from '@react-native-motion-kit/text-motion/presets'; +``` + +Built-in primitives come from the package root. The three presets come from `/presets`. Do not import internal renderer or descriptor files. + +Continue to the [quick start](./quick-start.md). diff --git a/docs/docs/en/guide/getting-started/overview.md b/docs/docs/en/guide/getting-started/overview.md new file mode 100644 index 0000000..8eb98d3 --- /dev/null +++ b/docs/docs/en/guide/getting-started/overview.md @@ -0,0 +1,48 @@ +# Overview + +`@react-native-motion-kit/text-motion` creates reusable animated text components for React Native. Compose text splitting, rendering, timing, effects, and accessibility in a recipe, then render the result as a component. + +```txt +split → layout(renderer) → timeline → effect → motion/accessibility → component +``` + +This documentation describes the current `0.1.0` public API. + +## Choose your starting point + +Use a [preset](../usage/presets.md) for common title, label, and product-copy animation. Use a [custom recipe](../usage/recipes.md) when you need to choose the motion unit, timing, or effects. + +```tsx +import { editorialRise } from '@react-native-motion-kit/text-motion/presets'; + +const HeroTitle = editorialRise().component(); + +export function Hero() { + return Design motion that feels native; +} +``` + +The component accepts a plain string child. Define it outside your React render function so a parent rerender reuses the same component type. + +## What you can build + +| Need | Public API | +| --- | --- | +| Animate words or user-perceived characters | `words()` or `graphemes()` with `nativeText()` | +| Reveal the lines React Native actually wraps | `overlayText()` with `lineReveal()`, without `.split(...)` | +| Reveal characters while reserving the complete phrase's layout | `graphemes()`, `nativeText()`, and `typewriter()` | +| Replay after a button or another app event | `useTextMotionControls()` and the `controls` prop | +| Follow scroll, gestures, or another app-owned animation | A Reanimated shared value passed as `progress` | +| Keep the phrase readable to screen readers | Parent-label accessibility with decorative token nodes hidden | + +`controls` sends playback commands; `progress` supplies the current visual state. An instance accepts one of these playback inputs at a time. + +## Scope and limits + +The intended workloads are short titles, headings, labels, onboarding copy, and product descriptions. Measure long paragraphs, many rows, and large grapheme counts on your target devices before adopting them. + +`nativeText()` uses containers around moving tokens and does not reproduce every behavior of a single native `Text`. Rich nested children, inline links, selectable text, and text `onPress` are outside the component contract. `lines()` splits explicit `\n` characters only; use `overlayText()` for automatic wrapping. + +The current public API does not include custom effect factories, a renderer-extension SDK, Skia effects, `pause`/`seek`/`reverse`, or built-in scroll, gesture, screen-focus, and in-view drivers. Apps can connect their own events through `controls` and continuous shared values through `progress`. Text changes are enter-only; the library does not retain old text for exit animation or provide a streaming-text API. + +Continue with [installation](./installation.md), then create your first component in the [quick start](./quick-start.md). diff --git a/docs/docs/en/guide/getting-started/quick-start.md b/docs/docs/en/guide/getting-started/quick-start.md new file mode 100644 index 0000000..8a41523 --- /dev/null +++ b/docs/docs/en/guide/getting-started/quick-start.md @@ -0,0 +1,86 @@ +# Quick Start + +Complete [installation](./installation.md), then choose a preset or create a recipe. Both approaches return a reusable React component. + +## Start with a preset + +```tsx +import { editorialRise } from '@react-native-motion-kit/text-motion/presets'; + +const HeroReveal = editorialRise().component(); + +export function HeroTitle() { + return ( + + Design motion that feels native + + ); +} +``` + +`editorialRise()` uses word tokens, a centered stagger, and combined rise, fade, and scale effects. The component autoplays when it mounts. + +## Create a word reveal + +```tsx +import { + defineTextMotion, + fade, + nativeText, + parentLabelPolicy, + rise, + stagger, + words, +} from '@react-native-motion-kit/text-motion'; + +const WordReveal = defineTextMotion() + .split(words()) + .layout(nativeText()) + .timeline(stagger(0.04)) + .effect(fade().and(rise({ y: 12 }))) + .motion({ kind: 'timing', options: { duration: 420 } }) + .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' })) + .component(); + +export function Headline() { + return Make every word count; +} +``` + +The timeline uses **seconds**: `stagger(0.04)` starts visible words 40ms apart. Motion `duration` uses **milliseconds**: each token animates for 420ms after its delay. Whitespace keeps its layout space without taking a delay slot. The accessibility policy exposes the complete phrase once and shows the final state when reduced motion is enabled. + +## Reveal actual rendered lines + +Use `overlayText()` when width and font settings should determine the animated lines. This renderer owns line measurement, so omit `.split(...)`. + +```tsx +import { + defineTextMotion, + lineReveal, + overlayText, + stagger, +} from '@react-native-motion-kit/text-motion'; + +const LineTitle = defineTextMotion() + .layout(overlayText()) + .timeline(stagger(0.08)) + .effect(lineReveal({ y: 18 })) + .motion({ kind: 'timing', options: { duration: 420 } }) + .component(); + +export function ResponsiveTitle() { + return ( + + Motion follows the lines your layout creates + + ); +} +``` + +`lines()` is different: it splits only explicit newline characters and does not measure wrapping. + +## Next steps + +- Learn how [recipes](../usage/recipes.md) compose and how to customize [presets](../usage/presets.md). +- Add [playback controls or external progress](../usage/playback.md). +- Compare [renderers](../../api/renderers.md), [effects](../../api/effects.md), and [component props](../../api/component-props.md). diff --git a/docs/docs/en/guide/usage/_meta.json b/docs/docs/en/guide/usage/_meta.json new file mode 100644 index 0000000..23b38b6 --- /dev/null +++ b/docs/docs/en/guide/usage/_meta.json @@ -0,0 +1 @@ +["recipes", "presets", "playback", "accessibility", "segmentation", "performance", "examples"] diff --git a/docs/docs/en/guide/usage/accessibility.md b/docs/docs/en/guide/usage/accessibility.md new file mode 100644 index 0000000..44cd2b4 --- /dev/null +++ b/docs/docs/en/guide/usage/accessibility.md @@ -0,0 +1,59 @@ +# Accessibility + +Text Motion treats animated token nodes as decoration. By default, the parent exposes the complete phrase as one accessible label and the individual animated nodes are hidden from accessibility tools. A screen reader should read the phrase once, rather than announcing each word or character. + +## Parent-label policy + +`parentLabelPolicy(options?)` configures this behavior for a recipe. Its only option is `reducedMotion`. + +| Option | Default | Meaning | +| --- | --- | --- | +| `reducedMotion` | `'system'` | Uses Reanimated's system reduced-motion behavior for internal animations. | +| `reducedMotion: 'final-state'` | Opt-in | When system reduced motion is enabled, renders the final visual state immediately. | + +```tsx +import { + defineTextMotion, + fade, + nativeText, + parentLabelPolicy, + rise, + words, +} from '@react-native-motion-kit/text-motion'; + +const AccessibleTitle = defineTextMotion() + .split(words()) + .layout(nativeText()) + .effect(fade().and(rise({ y: 10 }))) + .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' })) + .component(); + +export function Title() { + return ( + + Motion that keeps the message readable + + ); +} +``` + +`'final-state'` does not disable motion for everyone. It skips to the final state when the device's reduced-motion setting is enabled. That visual state also takes priority over controls and external progress, so a Typewriter phrase remains fully readable even when the app's shared value is still `0`. + +With `nativeText()` and the default `'system'` policy, an external `progress` value remains app-owned. Configure its animation curve to respect the user's preference, or choose `'final-state'` when the rendered text must immediately show the complete result. + +`overlayText()` has an additional renderer-level fallback: whenever system reduced motion is enabled, it renders the original final paragraph instead of animated line overlays. This applies with either policy and with external progress, and does not change the app's shared value. + +Do not pass `reduceMotion` to `.motion()` options. Text Motion exposes reduced-motion behavior through `.accessibility(...)` instead. + +## Labels and text props + +The string child supplies the default accessible label. An explicit `accessibilityLabel` can describe text whose visual wording is abbreviated. Supported accessibility props also include `accessibilityHint`, `accessibilityRole`, `accessibilityState`, `accessibilityValue`, `accessibilityActions`, `accessibilityLanguage`, and `onAccessibilityAction`. + +`allowFontScaling` and `maxFontSizeMultiplier` are supported. Verify larger font scales and narrow widths in your app, especially for rendered-line motion. See [Component props](../../api/component-props.md) for the complete prop contract and [Renderers](../../api/renderers.md) for layout differences. + +## Check in your app + +- Read the phrase using VoiceOver and TalkBack; confirm that decorative words and characters are not announced separately. +- Enable reduced motion and check the initial frame, replay, reset, and externally controlled states. +- Check your supported font scales, languages, widths, and contrast in the surrounding UI. +- Keep essential information readable independently of the decorative motion. Typewriter does not emit per-character announcements. diff --git a/docs/docs/en/guide/usage/examples.md b/docs/docs/en/guide/usage/examples.md new file mode 100644 index 0000000..e5765a3 --- /dev/null +++ b/docs/docs/en/guide/usage/examples.md @@ -0,0 +1,153 @@ +# Examples + +These examples use the current public API. Run them in a React Native app after [installation](../getting-started/installation.md). The documentation site presents code; it does not execute these native examples in the browser. + +## Start with a preset + +Presets are recipe factories imported from the `/presets` subpath. Create the component once outside render. + +```tsx +import { editorialRise } from '@react-native-motion-kit/text-motion/presets'; + +const HeroTitle = editorialRise().component(); + +export function Hero() { + return ( + + Design motion that feels native + + ); +} +``` + +See [Presets](./presets.md) for `softWave()` and `gentleEmphasis()`. + +## Word reveal with replay + +```tsx +import { Button, View } from 'react-native'; +import { + defineTextMotion, + fade, + nativeText, + rise, + stagger, + useTextMotionControls, + words, +} from '@react-native-motion-kit/text-motion'; + +const WordReveal = defineTextMotion() + .split(words()) + .layout(nativeText()) + .timeline(stagger(0.04)) + .effect(fade().and(rise({ y: 10 }))) + .motion({ kind: 'timing', options: { duration: 360 } }) + .component(); + +export function ReplayExample() { + const controls = useTextMotionControls(); + + return ( + + A little motion, word by word. + + ) : null} + {failed ? ( +

+ {lang === 'ko' + ? '영상을 재생하지 못했습니다. 다시 시도해 주세요.' + : 'The recording could not play. Please try again.'} +

+ ) : null} + + ); +} + +export function MotionDemo({ id }: { id: DemoId }) { + const lang = useLang() === 'ko' ? 'ko' : 'en'; + const theme = useDark() ? 'dark' : 'light'; + const [playing, setPlaying] = useState(false); + const [previewTheme, setPreviewTheme] = useState(theme); + const [sourceOpen, setSourceOpen] = useState(false); + const [sourceTab, setSourceTab] = useState('recipe'); + const sourceId = useId(); + const frameRef = useRef(null); + const demo = demos[id]; + const source = sourceTab === 'recipe' ? demoSources.recipes[id] : demoSources.player; + const native = demo.kind === 'native'; + + useEffect(() => { + // The sandbox has an opaque origin. Only appearance data is sent. + frameRef.current?.contentWindow?.postMessage({ type: 'text-motion-theme', theme }, '*'); + }, [theme, playing]); + + return ( +
+
+ {demo.label} + + {native ? (lang === 'ko' ? 'iOS 녹화' : 'iOS recording') : 'React Native Web'} + +
+ {native ? ( + + ) : playing ? ( +