Skip to content

Stories Viewer

A full-screen story pager with segmented progress bars, tap to move and hold to pause.

Stories Viewer, playing
Download .swift
Origin
Inspired by Kavsoft, written from scratch
Found by
stories · story viewer · instagram · progress bars · pager · full screen · hold to pause · historias · visor de historias · instagram · barras de progreso · paginador · pantalla completa · mantener para pausar

A full-screen story pager with segmented progress bars.

Tap the left or right half to move, and hold to pause. Stories do not advance by themselves with Reduce Motion or VoiceOver on: the tap areas still work.

struct StoriesViewerDemo: View {
@State private var run = 0
private let stories = [
StoriesViewerStory(
id: "dawn",
image: placeholder(colors: [.orange, .pink, .purple], symbol: "sunrise.fill"),
title: "Morning hike",
caption: "First light over the ridge.",
imageLabel: "A sunrise on a pink and purple sky"
),
StoriesViewerStory(
id: "forest",
image: placeholder(colors: [.teal, .green, .black], symbol: "tree.fill"),
title: "Into the woods",
caption: "Three hours of pure green.",
imageLabel: "A tree on a dark green background"
),
StoriesViewerStory(
id: "night",
image: placeholder(colors: [.indigo, .blue, .black], symbol: "moon.stars.fill"),
title: "Camp",
caption: "And then the stars came out.",
imageLabel: "A moon and stars on a night sky"
),
]
var body: some View {
StoriesViewer(stories: stories, duration: 3, onFinish: { run += 1 })
.id(run)
}
private static func placeholder(colors: [Color], symbol: String) -> Image {
let size = CGSize(width: 402, height: 874)
return Image(size: size, opaque: true) { context in
context.fill(
Path(CGRect(origin: .zero, size: size)),
with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height))
)
var icon = context.resolve(Image(systemName: symbol))
icon.shading = .color(.white.opacity(0.35))
let scale = 200 / max(icon.size.width, icon.size.height, 1)
let iconSize = CGSize(width: icon.size.width * scale, height: icon.size.height * scale)
context.draw(icon, in: CGRect(
x: (size.width - iconSize.width) / 2,
y: (size.height - iconSize.height) / 2,
width: iconSize.width,
height: iconSize.height
))
}
}
}
ParameterTypeDefault
stories[StoriesViewerStory]Required
durationTimeInterval4
onClose(() -> Void)?nil
onFinish() -> Void{}

Copy this code into your project, or download the file.

