iMessage, rebuilt line for line. Now it’s one SwiftUI view.
Bubbles that only get a tail at the end of a run. Typing dots that breathe. “Delivered” fading into “Read.” A keyboard you drag down with your finger. A year of details, and you add it with one line.
https://github.com/unionst/swift-chat.git
In Xcode: File › Add Package Dependencies, then paste.
Version 1.0. Free to use in any app. iOS 18 and later. Made by Union St.
The easiest chat is a Chat with some Messages in it.
import SwiftChat
Chat {
Message("Hey!", role: .user(id: "alex"), timestamp: .now)
Message("Hi 👋", role: .me, timestamp: .now)
}
.me is the right side, tinted. .user is the left. .system sits in the middle. That snippet is a finished, polished conversation.
Real chats have data that keeps changing, so Chat also takes a collection and turns every element into a bubble. The only rule is that your model is Identifiable. It never owns your data; it reacts as it changes. Sending, typing, attachments and pagination are modifiers, like the rest of SwiftUI.
Chat(messages) { message in
Message(message.text, role: message.role, timestamp: message.sentAt)
.messageStatus(message.status)
}
.chatTypingIndicators(typing)
.chatInputCapabilities([.photoLibrary, .files])
.onChatSend { text, media in
await conversation.send(text, media)
}
The eighty percent that isn’t SwiftUI.
A flat list of messages is the easy part. Making it feel like Messages is the rest of the work. Underneath, Swift Chat is a UICollectionView wrapped to look like any other view at your call site, because a SwiftUI List can’t do these things, and every one of them is a separate hard problem.
Drag the keyboard down like normal.
In Messages you can just drag the keyboard down with your finger and the transcript rides with it. That is not natural to build. Every time the keyboard moves, Swift Chat remeasures how far it went and pushes the scroll view by exactly that much, frame by frame.
And the grab handle isn’t the keyboard. You can start the drag on the input bar’s top edge, and the transcript, the bar and the keyboard travel as one welded assembly. Miss that and it feels like three views coming apart.
Bubbles on springs.
Scroll through Messages quickly and the bubbles lag a little and settle. Swift Chat uses a UIDynamicAnimator for the same effect: every cell sits on a stiff little spring, so a hard flick overshoots and settles as a chain instead of stopping like one rigid sheet.
Nothing jumps when messages come in. At the bottom, new messages slide into view. Scrolled up, the list doesn’t move; they land underneath and a scroll-to-bottom button appears.
The bar changes width when it leaves the edge.
At rest the input bar sits flush against the bottom of the screen and runs nearly edge to edge. Raise the keyboard and it doesn’t just move up. It narrows, pulling in on both sides.
A rounded rectangle flush against a rounded screen has to share the screen’s corner radius or the two curves fight. The moment the bar floats, the correct radius changes, and so does the correct width. Apple does this. Almost nobody notices, and everybody notices when it’s wrong.
And so many little things.
A tail is drawn only when the next bubble is from a different sender or the previous message is more than a minute old. The bubble is a hand-traced shape whose geometry scales with its corner radius. Images arrive with a BlurHash placeholder. New messages come in with a short, soft haptic that matches the one in Messages. Apple never shipped that one either.
ShapeStyle. .chatBubbleStyle(Color.pink.gradient), and it fits your app.
Already in apps people use every day.
Every one of these is Swift Chat with its own bubble style and header. Polymarket’s market chat ran on it for a stretch too, before they forked it into their codebase.
A year of details.
From the source repository’s log. The collection view alone is nearly a quarter of the package.
Every modifier, on one page.
Ordinary SwiftUI view modifiers applied to Chat. The full documentation has an example for each.
| Chat modifier | What it does |
|---|---|
chatInputPlaceholder(_:) | Placeholder text in the input field. Default is “Message”. |
chatInputCapabilities(_:) | Which attachments the plus button offers: .photoLibrary, .files, or [] for text only. |
onChatSend { text, media in } | Async handler called when the user sends. Either value may be nil. |
onChatTypingChanged { isTyping in } | Fires as the user starts and stops typing, for sending typing events to your server. |
chatTypingIndicators(_:) | Shows typing dots for the given roles. |
chatHeader { } | A SwiftUI view pinned above the transcript. |
chatEmptyView { } | What to show when there are no messages. |
chatAutoscrollBehavior(_:) | .whenAtBottom (default), .always, or .never. A scroll-to-bottom button appears when needed. |
chatLoadsOlderMessages { } | Async loader called at the top of the transcript. Return false when nothing older remains. |
onChatScrollEdge(_:perform:) | Callback when the reader reaches the top or bottom edge. |
onMessagesEvictable { ids in } | Which off-screen message ids can be dropped in very long threads. |
chatMessageContextMenu { id in } | Long-press menu items per message, as ChatContextMenuItem data. |
onChatMessageTap { id in } | Tap handler per message. |
chatSenderInfo(_:) | .automatic shows avatars and names in group chats only; .always shows them everywhere. |
chatBubbleStyle(_:) | Any ShapeStyle for outgoing bubbles. .tint(_:) also works. |
chatBubbleTailsHidden(_:) | Hides bubble tails. |
chatInputBarTint(_:) | A translucent tint over the input bar’s glass. |
chatHapticsDisabled(_:) | Turns off the tap on incoming messages. |
chatPaginationAnimated(_:) | Whether bubbles animate during rapid bursts of messages. |
Message modifiers and attachments
| Message modifier | What it does |
|---|---|
messageStatus(_:) | Shows sending, sent, delivered, read, or failed under the bubble. |
messageMedia(_:) | Attaches an image, video, audio clip, file, location, or poll. |
messageAttachment { } | Renders a custom SwiftUI view as the attachment. |
messageHeader { } | A view above the bubble, such as a sender name. |
messageFooter { } | A view below the bubble. |
messageAvatar { } | A custom avatar for incoming messages. |
messageStyle(_:) | .default for bubbles, .plain for bare text. |
contextMenu { } | A long-press menu for that message. |
font(_:) | Overrides the bubble text font. |
contentVersion(_:) | Forces a re-render when content changes but the id does not. |
| Attachment | Signature |
|---|---|
| Image | .image(url:width:height:blurhash:) |
| Video | .video(url:thumbnailURL:duration:) |
| Audio | .audio(url:duration:waveform:) |
| File | .file(url:name:size:mimeType:) |
| Location | .location(latitude:longitude:name:) |
| Poll | .poll(question:options:votes:) |
Or let your coding agent add it.
The API was designed as a result builder so it reads like SwiftUI. Not that you’ll write much of it; your agent will appreciate it. There’s an llms.txt with the whole reference in one request.
Add Swift Chat to this iOS app. Package URL https://github.com/unionst/swift-chat.git, product "SwiftChat", iOS 18+. Read https://unionst.com/swiftchat/llms-full.txt first, then build the conversation screen with Chat(messages) { Message($0.text, role: $0.role, timestamp: $0.sentAt) } and wire sending with .onChatSend.
Questions
What does it require?
iOS 18 or later, Xcode 16 or later, Swift 6.1 or later. Install it with Swift Package Manager and depend on the SwiftChat product.
Does it work with my backend?
Yes. It renders whatever collection of identifiable values you already have and calls you back when the user sends. There is no networking inside it to fight with.
Why iOS 18?
The keyboard tracking, the glass input bar and the scroll-edge effects lean on APIs that arrived in iOS 18. Supporting 17 would mean shipping a worse version of the thing this package exists for.
Is it out of beta?
Yes. 1.0 shipped in September 2026 after forty-six pre-releases. It runs in production in Rate.fm, Tally and Jester.
Is this the same package as UnionChat?
Yes. UnionChat became Swift Chat in September 2026. The package still exports a UnionChat product so existing imports keep compiling, and the old repository URL redirects.
Is it free? Is it open source?
Free, yes. Use it in any app, including commercial ones, at no charge. Open source, no. It ships as a binary the same way Apple distributes its own frameworks. The license is a page long. Teams that want the source can write to [email protected].
Add the chat everyone benchmarks against.
https://github.com/unionst/swift-chat.git
Free to use in any app. Closed source, like Apple’s own frameworks. iOS 18 and later.