I rebuilt iMessage line for line. Nobody wanted it.

I spent a year rebuilding iMessage in SwiftUI. Not a chat UI “inspired by” iMessage. iMessage. The bubble tails that only draw on the last message in a run. The typing dots that breathe in a wave instead of blinking. The keyboard you can drag down with your finger while the messages stay welded to it. “Delivered” fading into “Read” at exactly the right moment. I wrote up the whole build separately, and I stand behind every line of it. It might be the best screen I’ve ever made.1

Then I packaged it up, shipped it, and nobody wanted it.

This is the thing nobody wanted, the real package running on my phone, filmed this week:

Not a mockup of iMessage; my package pretending to be it. Watch the send fly out of the input bar on a spring, “Delivered” become “Read,” the dots breathe in their wave, and the reply take the tail from the run above it. The conversation is the demo target’s script, which is why the other guy is asking the questions I wish more people had asked.

The effort side of the ledger

If you read the build post, you know the effort was real. What I hadn’t done until writing this is count it.

Commits590
Days with a commit77
First commitAugust 11, 2025
Most recent commitAugust 29, 2026
Lines written29,300
Lines deleted13,341
Lines still standing14,975
ChatViewController.swift3,573 lines
Commits mentioning the bubble88
Commits mentioning the tail27
  1. Nov 8, 2025Added iOS 26 variants of chat bubble tails
  2. Nov 8, 2025Fixed an issue where the no-tail example bubble had no background
  3. Nov 10, 2025Fix message collation and improve bubble alignment
  4. Nov 10, 2025Merge pull request #10 from unionst/dimitri/bubble-tails
  5. Nov 10, 2025Merge pull request #14 from unionst/dimitri/example
  6. Nov 10, 2025Add ChatBubbleShape padding to messages for proper tail spacing
  7. Nov 10, 2025Adjust bottomContentPadding to 2px to account for bubble tail padding
  8. Nov 10, 2025Align avatar to bottom of bubble including tail
  9. Nov 11, 2025Updated the tail curves to be size agnostic
  10. Nov 11, 2025Merge pull request #18 from unionst/dimitri/tail-shape
  11. Nov 11, 2025Add ChatExample with full screen zoom transition and component controls
  12. Nov 11, 2025Fix message collation to respect 60-second time gap
  13. Nov 14, 2025Fix message spacing issue in SpringyCompositionalLayout
  14. Nov 18, 2025Show message tail for typing indicator
  15. Dec 3, 2025Fix message above typing indicator to not show tail when same sender
  16. Dec 3, 2025Scale typing indicator to account for internal tail space (content bubble matches message)
  17. Feb 3, 2026Add support for rich media previews + improve chat interface
  18. Jul 29, 2026Fly the send bubble behind the input bar's glass and go clear for the flight
  19. Aug 2, 2026messageAttachment: put your own view inside a real bubble
  20. Aug 2, 2026Attachment cards keep the neutral fill on both sides
  21. Aug 2, 2026Attachment bubbles take the tail allowance outside, like text does
  22. Aug 9, 2026A pending message leaves the previous bubble's tail alone; the tail hands off with the delivery label in one apply
  23. Aug 9, 2026Tail hands off at insert so the delivery swap is net-zero, and the rigid transition owns the scroll so the landing is flush by construction
  24. Aug 9, 2026Tail collates at delivery again; the rigid transition pins the top on shrink so the bottom message pops up instead
  25. Aug 10, 2026Attachments can colour the bubble behind them, tail included
  26. Aug 10, 2026The departing label also holds open the tail padding its row is about to lose, releasing both on one curve
  27. Aug 26, 2026A photo is cut out of the bubble itself, tail included, and wears no edge
Every commit on main whose message mentions the tail, oldest first. The first one is nine days before launch. The last one is this week.

The scrolling isn’t SwiftUI at all; it’s a UICollectionView wearing a SwiftUI costume, because List couldn’t do what iMessage does, and that one file is a quarter of the package. The keyboard drag is corrected frame by frame. The bubbles sit on actual springs. The bubble shape is hand-traced. The haptic on receive is a custom pattern I matched to Messages by feel. Here is what each of those sentences costs, filmed on my phone. Try them at 0.5x.