StoriesViewer.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
struct StoriesViewerStory: Identifiable {
let id: String
let image: Image
var title: String
var caption: String
/// What the image shows, for VoiceOver. Leave it nil when the image is decoration.
var imageLabel: String?
}
/// A full-screen story pager with segmented progress bars.
///
/// Tap the left or right half to move, and hold to pause. Stories do not advance by themselves
/// with Reduce Motion or VoiceOver on: the tap areas still work.
struct StoriesViewer: View {
let stories: [StoriesViewerStory]
var duration: TimeInterval = 4
var onClose: (() -> Void)?
var onFinish: () -> Void = {}
@State private var playback = StoriesViewerPlayback()
@State private var isPressing = false
@State private var pressStart: Date?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
private let holdThreshold: TimeInterval = 0.25
private var advancesByItself: Bool { !reduceMotion && !voiceOverEnabled }
private var isRunning: Bool {
advancesByItself && !isPressing && !playback.isFinished && !stories.isEmpty
}
private var current: StoriesViewerStory? {
stories.indices.contains(playback.index) ? stories[playback.index] : stories.last
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let current {
Color.clear
.overlay { storyImage(current) }
.clipped()
.ignoresSafeArea()
.id(current.id)
.transition(.opacity)
tapAreas
VStack(spacing: 0) {
header(for: current)
Spacer(minLength: 24)
caption(for: current)
}
.opacity(isPressing ? 0 : 1)
.animation(.easeInOut(duration: 0.2).delay(isPressing ? holdThreshold : 0), value: isPressing)
}
}
.foregroundStyle(.white)
.task(id: StoriesViewerSchedule(isRunning: isRunning, count: stories.count, duration: duration)) {
guard isRunning else { return }
var last = Date.now
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(33))
guard !Task.isCancelled else { return }
let now = Date.now
let elapsed = now.timeIntervalSince(last)
last = now
update { $0.tick(elapsed: elapsed, duration: duration, count: stories.count) }
}
}
.onChange(of: stories.count) { _, count in
playback.clamp(count: count)
}
.onChange(of: playback.isFinished) { _, isFinished in
if isFinished { onFinish() }
}
}
@ViewBuilder
private func storyImage(_ story: StoriesViewerStory) -> some View {
let image = story.image.resizable().scaledToFill()
if let label = story.imageLabel {
image.accessibilityLabel(label)
} else {
image.accessibilityRepresentation { EmptyView() }
}
}
private var tapAreas: some View {
HStack(spacing: 0) {
tapArea("Previous story") {
update { $0.previous(count: stories.count) }
}
tapArea("Next story") {
update { $0.next(count: stories.count) }
}
}
}
private func tapArea(_ label: String, action: @escaping () -> Void) -> some View {
Button {
let wasHold = pressStart.map { Date.now.timeIntervalSince($0) >= holdThreshold } ?? false
pressStart = nil
if !wasHold {
action()
}
} label: {
Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(StoriesViewerTapAreaStyle { isPressed in
if isPressed { pressStart = .now }
isPressing = isPressed
})
.accessibilityLabel(label)
}
private func header(for story: StoriesViewerStory) -> some View {
VStack(spacing: 10) {
HStack(spacing: 4) {
ForEach(stories.indices, id: \.self) { segment in
Capsule()
.fill(.white.opacity(0.32))
.overlay {
Capsule()
.fill(.white)
.scaleEffect(x: fill(for: segment), anchor: .leading)
}
.frame(height: 3)
}
}
.allowsHitTesting(false)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Story")
.accessibilityValue("\(min(playback.index + 1, stories.count)) of \(stories.count)")
HStack {
Text(story.title)
.font(.headline)
.lineLimit(2)
.minimumScaleFactor(0.7)
.allowsHitTesting(false)
Spacer()
if let onClose {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.headline)
.frame(minWidth: 44, minHeight: 44)
.contentShape(.rect)
}
.accessibilityLabel("Close")
}
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
}
private func caption(for story: StoriesViewerStory) -> some View {
Text(story.caption)
.font(.title3.weight(.semibold))
.lineLimit(8)
.minimumScaleFactor(0.6)
.shadow(color: .black.opacity(0.45), radius: 6, y: 2)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.bottom, 24)
.allowsHitTesting(false)
}
private func fill(for segment: Int) -> Double {
if !advancesByItself, segment == playback.index { return 1 }
return playback.fill(for: segment)
}
private func update(_ change: (inout StoriesViewerPlayback) -> Void) {
var updated = playback
change(&updated)
if updated.index == playback.index {
playback = updated
} else {
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.2)) { playback = updated }
}
}
}
private struct StoriesViewerSchedule: Equatable {
let isRunning: Bool
let count: Int
let duration: TimeInterval
}
/// Reports touches, including ones the system cancels, so a hold can never get stuck.
private struct StoriesViewerTapAreaStyle: ButtonStyle {
let onPressChange: (Bool) -> Void
func makeBody(configuration: Configuration) -> some View {
configuration.label
.contentShape(.rect)
.onChange(of: configuration.isPressed) { _, isPressed in
onPressChange(isPressed)
}
}
}
struct StoriesViewerPlayback: Equatable {
private(set) var index = 0
private(set) var progress = 0.0
private(set) var isFinished = false
mutating func tick(elapsed: TimeInterval, duration: TimeInterval, count: Int) {
guard count > 0, !isFinished, elapsed > 0 else { return }
progress += elapsed / max(duration, 0.1)
if progress >= 1 {
next(count: count)
}
}
mutating func next(count: Int) {
guard count > 0, !isFinished else { return }
if index + 1 < count {
index += 1
progress = 0
} else {
progress = 1
isFinished = true
}
}
mutating func previous(count: Int) {
guard count > 0 else { return }
isFinished = false
index = max(index - 1, 0)
progress = 0
}
/// Keeps the position valid when the list of stories changes, and moves on if new ones arrive after the end.
mutating func clamp(count: Int) {
if isFinished, index + 1 < count {
isFinished = false
index += 1
progress = 0
return
}
guard index >= count else { return }
index = max(count - 1, 0)
progress = 0
}
/// How full a segment's bar is, from 0 to 1.
func fill(for segment: Int) -> Double {
if segment < index { return 1 }
return segment == index ? min(max(progress, 0), 1) : 0
}
}
// MARK: - Demo
struct StoriesViewerDemo: View {
@State private var run = 0
private let stories = [
StoriesViewerStory(
id: "dawn",
image: placeholder(colors: [.orange, .pink, .purple], symbol: "sunrise.fill"),
title: "Morning hike",
caption: "First light over the ridge.",
imageLabel: "A sunrise on a pink and purple sky"
),
StoriesViewerStory(
id: "forest",
image: placeholder(colors: [.teal, .green, .black], symbol: "tree.fill"),
title: "Into the woods",
caption: "Three hours of pure green.",
imageLabel: "A tree on a dark green background"
),
StoriesViewerStory(
id: "night",
image: placeholder(colors: [.indigo, .blue, .black], symbol: "moon.stars.fill"),
title: "Camp",
caption: "And then the stars came out.",
imageLabel: "A moon and stars on a night sky"
),
]
var body: some View {
StoriesViewer(stories: stories, duration: 3, onFinish: { run += 1 })
.id(run)
}
private static func placeholder(colors: [Color], symbol: String) -> Image {
let size = CGSize(width: 402, height: 874)
return Image(size: size, opaque: true) { context in
context.fill(
Path(CGRect(origin: .zero, size: size)),
with: .linearGradient(Gradient(colors: colors), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height))
)
var icon = context.resolve(Image(systemName: symbol))
icon.shading = .color(.white.opacity(0.35))
let scale = 200 / max(icon.size.width, icon.size.height, 1)
let iconSize = CGSize(width: icon.size.width * scale, height: icon.size.height * scale)
context.draw(icon, in: CGRect(
x: (size.width - iconSize.width) / 2,
y: (size.height - iconSize.height) / 2,
width: iconSize.width,
height: iconSize.height
))
}
}
}
#Preview {
StoriesViewerDemo()
}