Skip to content
Kinetic Scale Text preview
An animated render of the SwiftUI source on this page.

Kinetic Scale Text

From syxUI — written for both platforms, not translated between them.

Interactive

A pressure point sweeps the line, swelling and thickening each glyph.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • text
  • kinetic
  • pressure
  • scale
  • interactive
  • typography
  • animation

The actual source

KineticScaleText.swift
// Kinetic Scale Text · syxUI · https://syxui.dev/components/kinetic-scale-text
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A pressure point sweeping the line, swelling and thickening each glyph it
/// passes over.
///
/// Leave `point` nil and a clock drives the sweep; set it — from a drag or a
/// pointer — and that position takes over. The clock-driven path still
/// exists so the effect reads as something without a finger on it.
struct KineticScaleText: View {
    var text: String = "pressure"
    /// Extra scale at the peak.
    var boost: Double = 0.34
    /// Falloff width, in characters.
    var reach: Double = 2.8
    /// One full sweep, in seconds.
    var period: Double = 3.2
    var pushApart: Bool = true
    /// Reverse at the ends instead of wrapping.
    var pingPong: Bool = true
    /// A live pointer position, in the text's own local coordinate space,
    /// that overrides the clock-driven sweep.
    var point: CGPoint? = nil
    /// Preview override. `0...1` is exactly one sweep — seamless only when
    /// `pingPong` is true, since a wrap otherwise jumps.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var dragPoint: CGPoint? = nil

    private let fontSize: CGFloat = 40
    /// Rough advance width used to turn a pointer's x into a character
    /// index. A real per-glyph measurement (Canvas + `context.resolve`)
    /// would track exactly; this is the cheap, dependency-free trade.
    private var averageCharWidth: CGFloat { fontSize * 0.6 }

    var body: some View {
        TimelineView(.animation(paused: reduceMotion)) { context in
            let characters = Array(text)
            let peak = peakIndex(count: characters.count, at: context.date)

            HStack(spacing: 0) {
                ForEach(characters.indices, id: \.self) { index in
                    character(characters[index], index: index, peak: peak)
                }
            }
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { value in dragPoint = value.location }
                    .onEnded { _ in dragPoint = nil }
            )
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private func peakIndex(count: Int, at date: Date) -> Double {
        guard count > 0 else { return 0 }

        if let live = point ?? dragPoint {
            return min(max(Double(live.x / averageCharWidth), 0), Double(count - 1))
        }

        let cycle = currentPhase(at: date)
        // `pingPong` makes one period a full there-and-back trip; without
        // it the point wraps from the end straight back to the start.
        let position = pingPong ? 1 - abs(1 - 2 * cycle) : cycle
        return position * Double(count - 1)
    }

    private func currentPhase(at date: Date) -> Double {
        if let phase { return min(max(phase, 0), 1) }
        if reduceMotion { return 0 }
        let ms = date.timeIntervalSinceReferenceDate * 1000
        return (ms / (period * 1000)).truncatingRemainder(dividingBy: 1)
    }

    @ViewBuilder
    private func character(_ character: Character, index: Int, peak: Double) -> some View {
        // Reduce Motion settles on the flat line rather than a frozen
        // mid-sweep frame — there is no single "resting" point to land on.
        let intensity = reduceMotion ? 0 : raisedCosine(abs(Double(index) - peak))

        Text(String(character))
            .font(.system(size: fontSize, weight: weight(for: intensity)))
            .scaleEffect(1 + boost * intensity, anchor: .bottom)
            .offset(y: -6 * intensity)
            // Half the extra width on each side: the neighbour is shoved,
            // not just the character under the point.
            .padding(.horizontal, pushApart ? 0.75 * intensity : 0)
    }

    /// Raised-cosine falloff: 1 at the peak, 0 at `reach` characters away,
    /// and — unlike a triangle or a hard cutoff — its derivative is zero at
    /// both ends, so neighbouring glyphs ease in rather than crease.
    private func raisedCosine(_ distance: Double) -> Double {
        guard reach > 0, distance < reach else { return 0 }
        return 0.5 * (1 + cos(.pi * distance / reach))
    }

    /// Nine-step `Font.Weight`, snapped from the continuous 400-800 the
    /// effect calls for. Dependency-free; the CoreText `wght` axis would
    /// make the thickening continuous at the cost of a platform typealias.
    private func weight(for intensity: Double) -> Font.Weight {
        switch 400 + intensity * 400 {
        case ..<450: .regular
        case ..<550: .medium
        case ..<650: .semibold
        case ..<750: .bold
        default: .heavy
        }
    }
}

#Preview {
    KineticScaleText()
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. Weight snaps across five Font.Weight steps rather than sweeping continuously — a real wght-axis interpolation is possible via CoreText but was not worth the platform typealias here. The pointer's x is converted to a character index with an average advance-width estimate, not per-glyph metrics, so very irregular strings track a little loosely.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27