Skip to content

feat: add a gradient streaming animation and animation options - #1156

Closed
hryhoriiK97 wants to merge 11 commits into
feat/markdown-streamfrom
feat/streaming-effects
Closed

hryhoriiK97 wants to merge 11 commits into
feat/markdown-streamfrom
feat/streaming-effects

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

What/Why?

streamingConfig.animation had two values, 'fade' and 'none'. #904 asks for a typewriter-style gradient in the app's own colors, and for chunks that arrive quickly not to snap in: each update cancelled the running fade and showed the previous chunk at once.

This is the seventh PR of the streaming stack, stacked on #1151. It turns animation into a set of presets that can each take options, adds the 'gradient' preset, and moves the effect math into the shared core.

<EnrichedMarkdownText
  stream={stream}
  streamingConfig={{
    animation: { type: 'gradient', length: 15, startColor: '#107ACA', endColor: 'transparent' },
  }}
/>

The streaming stack

  1. feat: rebuild markdown streaming and add a streaming API #1140: measure and schedule.
  2. feat(core): prepare streamed markdown in one core session #1141: the core streaming session.
  3. perf: render only the tail of a streamed text segment #1146: render only the tail.
  4. perf(ios): draw only the part of a long document near the screen #1149: iOS memory, drawing only the part of a long document near the screen.
  5. feat: add the streaming prop and streamingConfig.animation #1150: streaming and streamingConfig.animation.
  6. feat: stream markdown into a view without re-rendering it #1151: the stream object.
  7. This PR: custom streaming animations.

A later PR can add custom components per chunk (renderers.chunk) on the same inputs.

How

The API: animation takes a preset name or a preset object.

  • 'fade' (default) or { type: 'fade', duration }: each chunk fades in over duration ms (200).
  • 'gradient' or { type: 'gradient', length, startColor, endColor }: the last length characters (20) run from startColor to endColor, following the end of the text. Without startColor, each character keeps its own color (links, code, bold). endColor defaults to transparent and is the newest character's color.
  • 'none': new content appears at once, as does a duration or length of 0.

A type this version doesn't know shows content at once, with a warning in development, so an app can use a newer preset without breaking on an older version. Adding a preset means one case in the core and one name in JS. Colors nested in the prop go through processColor in JS.

The look of each character is worked out in the shared core (StreamingEffects, next to StreamingSession). It gives each character a tint, a tint amount and an opacity:

  • fade: from the chunk's own arrival time. Chunks a frame apart overlap and each fades in fully, which fixes the snap in streamingAnimation: add a gradient option for the last characters of streamed text #904.
  • gradient: from the character's distance from the end of the text, so it moves only when the text grows. When the stream ends, it settles to the text's own colors over 200 ms.
  • A character's own alpha is kept, so hidden spoiler text stays hidden under a tinted gradient.

The hosts only draw, and never change the text, so selection, copy and accessibility see it as it is:

  • iOS: the text tiles from perf(ios): draw only the part of a long document near the screen #1149 draw the animated glyphs in the effect's colors, through a layout-manager override that swaps the fill color. A display link runs only while looks change over time (a fade, or the gradient settling). A frame redraws only the tiles holding the animated characters, without laying the text view out.
  • Android: one span per run of equal looks, read in one JNI call per frame, with a Choreographer callback while looks change. Runs end only between characters, so an emoji in the gradient is never split across two spans (I saw a 🚗 drawn as two replacement characters before that fix).
  • This replaces ENRMTailFadeInAnimator, TailFadeInAnimator and FadeInSpan, which faded by rewriting the text's colors.

Edge cases:

  • In the github flavor, only the text segment at the end of the document keeps a gradient; segments the end has left settle.
  • The last chunk, arriving together with the end of the stream, still animates before settling.
  • Changing the animation mid-stream applies to text already on screen.
  • Reduce Motion turns animations off.
  • Android measurement ignores the animation options, so changing them doesn't remeasure.

Testing

Unit tests:

  • Core StreamingEffectsTests (doctest): fade timing and overlapping chunks, the gradient's position and colors, settling, and a render that adds nothing keeping a settled gradient settled.
  • iOS ENRMStreamingEffectsTests: colors from the core, hidden text staying hidden; ENRMTextViewportTests: an effect redraws the tile holding the end.
  • Android StreamingEffectSpanTest: tint and opacity, hidden text, the underline color.
  • Jest streaming-animation.test.ts: normalizing presets and options, unknown types, zero duration and length.
  • Results: Jest 1,830 of 1,830, iOS 103 of 103, Android 123 of 123, core 3 of 3 suites; typecheck and ktlint clean.

CPU on the iPhone 15 Pro (Release build), with the scratch chat screen from #1151: one answer streaming in through a stream, 60 characters per 16 ms. The table shows the main thread against no animation, averaged over 2 runs.

fade gradient
github chat +5.0% +0.4%
github forest +4.4% +2.4%
commonmark chat +5.6% −1.2%
commonmark forest +5.7% 0%
  • Process CPU is within ±1%, stream times are unchanged, and dropped frames match no animation (0–3.5 per ~1,450–1,680 frames).
  • The gradient costs about nothing: it changes only when the text does. The fade redraws its characters every frame for 200 ms.

