A SwiftUI Liquid Glass composer kit for AI chat interfaces.
Toolbar provides the primitives modern AI input bars are built from — multi-line editor, file / image / path attachments, slash commands, voice input, and a unified Liquid Glass surface — and lets you compose them declaratively. There is no monolithic Toolbar view: you put what you need inside a ToolbarContainer.
| Swift | 6.2+ |
| Platforms | iOS 26+ / iPadOS 26+ / macOS 26+ |
| Xcode | 26+ |
Swift Package Manager:
.package(url: "https://github.com/1amageek/Toolbar.git", branch: "main")Then add Toolbar to your target dependencies.
A minimal composer with a menu, editor, and adaptive trailing button (Send / Stop / Voice):
import SwiftUI
import Toolbar
struct ChatView: View {
@State private var text = ""
@State private var height: CGFloat = ToolbarControlMetrics.circleDiameter
@State private var isFocused = false
@State private var isStreaming = false
var body: some View {
ScrollView {
messageList
}
.safeAreaInset(edge: .bottom) {
ToolbarContainer {
HStack(alignment: .bottom, spacing: 8) {
ToolbarMenuButton {
Button("File", systemImage: "doc") { pickFile() }
Button("Image", systemImage: "photo") { pickImage() }
Button("Folder", systemImage: "folder"){ pickFolder() }
} label: {
Image(systemName: "plus")
}
ToolbarEditor(
text: $text,
contentHeight: $height,
isFocused: $isFocused,
placeholder: "Message..."
)
.frame(
minHeight: ToolbarControlMetrics.circleDiameter,
maxHeight: max(ToolbarControlMetrics.circleDiameter, min(height, 220))
)
if isStreaming {
StopButton(action: cancel)
} else {
SendButton(isEnabled: !text.isEmpty, action: send)
}
}
}
}
}
}ToolbarContainer paints one continuous Liquid Glass slab and wraps slab-local children in a GlassEffectContainer, so glass-circle buttons and attachment chips morph cohesively with the slab. Floating surfaces such as slash command suggestions belong in .popup { }, outside the slab. Embed the toolbar via .safeAreaInset(edge: .bottom) on the message scroll view — never directly inside a VStack.
Use ToolbarContainer(contentInsets:) for padding inside the glass slab. Use regular .padding(...) outside the container for spacing around the whole toolbar.
.accessory { } inserts a view above the composer content within the same slab. Use it for ephemeral state strips: attachment chips, live waveforms during recording, "transcribing…" indicators, error banners, draft previews, suggestion chips, upload progress.
@State private var voiceState: VoiceState = .idle
@State private var amplitudes: [Float] = []
@State private var amplitudeSource: (any VoiceAmplitudeSource)? = nil
ToolbarContainer {
HStack(alignment: .bottom, spacing: 8) {
// ... menu, editor ...
if text.isEmpty {
VoiceButton(
provider: voiceProvider,
onResult: { result in /* finalized text/audio */ },
onStateChange: { state in
withAnimation(.smooth(duration: 0.25)) { voiceState = state }
amplitudeSource = (state == .recording)
? voiceProvider as? any VoiceAmplitudeSource
: nil
}
)
} else {
SendButton(isEnabled: true, action: send)
}
}
}
.accessory {
switch voiceState {
case .idle:
EmptyView()
case .recording:
VoiceWaveform(amplitudes: amplitudes)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
case .transcribing:
TranscribingIndicator()
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}
}
.voiceAmplitudes(from: amplitudeSource, into: $amplitudes)The .transcribing state exists so the accessory stays visible between stopRecording() and the final result — without it the bar would briefly collapse and reflow once the transcript arrives.
Slash popup goes in .popup(isPresented:), outside the toolbar slab. Present it only while the editor text is in slash-command mode:
ToolbarContainer {
HStack(alignment: .bottom, spacing: 8) {
// ... editor with $text driving `matches` via SlashCommandProvider ...
}
}
.popup(isPresented: text.hasPrefix("/")) {
SlashCommandPopup(
commands: matches,
selectedIndex: selectedIndex,
onSelect: commit
)
}Implement SlashCommandProvider against your own command source, or use the bundled StaticSlashCommandProvider for fixed lists.
Attachments are data, not views. ToolbarAttachment exposes only the metadata the default renderer needs: display name, icon, optional badge label / symbol, and semantic tint. Use URLAttachment for dropped or pasted URLs; it classifies common images, documents, spreadsheets, presentations, code, archives, audio, and video through AttachmentResolver. FileAttachment, ImageAttachment, and PathAttachment remain available when callers want to force a specific treatment.
Render attachments as fixed-height previews with AttachmentChip in the accessory area above the editor row. Image attachments keep their aspect ratio and render as rounded thumbnails; other attachments render as extension badges. The thumbnail radius is inset-aware so its curve aligns with the outer toolbar slab:
ToolbarContainer {
HStack(alignment: .bottom, spacing: 8) {
// ... menu, editor, trailing action ...
}
}
.accessory {
if !attachments.isEmpty {
AttachmentStrip(attachments: attachments) { attachment in
remove(attachment)
}
}
}For inline [[marker]] attachments rendered directly inside the editor text, conform to InlineAttachmentRenderer.
| Component | Role |
|---|---|
ToolbarContainer |
Liquid Glass slab + GlassEffectContainer morph domain |
.popup { } |
Modifier inserting a floating surface above the slab |
.accessory { } |
Modifier inserting an ephemeral strip above the content |
.footer { } |
Modifier inserting persistent controls below the content |
ToolbarEditor |
Cross-platform multi-line editor (NSTextView / UITextView backed) |
ToolbarMenuButton |
Glass-circle menu styled to match other buttons |
SendButton / StopButton |
Trailing action buttons with shared metrics |
VoiceButton |
Mic ↔ stop, drives a VoiceInputProvider and emits VoiceState |
VoiceWaveform |
Bar-graph visualization fed by VoiceAmplitudeSource |
TranscribingIndicator |
Progress strip for the post-recording analysis state |
AttachmentChip |
Fixed-height preview for any ToolbarAttachment |
AttachmentStrip |
Horizontal attachment row with scroll clipping disabled |
URLAttachment |
Standard URL-backed attachment with extension / UTType classification |
AttachmentResolver |
Standard category, symbol, badge, and tint resolver for URL attachments |
SlashCommandPopup |
Glass popup list of matches |
GlassCircleButtonStyle |
Re-usable circular Liquid Glass button style |
ToolbarControlMetrics |
Platform-tuned circleDiameter / symbolSize (28/13 macOS, 40/17 iOS) |
Protocols you implement: VoiceInputProvider, VoiceAmplitudeSource, SlashCommandProvider, ToolbarAttachment / ToolbarURLAttachment, InlineAttachmentRenderer.
Use .footer { } for persistent composer controls that belong below the editor, such as attachment menus, workspace selectors, model selectors, and send / stop buttons:
ToolbarContainer {
ToolbarEditor(
text: $text,
contentHeight: $height,
isFocused: $isFocused,
placeholder: "Ask anything..."
)
.frame(minHeight: 96, maxHeight: 180, alignment: .topLeading)
}
.footer {
HStack(spacing: 8) {
ToolbarMenuButton {
Button("File", systemImage: "doc") { pickFile() }
Button("Image", systemImage: "photo") { pickImage() }
} label: {
Image(systemName: "plus")
}
WorkspacePickerChip(selection: $workspace)
Spacer()
SendButton(isEnabled: !text.isEmpty, action: send)
}
}- Declarative composition — no environment-modifier soup. The composer body is just SwiftUI views in an
HStack/VStack. - One slab, one morph domain — children that need glass (popup, capsule chips, circle buttons) share the container's
GlassEffectContainerso shape transitions stay fluid. - Provider-driven I/O — voice, slash commands, and inline attachment rendering are protocols. The library never talks to Whisper, Speech, OCR, or any concrete backend.
- Cross-platform internals stay private —
ToolbarEditoris the only place AppKit / UIKit leaks, and that leak is sealed behind the public SwiftUI surface.
See DESIGN.md for the full architecture write-up.
MIT — see LICENSE.