Three sends in a row with replies landing in between, and the keyboard never moves. The message flies out of the bar on a spring and the keyboard stays put underneath it.
A slow drag started on the input bar’s top edge, not on the keyboard. The messages ride down with the bar the entire way. SwiftUI can’t do this on its own; the collection view underneath is why it exists.
Three hard flicks. The transcript overshoots and settles as a chain of springs, each cell lagging its neighbor by a frame or two, instead of stopping like one rigid sheet.
Watch the edges of the input bar, not the keyboard. Keyboard down: flush and wide. Keyboard up: inset on both sides, floating. Two different shapes, one bar.

Eleven people have commits in that repo. I wrote about three in five of them; the rest came from Rafi, Aaron, John, Dimitri, Glenn, Kiran, Yohan, Gokul, Parth and Vikrant. The tail shape that finally worked came in as someone else’s pull request.

None of that was necessary. All of it was the point. I wanted the thing you could drop into any app and have it feel like Apple built it. And I got there.

Ship day

I polished the README. I made the screenshots, in both themes. I built an entire separate demo app so people could try it in one clone.2

The UnionChat package running: tailed bubbles, grouped runs, a system message, and the input bar, in light and dark
The screenshots in the README, which is exactly what a prospective adopter saw. Tails only on the last bubble of each run. Grouped timestamps. A system row in the middle. It is, I still think, correct.

Then I pushed the package public, wrote it up here, told some people, and I think tweeted about it once or twice. I didn’t do more than that. I was scared to, and I can say that now.

Nothing happened.

I want to be precise here, because founders love to fuzz this part. This is not a story about a launch that underperformed, because there was barely a launch. This is a story about silence. Here is that repository today, nine months later:

Stars8
Forks0
Watchers0
Issues ever opened0
Views, last 14 days21 (8 unique)
Clones, last 14 days8 (6 unique)
Top referrergithub.com

Eight clones in two weeks, and I can account for most of them. The kind of silence where you start checking whether the link works.3

The comparison that stung

The tempting excuse is that Swift packages just don’t get traction. Except I have a shelf of them, published by the same person, from the same org, promoted the same way, and I can put them side by side.

Effortlines of Swift
union-chat14,975
union-toast2,945
union-buttons2,064
union-tab-view1,055
union-confetti435
ReceptionGitHub stars
union-chat8
union-toast50
union-buttons4
union-tab-view126
union-confetti25
union-chat1,872 lines per star
Every Swift package I published under Union St, measured today. Lines of Swift counted from the shipping source, excluding tests; stars from the GitHub API. union-chat is five times the code of the next biggest and has the second-fewest stars. union-tab-view, a tab bar, went up eleven days after it and has fifteen times the stars.

union-tab-view is a tab bar. It went up eleven days after union-chat, it is a fourteenth of the code, and it has fifteen times the stars. union-confetti is 435 lines that throw confetti at the screen, and it beat union-chat three to one.

The market wasn’t rejecting Swift packages. It was rejecting this one.

The wrong diagnosis

My first theory was distribution. Wrong day to post. Wrong channels. Needed a demo video. Needed a better hook. I know how to get attention when I want it. The last thing I gave away like this became the most upvoted post in the history of r/swift, so this was the comfortable explanation: the product was great, the launch was timid, fixable. And the theory had the advantage of being partly true. I had barely told anyone.

It took me embarrassingly long to accept the real explanation, which is that a real launch would have changed the number and not the answer: you can’t market your way out of a product nobody was waiting for, and nobody was waiting for this.

Here’s the cold math I skipped before building. How many people are making an iOS app with a chat screen this year? Some. How many of those want their chat to look exactly like iMessage, instead of like their own brand? Fewer. How many of those would rather adopt a dependency than build it themselves and control it? Fewer still. And how many of that sliver would ever find my package?

I never ran that math because I didn’t want the answer. Nobody sits at their desk furious that their typing indicator doesn’t breathe. I was chasing a standard, and the standard was mine.

The reactions I did get were all some version of “wow, impressive.” Not one was “finally.” Impressive is what people say when they’re not going to use it.

That’s the distinction I couldn’t see from inside: impressive is not the same as wanted. Impressive gets you compliments. Wanted gets you users.

The diagnosis I skipped

That’s the honest lesson and I believe it. But writing this post made me go look at the artifact instead of the feeling, and the artifact has a second explanation on it in plain sight.

The thing I said you could drop into any app ships as a closed-source binary. The public repo is 35 lines of Swift: a thin wrapper around an xcframework downloaded from a release. You cannot read it, patch it, or audit it. It requires iOS 18, which in November 2025 meant walking away from a large share of everyone’s install base. It pulls in a third-party image library as a hard dependency. And in forty-six releases it has never once left beta. The newest tag is 0.7.1-beta.23.

