Glass Text
Liquid Glass drawn on the actual letter shapes of a string, with fallbacks before iOS 26 and under Reduce Transparency.
Live preview
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
import CoreText
/// Liquid Glass applied to text outlines via Core Text glyph paths.
/// The glyph path is built from the string and font when the view's body runs, then handed to `glassEffect(_:in:)` as a shape.
/// Its subject is glass itself, so it draws its glass directly rather than on a liquid group's shapes.
/// Split the string with `\n` for a stacked display title; lines use tight display leading.
///
/// - Parameters:
/// - text: The string to render. `\n` starts a new line.
/// - font: The `UIFont` used to build glyph paths: system semibold, the house weight, by default. Its point size is scaled with Dynamic Type, relative to `.largeTitle`.
/// - tint: Optional glass tint. The Material fallback blends it in at `style.fallbackTintAmount`.
/// - scalesWithDynamicType: Set false to pin the point size.
/// - style: Line alignment and leading, fallback specular strength, depth shadow and the Reduce Transparency fill.
public struct GlassText: View {
/// Visual tuning. `standard` is tuned for display sizes over color.
public struct Style: Sendable {
/// Horizontal alignment of lines when the text has more than one.
public var alignment: TextAlignment
/// Line height as a multiple of the font's line height. Display type reads best tight.
public var leading: CGFloat
/// Strength (0...1) of the fallback's top light and specular rim.
public var specular: Double
/// Opacity of the soft shadow that lifts the letters off the backdrop.
public var shadow: Double
/// How much of `tint` the Material fallback takes on, 0...1.
public var fallbackTintAmount: Double
/// Fill used under Reduce Transparency. `nil` uses `.primary`.
public var solidFill: Color?
public init(
alignment: TextAlignment = .center,
leading: CGFloat = 0.86,
specular: Double = 0.7,
shadow: Double = 0.16,
fallbackTintAmount: Double = 0.35,
solidFill: Color? = nil
) {
self.alignment = alignment
self.leading = leading
self.specular = specular
self.shadow = shadow
self.fallbackTintAmount = fallbackTintAmount
self.solidFill = solidFill
}
public static let standard = Style()
}
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@ScaledMetric(relativeTo: .largeTitle) private var typeScale: CGFloat = 1
private let text: String
private let font: UIFont
private let tint: Color?
private let scalesWithDynamicType: Bool
private let style: Style
public init(
_ text: String,
font: UIFont = .systemFont(ofSize: 64, weight: .semibold),
tint: Color? = nil,
scalesWithDynamicType: Bool = true,
style: Style = .standard
) {
self.text = text
self.font = font
self.tint = tint
self.scalesWithDynamicType = scalesWithDynamicType
self.style = style
}
public var body: some View {
// Cap the scale so accessibility sizes do not push a display word off screen.
let scale = scalesWithDynamicType ? min(typeScale, 1.6) : 1
// A new string takes the old one's place at once, with no transition of its own: a crossfade of two copies
// dims the letters that did not change, and on glass it doubles them. The letters stay still; the backdrop moves.
let shape = GlyphShape(text: text, font: font.withSize(font.pointSize * scale), alignment: style.alignment, leading: style.leading)
Group {
if reduceTransparency {
shape.fill(style.solidFill.map(AnyShapeStyle.init) ?? AnyShapeStyle(.primary))
} else {
glass(shape)
}
}
.frame(width: shape.size.width, height: shape.size.height)
// The glyphs come from `font` (the house semibold by default), not from the environment. The root still states
// the house weight, as every piece that sets type does.
.fontWeight(.semibold)
.accessibilityElement()
.accessibilityLabel(text.replacingOccurrences(of: "\n", with: " "))
.accessibilityAddTraits(.isStaticText)
}
/// Liquid Glass on the glyph outlines on iOS 26, built with the iOS 26 SDK; the frosted fallback everywhere else.
@ViewBuilder private func glass(_ shape: GlyphShape) -> some View {
#if compiler(>=6.2)
if #available(iOS 26, *) {
Color.clear
.glassEffect(tint.map { Glass.regular.tint($0) } ?? .regular, in: shape) // liquid: native
.shadow(color: .black.opacity(style.shadow), radius: 18, y: 10)
} else {
fallback(shape)
}
#else
fallback(shape)
#endif
}
/// Frosted glyphs with a light falling from the top, a specular rim and a soft lift.
private func fallback(_ shape: GlyphShape) -> some View {
ZStack {
shape.fill(.ultraThinMaterial) // liquid: native
if let tint { shape.fill(tint.opacity(style.fallbackTintAmount)) }
shape.fill(
LinearGradient(
colors: [.white.opacity(0.45 * style.specular), .white.opacity(0.04), .white.opacity(0.16 * style.specular)],
startPoint: .top, endPoint: .bottom
)
)
// Stroked at 2pt and clipped, so exactly 1pt of rim sits inside each outline.
shape
.stroke(LinearGradient(colors: [.white.opacity(0.9 * style.specular), .white.opacity(0.15 * style.specular)], startPoint: .top, endPoint: .bottom), lineWidth: 2)
.clipShape(shape)
}
.compositingGroup()
.shadow(color: .black.opacity(style.shadow), radius: 18, y: 10)
}
}
/// A `Shape` built from the glyph outlines of one or more lines. The path is resolved once in `init`.
private struct GlyphShape: Shape {
let size: CGSize
private let path: Path
init(text: String, font: UIFont, alignment: TextAlignment, leading: CGFloat) {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
let lineHeight = font.lineHeight * leading
var outlines: [(path: CGPath, width: CGFloat)] = []
for line in lines {
let glyphs = CGMutablePath()
let ctLine = CTLineCreateWithAttributedString(NSAttributedString(string: line, attributes: [.font: font]))
for run in CTLineGetGlyphRuns(ctLine) as! [CTRun] {
let count = CTRunGetGlyphCount(run)
let attributes = CTRunGetAttributes(run) as NSDictionary
let runFont = attributes[kCTFontAttributeName as String] as! CTFont
var ids = [CGGlyph](repeating: 0, count: count)
var positions = [CGPoint](repeating: .zero, count: count)
CTRunGetGlyphs(run, CFRange(location: 0, length: count), &ids)
CTRunGetPositions(run, CFRange(location: 0, length: count), &positions)
for i in 0..<count {
guard let glyph = CTFontCreatePathForGlyph(runFont, ids[i], nil) else { continue }
glyphs.addPath(glyph, transform: CGAffineTransform(translationX: positions[i].x, y: positions[i].y))
}
}
outlines.append((glyphs, CGFloat(CTLineGetTypographicBounds(ctLine, nil, nil, nil))))
}
let width = ceil(outlines.map(\.width).max() ?? 0)
let combined = CGMutablePath()
for (index, outline) in outlines.enumerated() {
let x: CGFloat
switch alignment {
case .leading: x = 0
case .center: x = ((width - outline.width) / 2).rounded()
case .trailing: x = width - outline.width
}
// Core Text is y-up; flip into SwiftUI's y-down space with each baseline at its line's ascender.
let baseline = font.ascender + CGFloat(index) * lineHeight
combined.addPath(outline.path, transform: CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: x, ty: baseline))
}
path = Path(combined)
let height = font.lineHeight + CGFloat(max(lines.count - 1, 0)) * lineHeight
size = CGSize(width: width, height: ceil(height))
}
func path(in rect: CGRect) -> Path {
path.applying(CGAffineTransform(translationX: rect.minX, y: rect.minY))
}
}
// MARK: - Example
/// The component alone: glass numerals over plain color blocks that drift underneath, so the glass
/// has something to bend. The blocks fill the stage; nothing else sits on them. Only the backdrop runs on the
/// clock, so the glass is never rebuilt per frame.
private struct GlassTextExample: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// When the drift began. Set on appear, and again when Reduce Motion turns off, so it always starts from rest.
@State private var start: Date?
var body: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
GlassText("07:30", font: .systemFont(ofSize: 120, weight: .semibold), scalesWithDynamicType: false)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
TimelineView(.animation(paused: !motion.allowsAmbient)) { context in
let elapsed = start.map { max(context.date.timeIntervalSince($0), 0) } ?? 0
// Under Reduce Motion the blocks hold their rest pose.
GlassTextBlocks(t: motion.allowsAmbient ? elapsed : 0).clipped()
}
}
.onAppear { start = .now }
.onChange(of: reduceMotion) { start = .now }
}
}
/// Plain shapes in the house blocks: a sky field, a tangerine sun, a butter bar and a lilac slab.
/// At `t` zero they sit in their rest pose.
private struct GlassTextBlocks: View {
let t: TimeInterval
var body: some View {
// Ease the sway's clock, not its amplitude: its rate rises on a smoothstep over two seconds, so the drift
// starts from rest, never outruns its own loop and joins it without a kink.
let ramp = min(t / 2, 1)
let phase = t < 2 ? ramp * ramp * ramp * (2 - ramp) : t - 1
let drift = CGFloat(sin(phase * 0.5))
ZStack {
GlassTextPalette.sky.ignoresSafeArea()
Circle()
.fill(GlassTextPalette.tangerine)
.frame(width: 190)
.offset(x: -70 + drift * 46, y: 8 + CGFloat(cos(t * 0.35)) * 14)
Capsule()
.fill(GlassTextPalette.butter)
.frame(width: 240, height: 64)
.rotationEffect(.degrees(-12))
.offset(x: 110 - drift * 60, y: -30)
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(GlassTextPalette.lilac)
.frame(width: 200, height: 110)
.offset(x: 90 + drift * 30, y: 130)
}
}
}
private enum GlassTextPalette {
static let sky = Color(red: 0.612, green: 0.761, blue: 1)
static let tangerine = Color(red: 1, green: 0, blue: 0)
static let butter = Color(red: 1, green: 0.851, blue: 0.463)
static let lilac = Color(red: 0.804, green: 0.722, blue: 1)
}
#Preview("Light") {
GlassTextExample()
.preferredColorScheme(.light)
}
#Preview("Dark") {
GlassTextExample()
.preferredColorScheme(.dark)
}
// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core)
/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
var reduceMotion = false
// Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
/// No overshoot, 90% in about 90ms. A press arriving under the finger.
static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
/// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
/// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
/// About 15% overshoot. A resolved action landing. At most once per interaction.
static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
/// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }
/// Reduce Motion: settles become this short spring with no overshoot.
private static var still: Animation { .spring(duration: 0.25, bounce: 0) }
// Roles.
/// Touch-down. Starts on the same frame and never bounces under the finger.
var press: Animation { .spring(Self.tight) }
/// The finger lifts off a pressed surface, which springs back through rest.
var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// Lands on a detent, page or segment.
var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
/// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
/// never shows a value that isn't true.
var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
/// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A shape or container changes size, corner radius or form, with a little give at the end.
var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
/// Something appears, opens or expands. Opening is a little slower than closing.
var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
/// Something leaves, closes or collapses. Quick and firm, out of the way.
var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
/// A resolved action lands: a check, a sent state, a reaction.
var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
/// A refused action. Firm, no wobble; `pieceShake` adds the movement.
var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
/// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }
/// Loops, drifts, idle breathing and parallax run only when this is true.
var allowsAmbient: Bool { !reduceMotion }
/// A moving transition, or a plain fade under Reduce Motion.
func transition(_ transition: AnyTransition) -> AnyTransition {
reduceMotion ? .opacity : transition
}
/// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
/// blurs out as the new one sharpens in. A fade under Reduce Motion.
@MainActor var swap: AnyTransition {
reduceMotion ? .opacity : AnyTransition(.blurReplace)
}
}
// swiftpieces-motion: end- Type
- GlassText
- Files
- GlassText.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.2.0
Liquid Glass applied to text outlines via Core Text glyph paths. The glyph path is built from the string and font when the view's body runs, then handed to glassEffect(_:in:) as a shape. Its subject is glass itself, so it draws its glass directly rather than on a liquid group's shapes. Split the string with \n for a stacked display title; lines use tight display leading.
Notes
- Glyph outlines come from Core Text (
CTFontCreatePathForGlyph), so anyUIFontworks, including custom families. The default is the system font at the house weight, semibold, like all SwiftPieces type; the example sets its numerals at 120pt so the strokes stay wide enough to bend the light. The path is built inbody, not on layout passes, so keepGlassTextout of anything that re-renders every frame: put the clock on the backdrop, as the example does, and leave the glass outside it. - On iOS 26 the outline is passed to
glassEffect(_:in:)as a shape. Glass is this piece's subject, so it draws its glass directly instead of through the liquid group the other pieces share; the glass call needs the iOS 26 SDK and is compiled only with it, so the piece still builds with Xcode 16. Below iOS 26 the outline is filled with.ultraThinMaterialand a hairline highlight; Reduce Transparency fills it with.primaryinstead. - The point size follows Dynamic Type via
@ScaledMetric(relativeTo: .largeTitle), capped at 1.6x so a display word never leaves the screen. PassscalesWithDynamicType: falseto pin it. - Glass reads best over a backdrop with soft tonal variation; a flat color gives it nothing to refract.
- Split the string with
\nfor a stacked display title.Style.alignmentandStyle.leading(0.86 of the font's line height by default) lay out the lines. - States: Liquid Glass on iOS 26 with a soft lift shadow; below iOS 26 a layered fallback (Material, optional tint at
fallbackTintAmount, a top light and a 1pt specular rim); under Reduce Transparency a solidStyle.solidFill(.primaryby default). - The letters have no motion of their own: no entrance, no idle loop, no press. A new string takes the old one's place at once, so letters that did not change never dim or double up mid-swap. For numerals that tick, such as a clock, use
UIFont.monospacedDigitSystemFont(ofSize:weight:)so unchanged digits keep their place. The life comes from the backdrop moving behind the glass. - Reduce Motion changes nothing in the component, since nothing in it moves. The example's color blocks start from a rest pose when the view appears and ease into their drift over two seconds; under Reduce Motion they hold that rest pose, and turning it off starts the drift again from rest.
- VoiceOver reads the lines as one static text.
- For a full purchase screen built around glass display type, see the Pro Purchase screen.
Usage
GlassTextExample()
.preferredColorScheme(.light)Parameters
| Parameter | Description |
|---|---|
text | The string to render. \n starts a new line. |
font | The UIFont used to build glyph paths: system semibold, the house weight, by default. Its point size is scaled with Dynamic Type, relative to .largeTitle. |
tint | Optional glass tint. The Material fallback blends it in at style.fallbackTintAmount. |
scalesWithDynamicType | Set false to pin the point size. |
style | Line alignment and leading, fallback specular strength, depth shadow and the Reduce Transparency fill. |
Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first
Building a whole app? See SwiftPieces Pro →
More SwiftUI text
All text →Build it yourself: SwiftUI animations guide · Liquid Glass guide