Glass Segments
A segmented control whose glass indicator you can grab and drag: it lifts under the finger, rubber-bands past the ends, stretches with velocity and settles with a spring, while the label ink flips exactly under its edge; tap still works.
- Type
- GlassSegments
- Files
- GlassSegments.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.0.0
Lessons
A language-learning home with a streak, today's goals as color blocks and a floating tab bar whose lens follows your finger.
Visual tuning for GlassSegments. standard is a solid track with a glass indicator; set indicatorTint for a color block indicator.
Notes
- A touch that lands on the indicator grabs it: it lifts (scale 1.04, deeper shadow), follows the finger, rubber-bands up to one segment past either end and stretches along the drag with velocity. Release commits to the segment the flick would land on (
predictedEndLocation) with a.spring(duration: 0.45, bounce: 0.3). A touch anywhere else is a plain tap on that segment. - One
.selectionhaptic fires each time the indicator's center crosses into a new segment, and once on tap; there is no second tick on release. - Label emphasis is continuous: each label's opacity tracks how much of the indicator overlaps it, so the emphasis hands over during the drag instead of swapping at the end.
- Reduce Transparency swaps the track for an opaque system fill. Reduce Motion removes the stretch and the lift scale and shortens the settle.
- Segments share the width equally; the control fills its container, so constrain it with
.frame(width:)when you want it compact. - Labels are drawn twice: once in
Style.ink, and once inStyle.selectedInkmasked to the indicator's live shape, so the color hands over exactly under the indicator edge while dragging or stretching. - The indicator is a solid fill (
indicatorSurface, orindicatorTintfor a color block) under clear Liquid Glass on iOS 26, so it reads on the track in light and dark.GlassSegmentsStyle.block(_:)makes a block indicator with dark ink. Style(GlassSegments.Style, alias ofGlassSegmentsStyle):track,ink,selectedInk,indicatorTint,indicatorSurface,font,disabledOpacity..disabled(true)fades the control and ignores touches. The row height scales with Dynamic Type (capped at 1.5x).- For a floating tab bar whose lens follows the finger, see the Pro Lessons screen (
lens-tab-bar).
Usage
GlassSegmentsExample()
.preferredColorScheme(.light)Source
import SwiftUI
/// Visual tuning for `GlassSegments`. `standard` is a solid track with a glass indicator; set `indicatorTint` for a color block indicator.
public struct GlassSegmentsStyle: Sendable {
/// Track fill behind the segments.
public var track: Color
/// Label color outside the indicator.
public var ink: Color
/// Label color under the indicator. It is masked to the indicator's shape, so it flips mid-drag.
public var selectedInk: Color
/// Indicator fill. `nil` uses `indicatorSurface`; a color draws a solid block. On iOS 26 the fill sits under clear Liquid Glass.
public var indicatorTint: Color?
/// Raised indicator fill when `indicatorTint` is `nil`.
public var indicatorSurface: Color
/// Label font.
public var font: Font
/// Opacity of the control while disabled with `.disabled(true)`.
public var disabledOpacity: Double
public init(
track: Color = GlassSegmentsStyle.adaptive(light: 0xE6E4DF, dark: 0x262626),
ink: Color = GlassSegmentsStyle.adaptive(light: 0x5C5A56, dark: 0xA6A49F),
selectedInk: Color = GlassSegmentsStyle.adaptive(light: 0x141414, dark: 0xF4F3EF),
indicatorTint: Color? = nil,
indicatorSurface: Color = GlassSegmentsStyle.adaptive(light: 0xFFFFFF, dark: 0x3A3A3A),
font: Font = .subheadline.weight(.semibold),
disabledOpacity: Double = 0.45
) {
self.track = track
self.ink = ink
self.selectedInk = selectedInk
self.indicatorTint = indicatorTint
self.indicatorSurface = indicatorSurface
self.font = font
self.disabledOpacity = disabledOpacity
}
public static let standard = GlassSegmentsStyle()
/// A block indicator in `fill` with dark ink under it.
public static func block(_ fill: Color) -> GlassSegmentsStyle {
GlassSegmentsStyle(selectedInk: Color(red: 0.078, green: 0.078, blue: 0.078), indicatorTint: fill)
}
/// A color that follows the appearance, from two 0xRRGGBB values.
public static func adaptive(light: UInt32, dark: UInt32) -> Color {
Color(UIColor { traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: CGFloat(hex >> 16 & 0xFF) / 255, green: CGFloat(hex >> 8 & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
})
}
}
/// Segmented control over any `Hashable` option type with a draggable indicator.
///
/// - Parameters:
/// - options: Segments in display order.
/// - selection: Bound selected option. Changing it externally slides the indicator.
/// - height: Height of the segment row, excluding the 3pt track inset. Scales with Dynamic Type.
/// - label: Display text for an option.
/// - systemImage: Optional SF Symbol for an option, shown before its label.
/// - style: Track, label inks, indicator fill and font. `GlassSegmentsStyle.block(_:)` makes a color block indicator.
public struct GlassSegments<Option: Hashable>: View {
public typealias Style = GlassSegmentsStyle
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.isEnabled) private var isEnabled
@ScaledMetric(relativeTo: .subheadline) private var typeScale: CGFloat = 1
@Binding private var selection: Option
@State private var displayedIndex: Int
@State private var dragCenter: CGFloat?
@State private var grabOffset: CGFloat = 0
@State private var dragging = false
@State private var touching = false
@State private var hoverIndex: Int?
@State private var stretch: CGFloat = 0
@State private var crossings = 0
private let options: [Option]
private let height: CGFloat
private let label: (Option) -> String
private let systemImage: ((Option) -> String?)?
private let style: Style
public init(options: [Option], selection: Binding<Option>, height: CGFloat = 40, style: Style = .standard, label: @escaping (Option) -> String, systemImage: ((Option) -> String?)? = nil) {
self.options = options
self._selection = selection
self.height = height
self.label = label
self.systemImage = systemImage
self.style = style
self._displayedIndex = State(initialValue: options.firstIndex(of: selection.wrappedValue) ?? 0)
}
public var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
let segmentWidth = width / CGFloat(max(options.count, 1))
let center = dragCenter ?? (CGFloat(displayedIndex) + 0.5) * segmentWidth
ZStack(alignment: .leading) {
indicator
.frame(width: segmentWidth, height: proxy.size.height)
.scaleEffect(x: 1 + stretch, y: 1 - stretch * 0.35)
.scaleEffect(dragging && !reduceMotion ? 1.04 : 1)
.shadow(color: .black.opacity(dragging ? 0.2 : 0.08), radius: dragging ? 10 : 3, y: dragging ? 5 : 1)
.offset(x: center - segmentWidth / 2)
.animation(.spring(duration: 0.25, bounce: 0.2), value: dragging)
labels(segmentWidth: segmentWidth, height: proxy.size.height, color: style.ink, accessible: true)
// The same row in the selected ink, cut to the indicator, so the color hands over under its edge.
labels(segmentWidth: segmentWidth, height: proxy.size.height, color: style.selectedInk, accessible: false)
.mask(alignment: .topLeading) {
Capsule()
.frame(width: segmentWidth, height: proxy.size.height)
.scaleEffect(x: 1 + stretch, y: 1 - stretch * 0.35)
.scaleEffect(dragging && !reduceMotion ? 1.04 : 1)
.offset(x: center - segmentWidth / 2)
.animation(.spring(duration: 0.25, bounce: 0.2), value: dragging)
}
.accessibilityHidden(true)
}
.contentShape(.rect)
.gesture(drag(segmentWidth: segmentWidth, width: width), including: isEnabled ? .all : .none)
}
.frame(height: height * min(typeScale, 1.5))
.padding(3)
.background(reduceTransparency ? Color(.secondarySystemFill) : style.track, in: .capsule)
.opacity(isEnabled ? 1 : style.disabledOpacity)
.animation(.smooth(duration: 0.25), value: isEnabled)
.sensoryFeedback(.selection, trigger: crossings)
.onChange(of: selection) { _, new in
guard let index = options.firstIndex(of: new), index != displayedIndex else { return }
withAnimation(reduceMotion ? .smooth(duration: 0.2) : .snappy(duration: 0.32)) { displayedIndex = index }
}
.accessibilityElement(children: .contain)
}
private func labels(segmentWidth: CGFloat, height: CGFloat, color: Color, accessible: Bool) -> some View {
HStack(spacing: 0) {
ForEach(options.indices, id: \.self) { index in
segmentLabel(options[index])
.font(style.font)
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.8)
.padding(.horizontal, 8)
.frame(width: segmentWidth, height: height)
.accessibilityElement(children: .combine)
.accessibilityAddTraits(index == displayedIndex ? [.isButton, .isSelected] : .isButton)
.accessibilityAction { select(index) }
.accessibilityHidden(!accessible)
}
}
}
@ViewBuilder private func segmentLabel(_ option: Option) -> some View {
if let image = systemImage.flatMap({ $0(option) }) {
Label(label(option), systemImage: image)
} else {
Text(label(option))
}
}
@ViewBuilder private var indicator: some View {
if #available(iOS 26, *), !reduceTransparency {
// A solid fill under clear glass: the indicator reads on any track in both appearances and still lenses the edge.
Capsule()
.fill(style.indicatorTint ?? style.indicatorSurface)
.glassEffect(.clear, in: .capsule)
} else {
Capsule().fill(style.indicatorTint ?? style.indicatorSurface)
}
}
// MARK: Interaction
/// A touch that starts on the indicator drags it; a touch elsewhere is a tap on that segment.
private func drag(segmentWidth: CGFloat, width: CGFloat) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !touching {
touching = true
let resting = (CGFloat(displayedIndex) + 0.5) * segmentWidth
dragging = abs(value.startLocation.x - resting) <= segmentWidth / 2
grabOffset = value.startLocation.x - resting
hoverIndex = displayedIndex
if dragging { dragCenter = resting }
}
guard dragging else { return }
let raw = value.location.x - grabOffset
let banded = rubberBand(raw, lower: segmentWidth / 2, upper: width - segmentWidth / 2, limit: segmentWidth)
let velocityStretch = reduceMotion ? 0 : min(abs(value.velocity.width) / 4000, 0.12)
withAnimation(.interactiveSpring(duration: 0.15)) {
dragCenter = banded
stretch = velocityStretch
}
let nearest = clampIndex(Int(banded / segmentWidth))
if nearest != hoverIndex {
hoverIndex = nearest
crossings += 1
}
}
.onEnded { value in
defer { touching = false; dragging = false; hoverIndex = nil }
if dragging {
// Commit to where the flick would land, not just where the finger let go.
let projected = value.predictedEndLocation.x - grabOffset
let index = clampIndex(Int(projected / segmentWidth))
if index != hoverIndex { crossings += 1 }
withAnimation(.spring(duration: 0.45, bounce: 0.3)) {
displayedIndex = index
selection = options[index]
dragCenter = nil
stretch = 0
}
} else if abs(value.translation.width) < 10, abs(value.translation.height) < 10 {
select(clampIndex(Int(value.location.x / segmentWidth)))
}
}
}
private func select(_ index: Int) {
guard index != displayedIndex else { return }
crossings += 1
withAnimation(reduceMotion ? .smooth(duration: 0.2) : .snappy(duration: 0.32)) {
displayedIndex = index
selection = options[index]
}
}
private func clampIndex(_ index: Int) -> Int { min(max(index, 0), options.count - 1) }
/// Past either end the indicator keeps moving with diminishing returns, never more than `limit` beyond.
private func rubberBand(_ x: CGFloat, lower: CGFloat, upper: CGFloat, limit: CGFloat) -> CGFloat {
func band(_ distance: CGFloat) -> CGFloat { (1 - 1 / (distance * 0.55 / limit + 1)) * limit }
if x < lower { return lower - band(lower - x) }
if x > upper { return upper + band(x - upper) }
return x
}
}
// MARK: - Example
/// The component alone: the picker centred on the stage, cycling through its options.
private struct GlassSegmentsExample: View {
enum Period: String, CaseIterable { case day, week, month, year }
@State private var period: Period = .week
var body: some View {
GlassSegments(options: Period.allCases, selection: $period) { $0.rawValue.capitalized }
.frame(maxWidth: 350)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(GlassSegmentsStyle.adaptive(light: 0xF3F2EE, dark: 0x121212))
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1.8))
let all = Period.allCases
period = all[(all.firstIndex(of: period)! + 1) % all.count]
}
}
}
}
#Preview("Light") {
GlassSegmentsExample()
.preferredColorScheme(.light)
}
#Preview("Dark") {
GlassSegmentsExample()
.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 →
Glass Action Menu
A floating signal trigger that long-presses open into a staggered line or arc of color-block actions you can slide across and release to fire; the trigger confirms with a check, tap toggles, a scrim dismisses, and the glass morphs on iOS 26.
Glass Surface
The Glass family foundation: Liquid Glass in a capsule, rect, circle or concentric shape on iOS 26, with a layered Material fallback that keeps the press response, solid tint, specular edge and a designed disabled state everywhere else.