Source Stack
Citations for an AI answer: a compact pill of overlapping site badges and a source count that opens into a list of numbered source cards. The badges fly out of the stack into their cards as the list cascades in under them, each card opens its link with a press, and closing gathers the badges back into the pill.
- Type
- SourceStack
- Files
- SourceStack.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 1.0.0
The sources behind an AI answer, folded into one pill under the reply and opened into numbered cards on tap. The pill shows the first few sources as overlapping badges (a colour disc with the site's initial) and the count. Tapping it sends those badges out of the stack into their cards while the list cascades in, numbered so they match the [1] style markers in your answer text. Tapping a card opens its link, or calls onOpen if you pass one. swift SourceStack([ .init(title: "Tram 28, stop by stop", site: "Tram Notes", detail: "Updated May", url: URL(string: "https://example.com/28")), .init(title: "How the funiculars climb", site: "Hill & Harbour", url: URL(string: "https://example.com/funiculars")) ])
Notes
- Closed, the stack is one pill: the first
stackedsources (3 by default) as overlapping badges, each a colour disc with the site's initial ringed in the surface so they stay apart, then the count ("4 sources", inflected for one) and a chevron. Place it directly under the answer it supports.alignmentputs the pill at the leading edge (the default, under an answer) or centred when the stack stands on its own; the list always spans the width. - Tapping the pill opens the list. The pill's badges fly out of the stack into their own cards on the morph spring with a little give, while the cards cascade in under them 40ms apart, rising 10pt as they fade in. Sources past
stackedarrive with their cards. The pill drops to the count alone and its chevron turns over. Closing reverses it on the quicker dismiss spring: the cards fade together and the badges gather back into the pill. - Each card shows its badge, the title (up to two lines), the site and optional
detail(a date, say), and its citation number, matching the[1]markers in your answer text. A tap opensurlthroughopenURL, or callsonOpenwhen you pass one, so you can show the page in-app. - Pass
isExpandedto open the list from your own code, for example when someone taps a[2]marker in the answer. A change from outside runs the same flight as a tap. - The pill and every card press in with the house press: a size-aware sink that springs back with a little give. One selection haptic per open or close, one light impact per source opened.
- VoiceOver reads the pill as "4 sources, collapsed" with a hint, and each card as one link: "Source 2: Tram 28, stop by stop, Tram Notes, Updated May". The badges and numbers are decoration and stay out of the tree.
- Reduce Motion: the badges move without overshoot on a short spring, the cards fade in without rising, and presses shade instead of sinking. Haptics are unchanged.
- Dynamic Type: titles and site lines scale freely; badges stop growing at xxxLarge so the stack stays a stack.
Stylesets the surface, text, secondary and ink colours, the badgeblocks(sky, butter, sage, lilac, sand and signal by default) and the card corner radius.
Usage
SourceStackExample()Parameters
| Parameter | Description |
|---|---|
sources | The sources, in citation order. Card numbers follow this order, starting at 1. |
isExpanded | Optional two-way open state, for opening the list from your own marker taps. Without it the pill keeps its own. |
stacked | How many badges the closed pill shows, 1...5. Defaults to 3. |
alignment | Where the pill sits across the width: .leading under an answer (the default), .center on its own. |
style | Surfaces, text and badge colours. Defaults to the SwiftPieces house palette, adapting to light and dark. |
onOpen | Called with the tapped source. Without it a card opens the source's url through openURL. |
Source
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
import UIKit
/// The sources behind an AI answer, folded into one pill under the reply and opened into numbered cards on tap.
///
/// The pill shows the first few sources as overlapping badges (a colour disc with the site's initial) and the count.
/// Tapping it sends those badges out of the stack into their cards while the list cascades in, numbered so they match
/// the `[1]` style markers in your answer text. Tapping a card opens its link, or calls `onOpen` if you pass one.
///
/// ```swift
/// SourceStack([
/// .init(title: "Tram 28, stop by stop", site: "Tram Notes", detail: "Updated May", url: URL(string: "https://example.com/28")),
/// .init(title: "How the funiculars climb", site: "Hill & Harbour", url: URL(string: "https://example.com/funiculars"))
/// ])
/// ```
///
/// - Parameters:
/// - sources: The sources, in citation order. Card numbers follow this order, starting at 1.
/// - isExpanded: Optional two-way open state, for opening the list from your own marker taps. Without it the pill keeps its own.
/// - stacked: How many badges the closed pill shows, 1...5. Defaults to 3.
/// - alignment: Where the pill sits across the width: `.leading` under an answer (the default), `.center` on its own.
/// - style: Surfaces, text and badge colours. Defaults to the SwiftPieces house palette, adapting to light and dark.
/// - onOpen: Called with the tapped source. Without it a card opens the source's `url` through `openURL`.
public struct SourceStack: View {
/// One cited source.
public struct Source: Identifiable, Hashable, Sendable {
public var id: String
/// The page or document title, up to two lines on its card.
public var title: String
/// The site or publisher. Its first letter is the badge.
public var site: String
/// A short extra after the site, such as a date. Optional.
public var detail: String?
/// Where a tap goes when you don't pass `onOpen`.
public var url: URL?
/// `id` defaults to the URL, or to the site and title when there is no URL.
public init(title: String, site: String, detail: String? = nil, url: URL? = nil, id: String? = nil) {
self.id = id ?? url?.absoluteString ?? "\(site)|\(title)"
self.title = title
self.site = site
self.detail = detail
self.url = url
}
}
/// Colours and shape. `.standard` is the house palette.
public struct Style: Sendable {
/// The pill and the cards.
public var surface: Color
/// Titles and the count.
public var text: Color
/// Site lines, numbers and the chevron.
public var secondary: Color
/// The initial on each badge.
public var ink: Color
/// Badge discs, cycled across sources.
public var blocks: [Color]
/// Card corner radius.
public var cornerRadius: CGFloat
/// Pass only what you want to change; `nil` keeps the house value.
public init(surface: Color? = nil, text: Color? = nil, secondary: Color? = nil, ink: Color? = nil, blocks: [Color]? = nil, cornerRadius: CGFloat = 18) {
self.surface = surface ?? adaptiveColor(light: 0xFFFFFF, dark: 0x1C1C1C)
self.text = text ?? adaptiveColor(light: 0x141414, dark: 0xF4F3EF)
self.secondary = secondary ?? adaptiveColor(light: 0x5C5A56, dark: 0xA6A49F)
self.ink = ink ?? houseColor(0x141414)
self.blocks = blocks ?? houseBlocks()
self.cornerRadius = max(cornerRadius, 0)
}
public static let standard = Style()
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.openURL) private var openURL
@Namespace private var flight
@State private var ownExpanded = false
@State private var toggleTick = 0
@State private var openTick = 0
private let sources: [Source]
private let isExpanded: Binding<Bool>?
private let stacked: Int
private let alignment: HorizontalAlignment
private let style: Style
private let onOpen: ((Source) -> Void)?
public init(_ sources: [Source], isExpanded: Binding<Bool>? = nil, stacked: Int = 3, alignment: HorizontalAlignment = .leading, style: Style = .standard, onOpen: ((Source) -> Void)? = nil) {
self.sources = sources
self.isExpanded = isExpanded
self.stacked = min(max(stacked, 1), 5)
self.alignment = alignment
self.style = style
self.onOpen = onOpen
}
private var expanded: Bool { isExpanded?.wrappedValue ?? ownExpanded }
private var motion: PieceMotion { PieceMotion(reduceMotion: reduceMotion) }
public var body: some View {
if !sources.isEmpty {
VStack(alignment: alignment, spacing: 8) {
pill
if expanded {
VStack(spacing: 8) {
ForEach(Array(sources.enumerated()), id: \.element.id) { index, source in
card(source, index: index)
// The cards arrive under the flying badges one after another, and leave together.
.transition(motion.transition(.asymmetric(
insertion: .opacity.combined(with: .offset(y: -10)).animation(motion.cascade(motion.reveal, index: index, step: 0.04)),
removal: .opacity.animation(motion.dismiss)
)))
}
}
}
}
// Fills the width offered with the pill at `alignment`, so it stays put as the list opens under it.
.frame(maxWidth: .infinity, alignment: Alignment(horizontal: alignment, vertical: .center))
// One weight, semibold, for every string and symbol (LIQUID_GLASS.md, rule 13).
.fontWeight(.semibold)
// One clock for the flight, whether the pill was tapped or the host flipped `isExpanded`: the badges travel
// on the morph spring with a little give, and come home on the quicker dismiss spring.
.animation(expanded ? motion.morph : motion.dismiss, value: expanded)
.sensoryFeedback(.selection, trigger: toggleTick)
.sensoryFeedback(.impact(weight: .light), trigger: openTick)
}
}
// MARK: Pill
private var pill: some View {
Button(action: toggle) {
HStack(spacing: 10) {
if !expanded {
HStack(spacing: -9) {
ForEach(Array(sources.prefix(stacked).enumerated()), id: \.element.id) { index, source in
badge(source, index: index, size: 26)
.matchedGeometryEffect(id: source.id, in: flight)
.zIndex(Double(stacked - index))
}
}
}
Text("^[\(sources.count) source](inflect: true)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(style.text)
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.caption.weight(.semibold))
.foregroundStyle(style.secondary)
.rotationEffect(.degrees(expanded ? 180 : 0))
.accessibilityHidden(true)
}
.padding(.leading, expanded ? 16 : 7)
.padding(.trailing, 14)
.frame(minHeight: 40)
.background(style.surface, in: .capsule)
.contentShape(.capsule)
.frame(minHeight: 44)
}
.buttonStyle(PiecePressStyle())
.accessibilityLabel(Text("^[\(sources.count) source](inflect: true)"))
.accessibilityValue(expanded ? Text("Expanded") : Text("Collapsed"))
.accessibilityHint(expanded ? Text("Hides the sources.") : Text("Shows the sources."))
}
// MARK: Cards
private func card(_ source: Source, index: Int) -> some View {
let shape = RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous)
let line = [source.site, source.detail].compactMap { $0 }.joined(separator: " · ")
return Button { open(source) } label: {
HStack(spacing: 12) {
// The pill's badges land here; the rest arrive with their cards.
if index < stacked {
badge(source, index: index, size: 32)
.matchedGeometryEffect(id: source.id, in: flight)
} else {
badge(source, index: index, size: 32)
}
VStack(alignment: .leading, spacing: 2) {
Text(source.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(style.text)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(line)
.font(.caption.weight(.semibold))
.foregroundStyle(style.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
// The citation number, matching the [n] markers in the answer.
Text("\(index + 1)")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(style.secondary)
.frame(minWidth: 24, minHeight: 24)
.background(style.secondary.opacity(0.12), in: .circle)
.accessibilityHidden(true)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(style.surface, in: shape)
.contentShape(shape)
}
.buttonStyle(PiecePressStyle())
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text("Source \(index + 1): \(source.title), \(line)"))
.accessibilityAddTraits(.isLink)
.accessibilityHint(Text("Opens the source."))
}
/// A colour disc with the site's initial, ringed in the surface so overlapping badges stay apart.
private func badge(_ source: Source, index: Int, size: CGFloat) -> some View {
let tint = style.blocks.isEmpty ? Color.accentColor : style.blocks[index % style.blocks.count]
return Text(String(source.site.trimmingCharacters(in: .whitespaces).prefix(1)).uppercased())
.font(.system(size: size * 0.42, weight: .semibold, design: .rounded))
.foregroundStyle(style.ink)
.frame(width: size, height: size)
.background(tint, in: .circle)
.overlay { Circle().strokeBorder(style.surface, lineWidth: 2) }
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
.accessibilityHidden(true)
}
// MARK: Actions
private func toggle() {
let next = !expanded
if let isExpanded { isExpanded.wrappedValue = next } else { ownExpanded = next }
toggleTick += 1
}
private func open(_ source: Source) {
openTick += 1
if let onOpen { onOpen(source) } else if let url = source.url { openURL(url) }
}
}
/// sRGB from a hex integer. `Double` keeps `0x14 / 255` as 20/255, which is what `Color.init` did inline.
private func houseColor(_ hex: UInt32) -> Color {
Color(red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
}
/// Badge discs, in house order. Split out of `Style.init` so that initializer type-checks.
private func houseBlocks() -> [Color] {
[0x9CC2FF, 0xFFD976, 0xA9DCB7, 0xCDB8FF, 0xE9D5B3, 0xFF0000].map { hex in houseColor(hex) }
}
/// A house-palette colour that follows the interface style.
private func adaptiveColor(light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { @Sendable traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
})
}
// MARK: - Example
/// The sources under an answer about Lisbon's trams, opening and closing on their own.
private struct SourceStackExample: View {
@State private var expanded = false
private let sources: [SourceStack.Source] = [
.init(title: "Why the old trams still run", site: "The Lisbon Review", detail: "2 days ago"),
.init(title: "Tram 28, stop by stop", site: "Tram Notes", detail: "Updated May"),
.init(title: "How the funiculars climb", site: "Hill & Harbour"),
.init(title: "A short history of the tram", site: "City Rails Journal", detail: "2019")
]
var body: some View {
SourceStack(sources, isExpanded: $expanded, alignment: .center) { _ in }
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.background(adaptiveColor(light: 0xF3F2EE, dark: 0x121212))
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(expanded ? 2.6 : 1.2))
expanded.toggle()
}
}
}
}
#Preview("Light") {
SourceStackExample()
}
#Preview("Dark") {
SourceStackExample()
.preferredColorScheme(.dark)
}
// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core, follow, pressMath, press, pressStyle)
/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
var reduceMotion = false
// Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
/// No overshoot, 90% in about 90ms. A press arriving under the finger.
static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
/// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
/// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
/// About 15% overshoot. A resolved action landing. At most once per interaction.
static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
/// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }
/// Reduce Motion: settles become this short spring with no overshoot.
private static var still: Animation { .spring(duration: 0.25, bounce: 0) }
// Roles.
/// Touch-down. Starts on the same frame and never bounces under the finger.
var press: Animation { .spring(Self.tight) }
/// The finger lifts off a pressed surface, which springs back through rest.
var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// Lands on a detent, page or segment.
var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
/// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
/// never shows a value that isn't true.
var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
/// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A shape or container changes size, corner radius or form, with a little give at the end.
var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
/// Something appears, opens or expands. Opening is a little slower than closing.
var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
/// Something leaves, closes or collapses. Quick and firm, out of the way.
var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
/// A resolved action lands: a check, a sent state, a reaction.
var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
/// A refused action. Firm, no wobble; `pieceShake` adds the movement.
var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
/// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }
/// Loops, drifts, idle breathing and parallax run only when this is true.
var allowsAmbient: Bool { !reduceMotion }
/// A moving transition, or a plain fade under Reduce Motion.
func transition(_ transition: AnyTransition) -> AnyTransition {
reduceMotion ? .opacity : transition
}
/// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
/// blurs out as the new one sharpens in. A fade under Reduce Motion.
@MainActor var swap: AnyTransition {
reduceMotion ? .opacity : AnyTransition(.blurReplace)
}
}
extension PieceMotion {
/// Follow-through: rank 0 leads, each later rank arrives a beat later on a slightly looser spring. Safe to reverse.
func follow(_ spring: Spring = PieceMotion.elastic, rank: Int) -> Animation {
guard !reduceMotion else { return .spring(duration: 0.25, bounce: 0) }
let k = Double(min(max(rank, 0), 6))
return .spring(duration: spring.duration + 0.04 * k, bounce: min(spring.bounce + 0.02 * k, 0.55))
}
/// One-shot entrances only: item `index` waits 30ms per place, capped at the seventh. Exits go together.
func cascade(_ animation: Animation, index: Int, step: Double = 0.03) -> Animation {
guard !reduceMotion, index > 0 else { return animation }
return animation.delay(step * Double(min(index, 7)))
}
}
extension PieceMotion {
/// About `depth` points per edge, not a fixed percentage: an icon sinks to 0.92, a pill 0.95, a card 0.985.
nonisolated static func pressScale(for size: CGSize, depth: CGFloat = 2.5) -> CGFloat {
let side = (max(size.width, 1) * max(size.height, 1)).squareRoot()
return min(max(1 - depth * 2 / side, 0.92), 0.985)
}
/// An anchor partway from the center toward the touch, so the press leans into the finger without tipping.
nonisolated static func pressAnchor(touch: CGPoint?, in size: CGSize, lean: CGFloat = 0.6) -> UnitPoint {
guard let touch, size.width > 0, size.height > 0 else { return .center }
let x = min(max(touch.x / size.width, 0), 1)
let y = min(max(touch.y / size.height, 0), 1)
return UnitPoint(x: 0.5 + (x - 0.5) * lean, y: 0.5 + (y - 0.5) * lean)
}
}
/// Sinks on touch-down, leaning toward the touch if given, and springs back from the same lean. Under Reduce
/// Motion it shades instead of moving (darker in light mode, lighter in dark), without turning transparent.
private struct PiecePress: ViewModifier {
let pressed: Bool
var touch: CGPoint?
var depth: CGFloat = 2.5
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
@State private var size: CGSize = .zero
@State private var anchor: UnitPoint = .center
func body(content: Content) -> some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let scale = pressed && !reduceMotion ? PieceMotion.pressScale(for: size, depth: depth) : 1
content
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.brightness(pressed && reduceMotion ? (colorScheme == .dark ? 0.1 : -0.08) : 0)
.scaleEffect(scale, anchor: anchor)
.animation(pressed ? motion.press : motion.release, value: pressed)
.onChange(of: pressed) { _, isPressed in
if isPressed { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
}
.onChange(of: touch) { _, touch in
if pressed, let touch { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
}
}
}
private extension View {
/// Sinks this view while `pressed`, leaning toward `touch` (in this view's coordinates) when given.
func piecePress(_ pressed: Bool, touch: CGPoint? = nil, depth: CGFloat = 2.5) -> some View {
modifier(PiecePress(pressed: pressed, touch: touch, depth: depth))
}
}
/// Only the press, centered: for chips, rows and tiles, and anything in scrolling content.
private struct PiecePressStyle: ButtonStyle {
var depth: CGFloat = 2.5
func makeBody(configuration: Configuration) -> some View {
configuration.label.piecePress(configuration.isPressed, depth: depth)
}
}
// swiftpieces-motion: endInstall
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first →
Building a whole app? See SwiftPieces Pro →
More SwiftUI ai
All ai →Build it yourself: SwiftUI loading states guide · SwiftUI animations guide
Prompt Chips
Liquid glass prompt chips above a composer, in a snapping horizontal row or a grid of columns, each keyed by a tinted glyph disc. They cascade in one after another as they appear. A tap presses the chip and swells it with a pop of its disc, and the other chips flow into it, nearest first, melting into its glass; then the gathered chip condenses into a drop that falls into the composer as the chips close, handing it the text.
Streaming Reply
The AI reply surface in liquid glass. A glass header pill carries the assistant mark and a phase label that morphs letter by letter, text arrives token by token with a per-word fade and a tinted cursor bubble, inline code sits on neutral chips, and user prompts are signal-tinted glass bubbles. A long press lifts the message: the header pill swells into a glass card around the reply and Copy and Regenerate bud out of its bottom edge, and a stopped reply buds a signal Retry out of its header pill.