Cards and Containers in SwiftUI: Beautiful Content Presentation
Part 24 of 100 in the SwiftUI Zero to Expert Series
I was scrolling through an e-commerce app when I paused on a product card.
The shadow was just right — not too harsh, not too soft.
The image had rounded corners that matched the card.
The price stood out without screaming.
It was such a small detail, but it made me want to tap and explore.
That’s when I realized: cards aren’t just containers for content.
They’re visual invitations.
A well-designed card says “this matters, look here.” A poorly designed one gets scrolled past without a second thought.
In UIKit, creating consistent cards meant subclassing UIView, managing Auto Layout constraints, handling shadow paths for performance, and creating reusable components that never quite felt reusable.
Every project had slightly different card implementations.
SwiftUI changed my approach entirely.
Cards became compositional — stack some views, add padding, apply a background, round the corners, drop a shadow.
What used to take a custom class now takes a few modifiers.
Let me show you how I build cards that make users want to tap.
Basic Card Designs
Simple Card
The foundation of every card is the same pattern: content, padding, background, corners, shadow.
import SwiftUI
struct SimpleCard: View {
let title: String
let subtitle: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
}
}That shadow formula — color: .black.opacity(0.1), radius: 5, x: 0, y: 2—took me dozens of iterations to settle on.
Too much opacity looks harsh.
Too much radius looks blurry.
The slight y-offset gives the illusion the card is lifted off the surface, floating just above the background.
Image Card
Cards with images need special attention to aspect ratio and clipping.
import SwiftUI
struct ImageCard: View {
let imageName: String
let title: String
let description: String
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Image(systemName: imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 180)
.clipped()
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
.padding()
}
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
}The spacing: 0 on the outer VStack is intentional—I want the image to touch the top of the card with no gap, then have the text content below with its own padding.
This creates visual hierarchy where the image feels like a header.
Horizontal Card
Sometimes vertical space is precious.
Horizontal cards pack information efficiently.
import SwiftUI
struct HorizontalCard: View {
let title: String
let subtitle: String
let icon: String
var body: some View {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.title)
.foregroundColor(.white)
.frame(width: 60, height: 60)
.background(Color.blue)
.cornerRadius(12)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3)
}
}That chevron at the end is subtle UI language.
It tells users “this leads somewhere” — a detail that increases tap rates because users know what to expect.
Product Cards
E-commerce apps live and die by their product cards.
I’ve built dozens of variations.
E-commerce Product Card
import SwiftUI
struct ProductCard: View {
let product: Product
@State private var isFavorite = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .topTrailing) {
Image(systemName: "photo")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 180)
.background(Color.gray.opacity(0.2))
.clipped()
Button {
isFavorite.toggle()
} label: {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundColor(isFavorite ? .red : .gray)
.padding(8)
.background(Color.white)
.clipShape(Circle())
}
.padding(8)
if product.discount > 0 {
Text("-\(product.discount)%")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red)
.cornerRadius(4)
.padding(8)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
VStack(alignment: .leading, spacing: 8) {
Text(product.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
HStack {
Text("$\(product.price, specifier: "%.2f")")
.font(.headline)
.foregroundColor(.blue)
if product.discount > 0 {
Text("$\(product.originalPrice, specifier: "%.2f")")
.font(.caption)
.strikethrough()
.foregroundColor(.secondary)
}
}
HStack(spacing: 4) {
ForEach(0..<5) { index in
Image(systemName: index < Int(product.rating) ? "star.fill" : "star")
.font(.caption2)
.foregroundColor(.orange)
}
Text("(\(product.reviewCount))")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding()
}
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 4)
}
}
struct Product: Identifiable {
let id = UUID()
let name: String
let price: Double
let originalPrice: Double
let discount: Int
let rating: Double
let reviewCount: Int
}The favorite button in the corner took me a while to get right.
That white circular background ensures the heart is visible regardless of the image behind it.
The discount badge on the opposite corner creates visual balance — your eye naturally sweeps from corner to corner.
Compact Product Card
For lists or when showing many items, a compact horizontal format works better.
import SwiftUI
struct CompactProductCard: View {
let product: Product
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
.frame(width: 80, height: 80)
.overlay(
Image(systemName: "photo")
.foregroundColor(.gray)
)
VStack(alignment: .leading, spacing: 4) {
Text(product.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
Text("$\(product.price, specifier: "%.2f")")
.font(.headline)
.foregroundColor(.blue)
HStack(spacing: 2) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundColor(.orange)
Text("\(product.rating, specifier: "%.1f")")
.font(.caption2)
.foregroundColor(.secondary)
}
}
Spacer()
Button {
// Add to cart
} label: {
Image(systemName: "plus")
.padding(8)
.background(Color.blue)
.foregroundColor(.white)
.clipShape(Circle())
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
}
}The add-to-cart button is always within thumb reach.
I learned this from watching users struggle with buttons placed at inconvenient locations — that plus button should be easy to tap while scrolling.
Social Cards
Social media apps need cards that balance content with interaction.
Post Card
import SwiftUI
struct PostCard: View {
let post: SocialPost
@State private var isLiked = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Header
HStack {
Circle()
.fill(Color.blue.opacity(0.3))
.frame(width: 44, height: 44)
.overlay(Text(String(post.author.prefix(1))))
VStack(alignment: .leading, spacing: 2) {
Text(post.author)
.font(.subheadline)
.fontWeight(.semibold)
Text(post.timestamp)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button {
// More options
} label: {
Image(systemName: "ellipsis")
.foregroundColor(.secondary)
}
}
// Content
Text(post.content)
.font(.body)
// Image (if any)
if post.hasImage {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(height: 200)
.overlay(Image(systemName: "photo").font(.largeTitle).foregroundColor(.gray))
}
// Actions
HStack(spacing: 24) {
Button {
isLiked.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: isLiked ? "heart.fill" : "heart")
.foregroundColor(isLiked ? .red : .secondary)
Text("\(post.likes + (isLiked ? 1 : 0))")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Button {
// Comment
} label: {
HStack(spacing: 6) {
Image(systemName: "bubble.right")
Text("\(post.comments)")
}
.font(.subheadline)
.foregroundColor(.secondary)
}
Button {
// Share
} label: {
Image(systemName: "square.and.arrow.up")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
}
}
struct SocialPost: Identifiable {
let id = UUID()
let author: String
let timestamp: String
let content: String
let hasImage: Bool
let likes: Int
let comments: Int
}The like count updates immediately when you tap — post.likes + (isLiked ? 1 : 0).
This optimistic UI update makes the app feel responsive even before the server confirms the action.
Notification Cards
Notifications need to communicate urgency through design.
import SwiftUI
struct NotificationCard: View {
let notification: AppNotification
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
// Icon
Image(systemName: notification.icon)
.font(.title2)
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(notification.color)
.cornerRadius(10)
// Content
VStack(alignment: .leading, spacing: 4) {
Text(notification.title)
.font(.subheadline)
.fontWeight(.semibold)
Text(notification.message)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
Spacer()
// Time and dismiss
VStack(alignment: .trailing, spacing: 8) {
Text(notification.time)
.font(.caption2)
.foregroundColor(.secondary)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
}
}
struct AppNotification: Identifiable {
let id = UUID()
let icon: String
let title: String
let message: String
let time: String
let color: Color
}The colored icon background creates instant visual categorization — red for urgent, blue for info, green for success.
Users learn to recognize notification types at a glance without reading.
Stat Cards
Dashboards need cards that communicate numbers effectively.
import SwiftUI
struct StatCard: View {
let title: String
let value: String
let change: Double
let icon: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: icon)
.foregroundColor(.blue)
Spacer()
HStack(spacing: 4) {
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption)
Text("\(abs(change), specifier: "%.1f")%")
.font(.caption)
.fontWeight(.medium)
}
.foregroundColor(change >= 0 ? .green : .red)
}
Text(value)
.font(.title)
.fontWeight(.bold)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
}
}
struct StatsGridView: View {
var body: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
StatCard(title: "Total Sales", value: "$12,450", change: 12.5, icon: "dollarsign.circle")
StatCard(title: "Orders", value: "1,234", change: -3.2, icon: "bag.fill")
StatCard(title: "Customers", value: "856", change: 8.1, icon: "person.2.fill")
StatCard(title: "Conversion", value: "3.2%", change: 0.5, icon: "chart.line.uptrend.xyaxis")
}
.padding()
}
}The change indicator in the top-right creates a natural reading pattern: icon (what), change (trend), value (how much), title (context).
Users can scan multiple stat cards quickly because the information is always in the same place.
Expandable Card
Some content needs to collapse until users want more details.
import SwiftUI
struct ExpandableCard: View {
let title: String
let content: String
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Button {
withAnimation(.spring()) {
isExpanded.toggle()
}
} label: {
HStack {
Text(title)
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.secondary)
.rotationEffect(.degrees(isExpanded ? 180 : 0))
}
}
if isExpanded {
Text(content)
.font(.body)
.foregroundColor(.secondary)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
}
}The chevron rotation is a small touch that makes a big difference.
Users see the chevron flip and immediately understand the content will collapse or expand.
It’s a learned UI pattern that reduces cognitive load.
What I Know Now That I Wish I’d Known Then
Shadow consistency matters.
I used to eyeball shadows for each card. Now I have a small set of shadow presets light, medium, heavy and use them consistently.
This creates visual hierarchy across the app.
Padding creates breathing room.
Too little padding makes cards feel cramped. Too much wastes space.
I’ve settled on 16 points as my default card padding it works well on both phones and tablets.
Corner radius should match. When a card has an image at the top, the image corners should match the card corners. Nothing looks more amateur than mismatched radii.
Test with real content. Lorem ipsum hides problems.
A product title that’s 50 characters long, a username with 25 characters, a description that’s only 10 words real content breaks assumptions.
The Gotchas That Caught Me
Shadow clipping. If you apply .cornerRadius() before .shadow(), the shadow gets clipped. Always apply shadow before clipping, or use the newer .clipShape() approach.
Background color in dark mode.
Color.white doesn't adapt to dark mode. Use Color(.systemBackground) instead—it's white in light mode, dark in dark mode.
Touch targets. Cards should be tappable, but sometimes you want buttons inside cards too.
Make sure the entire card is a button, or clearly separate the tap zones so users don’t accidentally navigate when they meant to favorite.
Performance with many cards. Shadows are expensive to render.
In lists with hundreds of cards, consider simplifying or removing shadows, or using drawingGroup() to rasterize the shadow.
Well-designed cards improve content presentation:
- Hierarchy through visual weight
- Consistency in spacing and styling
- Interactivity with appropriate feedback
- Responsiveness across screen sizes
That product card I admired in the e-commerce app? I eventually rebuilt it.
Then I built a dozen variations.
Cards became my favorite UI element to design because they’re self-contained puzzles
how do you communicate this information in this space with this visual treatment?
The answer is always the same: content first, then structure, then style.
Get the information hierarchy right, wrap it in consistent spacing, apply shadows and corners that match your design system, and test with real data.
In the next article, we’ll explore Onboarding Flows.
Thank you for reading! If you enjoyed it, please consider clapping 👏 and following for more updates! 🚀
