Glass Text
Liquid Glass rendered on the actual glyph outlines of a string, one or several lines, with a specular Material fallback below iOS 26, a solid fill under Reduce Transparency and Dynamic Type scaling.
- Type
- GlassText
- Files
- GlassText.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.0.0
Purchase
A complete StoreKit 2 purchase: plans load, the purchase runs, success lands, the app unlocks.
Liquid Glass applied to text outlines via Core Text glyph paths. The glyph path is built once per string and font, then handed to glassEffect(_:in:) as a shape. 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 path is built once per string and font, not on every layout pass. - On iOS 26 the outline is passed to
glassEffect(_:in:)as a shape. Below iOS 26 it 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). - 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. 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. |
Source
import SwiftUI
import CoreText
/// Liquid Glass applied to text outlines via Core Text glyph paths.
/// The glyph path is built once per string and font, then handed to `glassEffect(_:in:)` as a shape.
/// 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. 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: .black),
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
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 if #available(iOS 26, *) {
Color.clear
.glassEffect(tint.map { Glass.regular.tint($0) } ?? .regular, in: shape)
.shadow(color: .black.opacity(style.shadow), radius: 18, y: 10)
} else {
fallback(shape)
}
}
.frame(width: shape.size.width, height: shape.size.height)
.accessibilityElement()
.accessibilityLabel(text.replacingOccurrences(of: "\n", with: " "))
.accessibilityAddTraits(.isStaticText)
}
/// Material 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)
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.
private struct GlassTextExample: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
TimelineView(.animation(paused: reduceMotion)) { context in
let t = context.date.timeIntervalSinceReferenceDate
GlassText("07:30", font: .systemFont(ofSize: 112, weight: .heavy), scalesWithDynamicType: false)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background { GlassTextBlocks(t: t).clipped() }
}
}
}
/// Plain shapes in the house blocks: a sky field, a tangerine sun, a butter bar and a lilac slab.
private struct GlassTextBlocks: View {
let t: TimeInterval
var body: some View {
let drift = CGFloat(sin(t * 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)
}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.
Building a whole app? See Swift Pieces Pro →
All components
Every free piece. Filter by category, open a piece for its parameters, source and install commands.
Text Reveal
Reveals a headline by characters, words or lines, rising from a baseline mask with a stagger sized to land in under a second, then wipes solid highlight blocks behind the phrases that matter.