Skip to content

Glass Tag Cloud

Tags of tinted, interactive Liquid Glass that melt together when they get close.

Glass Tag Cloud, playing
  • Glass
  • iOS 26.0+
  • intermediate
Download .swift
Origin
Original work
Found by
tags · tag cloud · chips · flow layout · liquid glass · glass · merge · filter · etiquetas · nube de etiquetas · filtros · cristal líquido · cristal · fusionar · diseño fluido

Tags of tinted, interactive Liquid Glass that melt together when they get close.

mergeDistance is the gap below which neighbouring tags blend. With Reduce Motion on, selecting a tag changes it without animating.

@available(iOS 26, *)
struct GlassTagCloudDemo: View {
@State private var selection: Set<String> = ["SwiftUI"]
@State private var isTight = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let tags = [
GlassTagCloudTag(title: "SwiftUI", tint: .blue),
GlassTagCloudTag(title: "Animation", tint: .pink),
GlassTagCloudTag(title: "Layout", tint: .orange),
GlassTagCloudTag(title: "Glass", tint: .teal),
GlassTagCloudTag(title: "Gestures", tint: .purple),
GlassTagCloudTag(title: "Symbols", tint: .green),
GlassTagCloudTag(title: "Charts", tint: .red),
GlassTagCloudTag(title: "Navigation", tint: .indigo),
GlassTagCloudTag(title: "Metal", tint: .mint),
GlassTagCloudTag(title: "Widgets", tint: .yellow),
]
var body: some View {
ZStack {
LinearGradient(colors: [.indigo, .black], startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
// Scrolls only when Dynamic Type makes the content taller than the screen.
ViewThatFits(in: .vertical) {
content
ScrollView {
content
}
}
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 32) {
Text("Pick your interests")
.font(.largeTitle.bold())
.foregroundStyle(.white)
GlassTagCloud(tags: tags, selection: $selection, spacing: isTight ? 3 : 18)
.frame(maxWidth: .infinity, alignment: .leading)
Toggle("Tight spacing", isOn: $isTight.animation(reduceMotion ? nil : .smooth(duration: 0.7)))
.font(.headline)
.foregroundStyle(.white)
}
.padding(24)
}
}
@available(iOS 26, *)
ParameterTypeDefault
tags[GlassTagCloudTag]Required
selectionBinding<Set<String>>Required
spacingCGFloat16
mergeDistanceCGFloat10

Copy this code into your project, or download the file.

GlassTagCloud.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
@available(iOS 26, *)
struct GlassTagCloudTag: Identifiable {
let title: String
let tint: Color
var id: String { title }
}
/// Tags of tinted, interactive Liquid Glass that melt together when they get close.
///
/// `mergeDistance` is the gap below which neighbouring tags blend. With Reduce Motion on,
/// selecting a tag changes it without animating.
@available(iOS 26, *)
struct GlassTagCloud: View {
let tags: [GlassTagCloudTag]
@Binding var selection: Set<String>
var spacing: CGFloat = 16
var mergeDistance: CGFloat = 10
@ScaledMetric(relativeTo: .subheadline) private var horizontalPadding = 16.0
@ScaledMetric(relativeTo: .subheadline) private var verticalPadding = 10.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
GlassEffectContainer(spacing: mergeDistance) {
GlassTagCloudLayout(spacing: spacing) {
ForEach(tags) { tag in
tagButton(tag)
}
}
}
}
private func tagButton(_ tag: GlassTagCloudTag) -> some View {
let isSelected = selection.contains(tag.title)
return Button {
withAnimation(reduceMotion ? nil : .snappy) {
if isSelected {
selection.remove(tag.title)
} else {
selection.insert(tag.title)
}
}
} label: {
Text(tag.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
// A little taller for a touch than it looks, up to the size of a touch target.
.padding(.vertical, 3)
.contentShape(.capsule)
.padding(.vertical, -3)
}
.buttonStyle(.plain)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.glassEffect(.regular.tint(tag.tint.opacity(isSelected ? 0.85 : 0.3)).interactive(), in: .capsule)
}
}
@available(iOS 26, *)
private struct GlassTagCloudLayout: Layout {
var spacing: CGFloat
var animatableData: CGFloat {
get { spacing }
set { spacing = newValue }
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let sizes = subviews.map { $0.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) }
return GlassTagCloudFlow.arrange(sizes, width: proposal.width ?? .infinity, spacing: spacing).size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let tagProposal = ProposedViewSize(width: bounds.width, height: nil)
let sizes = subviews.map { $0.sizeThatFits(tagProposal) }
let origins = GlassTagCloudFlow.arrange(sizes, width: bounds.width, spacing: spacing).origins
for (subview, origin) in zip(subviews, origins) {
subview.place(at: CGPoint(x: bounds.minX + origin.x, y: bounds.minY + origin.y), proposal: tagProposal)
}
}
}
@available(iOS 26, *)
nonisolated enum GlassTagCloudFlow {
/// Places items left to right, starting a new row when the next item would not fit in `width`.
static func arrange(_ sizes: [CGSize], width: CGFloat, spacing: CGFloat) -> (origins: [CGPoint], size: CGSize) {
var origins: [CGPoint] = []
var cursor = CGPoint.zero
var rowHeight: CGFloat = 0
var usedWidth: CGFloat = 0
for size in sizes {
if cursor.x > 0, cursor.x + size.width > width {
cursor = CGPoint(x: 0, y: cursor.y + rowHeight + spacing)
rowHeight = 0
}
origins.append(cursor)
usedWidth = max(usedWidth, cursor.x + size.width)
rowHeight = max(rowHeight, size.height)
cursor.x += size.width + spacing
}
return (origins, CGSize(width: usedWidth, height: cursor.y + rowHeight))
}
}
// MARK: - Demo
@available(iOS 26, *)
struct GlassTagCloudDemo: View {
@State private var selection: Set<String> = ["SwiftUI"]
@State private var isTight = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let tags = [
GlassTagCloudTag(title: "SwiftUI", tint: .blue),
GlassTagCloudTag(title: "Animation", tint: .pink),
GlassTagCloudTag(title: "Layout", tint: .orange),
GlassTagCloudTag(title: "Glass", tint: .teal),
GlassTagCloudTag(title: "Gestures", tint: .purple),
GlassTagCloudTag(title: "Symbols", tint: .green),
GlassTagCloudTag(title: "Charts", tint: .red),
GlassTagCloudTag(title: "Navigation", tint: .indigo),
GlassTagCloudTag(title: "Metal", tint: .mint),
GlassTagCloudTag(title: "Widgets", tint: .yellow),
]
var body: some View {
ZStack {
LinearGradient(colors: [.indigo, .black], startPoint: .topLeading, endPoint: .bottomTrailing)
.ignoresSafeArea()
// Scrolls only when Dynamic Type makes the content taller than the screen.
ViewThatFits(in: .vertical) {
content
ScrollView {
content
}
}
}
}
private var content: some View {
VStack(alignment: .leading, spacing: 32) {
Text("Pick your interests")
.font(.largeTitle.bold())
.foregroundStyle(.white)
GlassTagCloud(tags: tags, selection: $selection, spacing: isTight ? 3 : 18)
.frame(maxWidth: .infinity, alignment: .leading)
Toggle("Tight spacing", isOn: $isTight.animation(reduceMotion ? nil : .smooth(duration: 0.7)))
.font(.headline)
.foregroundStyle(.white)
}
.padding(24)
}
}
@available(iOS 26, *)
#Preview {
GlassTagCloudDemo()
}