Galaxy A16 (Android 15): the main thread rose by 2–4 points with fade and 0–3 points with gradient, with 0.05% jank in every case and unchanged stream times. These runs were before the last review fixes, which only touch how Android spans split around characters.

Visual checks on the iPhone and on a Pixel 9 emulator (Android 16), github flavor, with #904's colors:

  • the gradient follows the end of the document across paragraphs, headings and new text segments;
  • switching to 'none' mid-stream and back applies at once to the text on screen;
  • spoilers stay hidden inside the gradient;
  • the gradient settles when the stream ends;
  • emoji at the end of the gradient draw whole on Android.

Docs

  • streamingConfig.animation documents the presets and their options.
  • The streaming guide has an Animations section on how fade and gradient behave.
  • docs-md/API_REFERENCE.md and llms.txt are updated; check-examples and check-llms pass.

Limits

Changes after review

  • Core: a fade over no time or a gradient over no characters is no effect.
  • Android: the run splitting is its own tested function (StreamingEffectRuns, ICU grapheme boundaries), the looks are written into the Java arrays in place, and a dropped text segment frees its effects (no finalizer).
  • Reduce Motion applies to text from the next arrival. Only gradients settle when the stream ends, and 'none' makes no text effects.
  • The normalized animation is kept while its options are the same.
  • Docs: duration applies to text only, and docs-md lists 'gradient' and its options.

Checked on the whole stack after these changes: core suite and fuzzing, Jest (1,838), Android unit tests (134), iOS unit tests (118), ktlint; streams on an iPhone 15 Pro and a Galaxy A16 call onStreamingEnd once and end at the static height, with CPU level or lower; the 106 Maestro flows give the same results and screenshots as before.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing (not run)
  • Required E2E tests have been added (if applicable): core, iOS, Android and Jest unit tests

🤖 Generated with Claude Code

hryhoriiK97 and others added 11 commits November 11, 2026 20:43
`streamingConfig.animation` takes 'fade', 'gradient', 'none', or an
object with options: { type: 'fade', duration } and { type: 'gradient',
length, startColor, endColor } (#904). A type this version does not know
shows new content at once, with a warning in development.

How each new character looks is worked out in the shared core
(StreamingEffects): a fade from each chunk's own arrival time, so chunks
arriving every frame fade in fully instead of snapping, and a gradient
from each character's distance from the end of the text. The newest
character takes `endColor`, and the gradient settles to the text's own
colors over 200 ms when the stream ends. The last chunk, arriving with
the end of the stream, still animates before settling.

The hosts only draw: iOS draws the animated glyphs in the effect's
colors from the text tiles, with a display link only while the looks
change over time; Android applies one span per run of equal looks, read
in one JNI call per frame. A character's own alpha is kept, so hidden
spoiler text stays hidden, and the text itself never changes, so
selection, copy and accessibility are unaffected. This replaces
ENRMTailFadeInAnimator, TailFadeInAnimator and FadeInSpan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
streamingConfig.animation documents 'gradient' and the object forms
with their options, and the streaming guide gains an Animations section
on how fade and gradient behave and how to match a brand's colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as no effect

lookAt divided by the duration unchecked; configure now turns such a config
into None, the one place the effects' options are checked. Adds tests for
configure starting over, a gradient between two opaque colors and a tinted
gradient settling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d test them

The run computation (grapheme boundaries, runs of equal look) becomes
StreamingEffectRuns, which keeps one BreakIterator instead of making one per
frame. It uses android.icu's, which java.text's wraps on Android, so the JVM
tests see the same grapheme rules: a surrogate pair and a ZWJ sequence the
range starts inside, and plain runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nativeLooks filled three std::vectors each frame and copied them over; lookAt
makes no JNI calls, so it now writes through critical array pointers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The text effects read Reduce Motion once, when configured, so toggling it
while a view was mounted changed the block fades but not the text. They now
check it on each arrival, as the tail fade did, and start over when it
changed. Android's StreamingEffects.configure takes the Config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishing a fade does nothing, so the hosts settle only gradients. With
'none' or Reduce Motion, Android no longer makes an animator and a core object
for each streamed text segment. The block fade follows the parsed animation
type on both platforms instead of a flag or the raw string, Android's config
defaults live in one place, and a comment repeated in four files stays in one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Segment views the reconciler removed kept their core object until the
deprecated finalize ran, as textEffects holds them weakly. onViewRemoved now
releases them, so finalize goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e same

processColor makes a new object for a PlatformColor each render, so the
memoised streamingConfig was rebuilt every time. useStreamingAnimation
memoises on the raw type, duration, length and colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocks and table rows keep a fixed 200 ms fade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… effects left stale

docs-md/MARKDOWN_STREAMING.md listed only 'fade' and 'none'; it now has
'gradient' and the preset options, as the guide and API reference do.
The slot reveal's ease was still called the tail fade's, which this PR
removed, and the web guide called streamingConfig block handling only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant