Skip to content

1amageek/Toolbar

Repository files navigation

Toolbar

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.

Requirements

Swift 6.2+
Platforms iOS 26+ / iPadOS 26+ / macOS 26+
Xcode 26+

Installation

Swift Package Manager:

.package(url: "https://github.com/1amageek/Toolbar.git", branch: "main")

Then add Toolbar to your target dependencies.

Quick start

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.

Voice + accessory area

.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 commands

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

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.

Public surface

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.

Footer controls

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)
    }
}

Design philosophy

  • 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 GlassEffectContainer so 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 privateToolbarEditor is 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.

License

MIT — see LICENSE.

About

Awesome autolayout Toolbar. Toolbar is a library for iOS. You can easily create chat InputBar.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages