DEV Community

Cover image for How Morph Text dissolves one word into the next
Juan Silva
Juan Silva

Posted on

How Morph Text dissolves one word into the next

Morph Text melts one word into the next: the letters blur, run into each other and sharpen into the new word. It's two blurred Texts and one colour matrix, and it runs the same on Android, iOS and the web.

I ported it to Compose for Glaze, a gallery of Compose Multiplatform components. The original is Victor Welander's Gooey Text Morphing, a React component built on SVG filters.

You can run it in your browser while you read, and drag every value this article talks about: glaze.lightscout.co.uk/components/morph-text.

The idea

Draw both words on top of each other. As the old word fades out it gets blurrier, and as the new word fades in it gets sharper. On its own that's a plain cross-fade with some blur.

Then the whole thing goes through a threshold on alpha. Any pixel above a cut-off becomes solid and anything below it disappears. Blurred letters have soft edges and long faint tails, so the threshold turns them into hard blobs. Where the blobs of the two words overlap, they merge into one shape.

The CSS version does the same two steps with feGaussianBlur and feColorMatrix. Compose has both pieces: BlurEffect for the blur and ColorFilter.colorMatrix for the threshold.

Timing

The animation is a two-state loop: morph for morphTime seconds, then hold the new word for cooldownTime seconds. A LaunchedEffect drives it frame by frame with withFrameMillis, so it follows the display's refresh rate and picks up new timings as soon as a slider moves.

