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

Variable Weight Text

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

A wave of weight rolls through the word, thickening glyphs as it passes.

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

  • text
  • variable font
  • weight
  • wave
  • typography
  • kinetic
  • animation

The actual source

VariableWeightText.swift
// Variable Weight Text · syxUI · https://syxui.dev/components/variable-weight-text
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

#if os(macOS)
import AppKit
private typealias PlatformFont = NSFont
private typealias PlatformFontDescriptor = NSFontDescriptor
#else
import UIKit
private typealias PlatformFont = UIFont
private typealias PlatformFontDescriptor = UIFontDescriptor
#endif

/// A wave of weight rolls through the word, thickening glyphs as it passes.
///
/// A raised-cosine bump travels left to right through the string; every
/// character's weight is a continuous function of its distance from the
/// bump centre, so neighbours are always mid-transition and the effect
/// reads as gooey rather than as a staircase of discrete weight jumps.
/// `.font()` is not animatable, so the driver is `TimelineView(.animation)`.
struct VariableWeightText: View {
    var text: String = "elastic"
    var minWeight: Double = 300
    var maxWeight: Double = 800
    /// Width of the travelling bump, in characters.
    var waveWidth: Double = 3.5
    var period: Double = 2400
    /// Sweeps the `wdth` axis (85...115) alongside weight.
    var widthAxis: Bool = false
    var fontSize: CGFloat = 40
    /// Preview override. When set, the component renders that point of one
    /// period instead of driving its own clock.
    var phase: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private var characters: [Character] { Array(text) }

    var body: some View {
        Group {
            if reduceMotion && phase == nil {
                staticRow
            } else {
                TimelineView(.animation) { context in
                    let elapsed = phase ?? (context.date.timeIntervalSinceReferenceDate
                        .truncatingRemainder(dividingBy: period / 1000) / (period / 1000))
                    // The bump travels the full string width plus one wave
                    // width of run-in/run-out, so it starts and ends off-frame.
                    let span = Double(characters.count) + waveWidth
                    let position = elapsed * span - waveWidth / 2
                    row(bumpPosition: position)
                }
            }
        }
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    /// Reduce Motion: every glyph at the midpoint weight, no travelling
    /// bump — a settled frame rather than a wave frozen mid-flight.
    private var staticRow: some View {
        let midWeight = (minWeight + maxWeight) / 2
        return HStack(spacing: 0) {
            ForEach(Array(characters.enumerated()), id: \.offset) { _, ch in
                Text(String(ch)).font(variableFont(weight: midWeight, width: 100))
            }
        }
    }

    private func row(bumpPosition: Double) -> some View {
        HStack(spacing: 0) {
            ForEach(Array(characters.enumerated()), id: \.offset) { i, ch in
                let distance = abs(Double(i) - bumpPosition)
                // Raised cosine: 1 at the bump centre, 0 at ±waveWidth/2, and
                // smooth (not just clamped-linear) in between.
                let normalised = min(distance / (waveWidth / 2), 1)
                let intensity = (cos(normalised * .pi) + 1) / 2

                let weight = minWeight + (maxWeight - minWeight) * intensity
                let width = widthAxis ? 85 + 30 * intensity : 100
                let scale = 1 + 0.02 * intensity

                Text(String(ch))
                    .font(variableFont(weight: weight, width: width))
                    .scaleEffect(scale, anchor: .bottom)
            }
        }
    }

    /// Reaches through CoreText for a continuous weight (and optionally
    /// width) axis — SwiftUI's `Font.Weight` is a discrete enum and cannot
    /// express this. `PlatformFont` is `NSFont` on macOS and `UIFont` on
    /// iOS; both expose the same CoreText variation attribute, so this is
    /// one code path rather than two.
    private func variableFont(weight: Double, width: Double) -> Font {
        var variations: [Int: Double] = [2003265652: weight] // 'wght'
        if widthAxis { variations[2003072104] = width }       // 'wdth'

        let axes: [PlatformFontDescriptor.AttributeName: Any] = [
            kCTFontVariationAttribute as PlatformFontDescriptor.AttributeName: variations
        ]

        let base = PlatformFont.systemFont(ofSize: fontSize)
        let descriptor = base.fontDescriptor.addingAttributes(axes)

        #if os(macOS)
        // NSFont(descriptor:size:) is failable where UIFont's is not — fall
        // back to the unvaried base font rather than force-unwrapping.
        let resolved = PlatformFont(descriptor: descriptor, size: fontSize) ?? base
        #else
        let resolved = PlatformFont(descriptor: descriptor, size: fontSize)
        #endif
        return Font(resolved as CTFont)
    }
}

#Preview {
    VariableWeightText()
        .frame(width: 320, height: 120)
}
iOS 17 · No dependencies

SwiftUI note. Reaches through CoreText's `kCTFontVariationAttribute` on `PlatformFont` (`NSFont` on macOS, `UIFont` on iOS — one code path, not a feature fork) for a continuous `wght`/`wdth` axis, since `Font.Weight` is a discrete enum. Works with SF Pro directly, no bundled font needed. `NSFont(descriptor:size:)` is failable where `UIFont`'s is not — the macOS branch falls back to the unvaried base font rather than force-unwrapping.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27