# Closed Captions

The Stream API supports adding real-time closed captioning (subtitles for
participants) to your calls. This guide shows you how to build a closed
captioning UI using our Swift SDK.

## Prerequisites

Make sure that the closed captioning feature is enabled in your app's Stream
Dashboard. To enable this feature for a specific call type, go to the call type
settings and set the Closed Captions Mode to one of the following:

- **Available:** the feature is available for your call and can be turned on.
- **Auto-on:** the feature is available and will be automatically turned on when
  the call starts.

## Starting and stopping closed captions

If the Closed Caption Mode is set to **Auto-on**, closed caption will start as
soon as the call starts. If it's set to **Available**, you can start it
manually:

```swift
try await call.startClosedCaptions() // start closed captions
```

In both cases, you can stop closed captions by calling:

```swift
try await call.stopClosedCaptions() // stop closed captions
```

The user must have permission to start or stop closed captioning.

Note that starting and stopping closed captioning affects all call participants.
When closed captions are running, all call participants receive them.

If you want to enable or disable closed captioning for each participant
individually, consider making it Auto-on for the call, but rendering captions
conditionally based on each participant's client-side preference.

## Rendering closed captions

The next two sections show you how to render closed captions in your call UI and
how to start and stop them.

### Closed Captions UI

When running, closed captions are available to your application via `Call.state.closedCaptions` published property. Here is how to use it:

```swift
struct DemoClosedCaptionsView: View {

    @Injected(\.colors) private var colors

    var call: Call
    @State private var items: [CallClosedCaption] = []

    init(_ call: Call) {
        self.call = call
    }

    var body: some View {
        Group {
            if items.isEmpty {
                EmptyView()
            } else {
                VStack {
                    ForEach(items, id: \.hashValue) { item in
                        HStack(alignment: .top) {
                            Text(item.speakerId)
                                .foregroundColor(.init(colors.textLowEmphasis))

                            Text(item.text)
                                .lineLimit(3)
                                .foregroundColor(colors.text)
                                .frame(maxWidth: .infinity)
                        }
                        .transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top)))
                    }
                }
                .padding(.horizontal)
                .padding(.vertical, 8)
                .background(Color.black.opacity(0.75))
                .animation(.default, value: items)
            }
        }
        .onReceive(call.state.$closedCaptions) { items = $0 }
    }
}
```

By default, this property exposes two most recent captions. This behavior can be
[further tweaked](#tweak-visibility-settings).

### Check if closed captions are running

It's always possible to check if closed captions are running:

```swift
let isCaptioningInProgress = call.state.captioning
```

### Toggling closed captions

Only users who have permission to start or stop closed captions can toggle
closed captions on and off. This can be set up in the Stream Dashboard in the
Permissions section.

Here is how you can create a button that toggles closed captions depending on
user permissions:

```swift
import Foundation
import StreamVideo
import StreamVideoSwiftUI
import SwiftUI

struct DemoCloseCaptionsButtonView: View {

    var call: Call
    @State private var areClosedCaptionsAvailable = false
    @State private var isCaptioning = false

    init(call: Call) {
        self.call = call
        areClosedCaptionsAvailable = (call.state.settings?.transcription.closedCaptionMode ?? .disabled) != .disabled
        && call.currentUserHasCapability(.startClosedCaptionsCall) == true
        && call.currentUserHasCapability(.stopClosedCaptionsCall) == true
        isCaptioning = call.state.captioning == true
    }

    var body: some View {
        Group {
            if areClosedCaptionsAvailable {
                Button {
                        Task {
                            do {
                                if isCaptioning {
                                    try await call.stopClosedCaptions()
                                } else {
                                    try await call.startClosedCaptions()
                                }
                            } catch {
                                log.error(error)
                            }
                        }
                } label: {
                    Label {
                        Text(isCaptioning ? "Disable Closed Captions" : "Closed Captions")
                    } icon: {
                        Image(
                            systemName: isCaptioning
                            ? "captions.bubble.fill"
                            : "captions.bubble"
                        )
                    }
                }
                .onReceive(call.state.$captioning) { isCaptioning = $0 }
            }
        }
        .onReceive(call.state.$settings) {
            guard let mode = $0?.transcription.closedCaptionMode else {
                areClosedCaptionsAvailable = false
                return
            }
            areClosedCaptionsAvailable = mode != .disabled
        }
    }
}
```

Note that starting and stopping closed captioning affects all call participants.
When closed captions are running, all call participants receive them.

If you want to enable or disable closed captioning for each participant
individually, consider making it Auto-on for the call, but rendering captions
conditionally based on each participant's client-side preference.

And now we can add these newly created components anywhere in our call UI.

## Advanced usage

If the default behavior is not enough, there are several ways to tweak it.

### Override the default Close Caption Mode

It's possible to override the default Close Caption Mode of the call type when
creating a call:

```swift
try await call.create(
    transcription: TranscriptionSettingsRequest(
        closedCaptionMode: .available,
        mode: .available
    )
)
```

### Tweak visibility settings

By default, we keep a maximum of two captions visible, and each caption is
visible for a maximum duration of 2.7 seconds (unless it is pushed out earlier
by newer captions). This allows for a nice real-time experience while making
sure there's enough time to read each caption.

The settings can be tweaked further:

```swift
await call.updateClosedCaptionsSettings(
    itemPresentationDuration: 2.7, // maximum duration a caption can stay visible(in seconds)
    maxVisibleItems: 2 // maximum number of captions visible at one time
)
```

### Build your own logic

Finally, if the behavior provided by the SDK isn't enough, you can always
subscribe to the caption events and build your own logic. Here is how to do it:

```swift
let call = streamVideo.call(callType: "default", callId: "123")

for await event in call.subscribe(for: ClosedCaptionEvent.self) {
    print("Closed caption event: \(event)")
}
```

---

This page was last updated at 2025-08-15T14:16:40.047Z.

For the most recent version of this documentation, visit [https://getstream.io/video/docs/ios/ui-cookbook/closed-captions/](https://getstream.io/video/docs/ios/ui-cookbook/closed-captions/).