LaunchedEffect(textCount, morphTime, cooldownTime) {
    var lastMs = withFrameMillis { it }
    while (true) {
        val frameMs = withFrameMillis { it }
        val dt = (frameMs - lastMs) / 1000f
        lastMs = frameMs
        state = state.run {
            if (inCooldown) {
                val remaining = cooldownRemaining - dt
                if (remaining <= 0f) copy(textIndex = (textIndex + 1) % textCount, morphFraction = 0f, inCooldown = false)
                else copy(cooldownRemaining = remaining)
            } else {
                val next = (morphFraction + dt / morphTime).coerceAtMost(1f)
                if (next >= 1f) copy(morphFraction = 1f, inCooldown = true, cooldownRemaining = cooldownTime)
                else copy(morphFraction = next)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The morph fraction f runs from 0 to 1. Each word gets its own progress from it: t1 for the word leaving and t2 for the word arriving.

val p = (config.morphSequence * 0.5f).coerceIn(0f, 0.499f)
val t1 = ((1f - f) / (1f - p)).coerceIn(0f, 1f)
val t2 = ((f - p) / (1f - p)).coerceIn(0f, 1f)
Enter fullscreen mode Exit fullscreen mode

With p at 0 the two words cross over evenly across the whole morph. Raising it holds the old word at full strength for the first part of the morph before the cross starts. The default of 0.5 gives p = 0.25, so the old word sits still for the first quarter and the merge happens in the last three quarters.

Alpha isn't t itself but t^0.4. That curve keeps a word mostly opaque until late in its fade, which leaves more blurred material on screen for the threshold to work with.

The blur curve

Each word's blur radius comes from its progress t:

blur=min⁡(blurBaset−blurBase, blurMaxDp) \text{blur} = \min\left(\frac{\text{blurBase}}{t} - \text{blurBase},\ \text{blurMaxDp}\right)

At t = 1 the word is sharp. Halfway, at t = 0.5, the blur equals blurBase. At a quarter it's three times blurBase, and as t heads to 0 it shoots up, so blurMaxDp caps it. With the defaults (blurBase 8, blurMaxDp 40) a word is 8dp blurred at half strength and hits the 40dp cap at about one sixth.

A linear blur would spread the change evenly across the morph. This curve keeps the blur under blurBase for the first half of a word's fade, then climbs steeply at the end, which is where the threshold turns it into blobs.

One Compose detail: the blur spreads past the text's layout bounds, so the inner Box is padded by blurMaxDp + 8dp. Without it, the blur would be cut off at the threshold layer's bounds.

The threshold

The threshold is a colour matrix that leaves red, green and blue alone and rewrites alpha:

ColorFilter.colorMatrix(
    ColorMatrix(
        floatArrayOf(
            1f, 0f, 0f, 0f, 0f,
            0f, 1f, 0f, 0f, 0f,
            0f, 0f, 1f, 0f, 0f,
            0f, 0f, 0f, thresholdGain, thresholdBias,
        )
    )
)
Enter fullscreen mode Exit fullscreen mode

The last row says A' = thresholdGain × A + thresholdBias, clamped to 0–255. Compose takes the offset column on the same 0–255 scale as the channels, as Android's ColorMatrixColorFilter does.

With the defaults, gain 18 and bias -7:

  • A pixel at alpha 0 stays at 0.
  • Anything above about 15 (6% opacity) comes out at 255, fully solid.
  • Between those two is a thin soft band that keeps the blob edges anti-aliased.

So a blurred letter's faint halo, which you'd normally barely see, turns solid. Two halos that touch become one shape. Gain sets how hard the edge is: higher gain means a thinner soft band. Bias moves the cut-off: more negative ignores more of the faint halo, so the blobs shrink and stay apart for longer.

The filter is applied once, to the parent Box, through graphicsLayer { colorFilter = thresholdFilter }, so both words are drawn into one offscreen layer and thresholded together. Thresholding each word on its own would give two sets of blobs that never merge.

A Multiplatform gotcha: Skia reads that offset column as -1 to 1, not -255 to 255. Older Compose Multiplatform versions passed it straight through, so the same matrix behaved differently on iOS, desktop and web (issue 3461). If your threshold looks right on Android and wrong elsewhere, check your Compose version first.

Putting it together

Three layers: an outer Box that applies the threshold, a padded inner Box that leaves room for the blur, and the two Texts, each with its own alpha and blur.

Box(
    contentAlignment = Alignment.Center,
    modifier = modifier.graphicsLayer {
        colorFilter = thresholdFilter
        clip = false
    },
) {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.padding(config.blurMaxDp.dp + 8.dp),
    ) {
        Text(
            text = text1,
            style = style,
            modifier = Modifier.graphicsLayer {
                alpha = t1.powF(0.4f)
                val blurPx = (config.blurBase / t1.coerceAtLeast(0.001f) - config.blurBase)
                    .coerceIn(0f, config.blurMaxDp)
                    .dp.toPx()
                if (blurPx > 0f) renderEffect = BlurEffect(blurPx, blurPx, TileMode.Decal)
                clip = false
            },
        )
        // text2 is the same, with t2
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details that are easy to miss:

  • TileMode.Decal treats everything outside the text as transparent. The default clamps the edge pixels outwards and smears them.
  • The blur is only set when it's above 0. A word that's fully visible draws without a blur pass.
  • Float.pow isn't in the common standard library, so powF goes through Double. That keeps the file in common code for Android, iOS and the web.

The whole file is 199 lines, with the state machine and config included.

Tuning it

The live page starts from a softer preset than the code's defaults, mainly a low gain and a deep bias. That's why its edges glow a little rather than snapping hard.

Parameter Code default Live page Turn it up and…
morphTime 1s 1.36s the melt slows down
cooldownTime 0.25s 0.79s each word holds longer before the next morph
blurBase 8 12.19 words start dissolving earlier in their fade
blurMaxDp 40dp 20.76dp blobs spread further and merge with letters further away
thresholdGain 18 3.9 edges get harder; at the low end they turn into a glow
thresholdBias -7 -38.25 (more negative) more of the faint halo is cut, so blobs shrink
morphSequence 0.5 7.24 the old word holds longer before the cross starts

morphSequence is halved and capped just under 0.5, so anything from 1 upwards behaves the same: the old word holds for the first half of the morph, and the cross happens in the second half.

A good way in: drag gain right down to about 2 and watch the glow, then push it back above 20 and watch the edges snap. Then do the same with Blur Max.

Try it

If you build something with it, or tune a preset you like better than mine, I'd like to see it.

Top comments (0)