This is the entire public Package.swift, which is what an adopter reads before deciding:

let package = Package(
    name: "union-chat",
    platforms: [
        .iOS(.v18)
    ],
    products: [
        .library(
            name: "UnionChat",
            targets: ["UnionChatWrapper", "UnionChatBinary"])
    ],
    dependencies: [
        .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.0.0")
    ],
    targets: [
        .target(
            name: "UnionChatWrapper",
            dependencies: [
                "UnionChatBinary",
                .product(name: "Kingfisher", package: "Kingfisher")
            ],
            path: "Sources/UnionChatWrapper"
        ),
        .binaryTarget(
            name: "UnionChatBinary",
            url: "https://github.com/unionst/union-chat/releases/download/0.7.1-beta.23/UnionChat.xcframework.zip",
            checksum: "da7d56072f3834b4a5c67bd064ec264cbb8a575b60c7a8b9b2256291b41bf67e"
        )
    ]
)

Put yourself in the shoes of the one developer who did find it. They wanted a chat screen. They found an unreadable beta blob, from an author they’ve never heard of, that raises their deployment target and adds a dependency, with a version number that says don’t.

I have been telling this story as “the market didn’t want the perfect thing.” Some of it is “I shipped the perfect thing in a package nobody sane would take a risk on.” Both are true. The second one is more useful, and I couldn’t see it while I was busy being wounded about the first.

It never actually stopped

Here’s the part I didn’t know about myself until I plotted it.

0100200Aug2025SepOctNovDecJan2026FebMarAprMayJunJulAugpublishedWavelength ships it590 commits over 13 months, 6 of them silent
Every commit to the union-chat source repository, by month. The package went public on November 17th, 2025, at the top of that spike. Then it decays, and March through June 2026 are four consecutive months of nothing at all. It comes back in July and August — not because anyone starred it, but because Wavelength needed a chat screen.

Two hundred and eighty-one commits in the month I launched. Then a decay, and then March through June where I touched it zero times, four months running. The sulk, rendered. And then it comes back, hard, in the summer.

Not because someone starred it.

The coda

Five weeks after launch, union-chat went into Wavelength, the music app I actually run, to draw the comments under a review. I didn’t count that as a user because the user was me. By this summer it was the whole messaging surface. Eleven files import it. It is the chat of a real product with real users, and every detail I ground on for a year, the collated tails, the delivery label, the keyboard welded to the input bar, is load-bearing in an app people open.

Wavelength Crew, a group chat inside the Wavelength app: a pink outgoing bubble marked Delivered, then replies from CHAZZJAZZ and jonathan with tails and avatars, a crossed-fingers emoji, and the input barWavelength, this month. That’s union-chat, pink: tail on the last bubble of each run, sender names above each run, “Delivered” under the outgoing message.

And the joke, which I love, is on me. Wavelength doesn’t depend on the package I published. Its Package.resolved points at union-chat-source, the private repository, pinned to a commit on main. And Wavelength isn’t alone. Polymarket runs a version of it too: they forked the source and rewrote a great deal of it. Two apps in the world run this code, and neither one uses the package. Not even I would adopt the thing I shipped.

This month is the busiest that repo has had since launch, 155 commits, and every one of them exists because a real app needed something. Demand arrived from inside the building, and the work instantly got better for having a user.

I don’t regret it. The work made me better in ways client projects never could have, because no client will ever pay for the thirty-first commit about a bubble tail.4 Every trick in that package went straight into everything I’ve shipped since, and now into a product I own.

And no, I’m not opening the source. There is too much in it now. So the package stays exactly what it is: free, closed, and unadopted, while the code inside it runs in two real apps.

Nobody wanted it. Then, eventually, one person did, and it turned out to be me. That’s a worse business plan than I’d like and a better ending than I expected.

Footnotes

  1. I’m biased, but I’m also documented. The build post has the receipts: the springs, the frame-by-frame keyboard math, the hand-traced bubble.

  2. The demo app also has no users. It’s a matched set.

  3. In the draft of this post I wrote “the ninth revision of a bubble tail” and called it a rhetorical number. The real one, counted out of the commit log: twenty-seven commits with “tail” in the message, the first nine days before going public and the most recent this week. Nine months, on the corner of a bubble.