Skip to content

Shrinking Extended Button

An extended floating button that collapses from a labeled pill to a plain icon as the scroll view moves down.

Shrinking Extended Button, playing
Download .swift
Origin
Original work
Found by
extended fab · floating button · collapse · scroll · pill · compose button · shrink · botón flotante · botón extendido · contraer · desplazamiento · píldora · encoger

An extended floating button that shrinks from a labeled pill to a plain icon.

Pass isExtended, or drive it from a scroll view with ShrinkingExtendedButtonScrollTracker. With Reduce Motion on, it changes shape without animating.

struct ShrinkingExtendedButtonDemo: View {
@State private var isExtended = true
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(0..<30, id: \.self) { index in
HStack(spacing: 14) {
Circle()
.fill(Color(hue: Double(index % 8) / 8, saturation: 0.55, brightness: 0.9))
.frame(width: 44, height: 44)
.accessibilityHidden(true)
Text("Message \(index + 1)")
.font(.headline)
Spacer(minLength: 0)
}
.padding(14)
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
}
.padding(16)
}
.modifier(ShrinkingExtendedButtonScrollTracker(isExtended: $isExtended))
ShrinkingExtendedButton(title: "New Message", systemImage: "square.and.pencil", isExtended: isExtended) {}
.padding(24)
}
}
}
ParameterTypeDefault
titleLocalizedStringResourceRequired
systemImageStringRequired
isExtendedBooltrue
tintColor.indigo
action() -> VoidRequired

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

ShrinkingExtendedButton.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
enum ShrinkingExtendedButtonMath {
/// Rubber-banding past the top gives a negative offset, which counts as being at the top.
static func shouldExtend(offset: CGFloat, threshold: CGFloat) -> Bool {
offset <= max(threshold, 0)
}
}
/// An extended floating button that shrinks from a labeled pill to a plain icon.
///
/// Pass `isExtended`, or drive it from a scroll view with `ShrinkingExtendedButtonScrollTracker`.
/// With Reduce Motion on, it changes shape without animating.
struct ShrinkingExtendedButton: View {
let title: LocalizedStringResource
let systemImage: String
var isExtended = true
var tint: Color = .indigo
let action: () -> Void
@ScaledMetric(relativeTo: .headline) private var height = 58.0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: systemImage)
.font(.title3.weight(.semibold))
if isExtended {
Text(title)
.font(.headline)
.transition(.opacity.combined(with: .scale(scale: 0.8, anchor: .leading)))
}
}
.foregroundStyle(ShrinkingExtendedButtonContrastingForeground(background: tint))
.padding(.horizontal, isExtended ? 22 : 0)
.frame(minWidth: height, minHeight: height)
.background(tint.gradient, in: Capsule())
.shadow(color: tint.opacity(0.35), radius: 12, y: 6)
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.animation(reduceMotion ? nil : .spring(duration: 0.35, bounce: 0.15), value: isExtended)
}
}
/// Apply to a scroll view to set `isExtended` from how far it has moved from the top.
struct ShrinkingExtendedButtonScrollTracker: ViewModifier {
@Binding var isExtended: Bool
var threshold: CGFloat = 40
func body(content: Content) -> some View {
content.onScrollGeometryChange(for: Bool.self) { geometry in
ShrinkingExtendedButtonMath.shouldExtend(
offset: geometry.contentOffset.y + geometry.contentInsets.top,
threshold: threshold
)
} action: { _, shouldExtend in
isExtended = shouldExtend
}
}
}
/// White or black, whichever reads better on `background`. Black only on a
/// light color, so the system's own colors keep white.
nonisolated struct ShrinkingExtendedButtonContrastingForeground: ShapeStyle {
let background: Color
func resolve(in environment: EnvironmentValues) -> Color {
let color = background.resolve(in: environment)
let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue
return luminance > 0.5 ? .black : .white
}
}
// MARK: - Demo
struct ShrinkingExtendedButtonDemo: View {
@State private var isExtended = true
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(0..<30, id: \.self) { index in
HStack(spacing: 14) {
Circle()
.fill(Color(hue: Double(index % 8) / 8, saturation: 0.55, brightness: 0.9))
.frame(width: 44, height: 44)
.accessibilityHidden(true)
Text("Message \(index + 1)")
.font(.headline)
Spacer(minLength: 0)
}
.padding(14)
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
}
.padding(16)
}
.modifier(ShrinkingExtendedButtonScrollTracker(isExtended: $isExtended))
ShrinkingExtendedButton(title: "New Message", systemImage: "square.and.pencil", isExtended: isExtended) {}
.padding(24)
}
}
}
#Preview {
ShrinkingExtendedButtonDemo()
}