Skip to content
Components PulseChip

PulseChip

Filters, tags and attribute pills.

Data Display
interactive demo

A live browser stand-in using the exact tokens, motion and API of the SwiftUI component.

Filters, tags and attribute pills.

Compact, selectable pills in default, select, tinted, outlined and glass styles — with an optional animated checkmark.

Single / multi select patterns
5 visual styles
Task-style checkmark
Dead-simple onTap
Usage
PulseChip("Swift", isSelected: $isSwift)
PulseChip("Premium", isSelected: true, style: .tinted)
Install
$ pulse add chip

Source files

Sources/PulseUI/Components/PulseChip/PulseChip.swift
Full source Sources/PulseUI/Components/PulseChip/PulseChip.swift
import SwiftUI

// MARK: - PulseChip

/// Filter chip / tag with selection states, icons, and smooth animations.
public struct PulseChip<Content: View>: View {
    @Environment(\.pulseTheme) private var theme
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    let isSelected: Bool
    let style: ChipStyle
    let showsCheckmark: Bool
    let onTap: (() -> Void)?
    let accessibilityLabel: String?
    let content: () -> Content

    @State private var isPressed = false

    public init(
        _ text: String,
        isSelected: Bool = false,
        style: ChipStyle = .default,
        showsCheckmark: Bool = true,
        accessibilityLabel: String? = nil,
        onTap: (() -> Void)? = nil
    ) where Content == Text {
        self.init(
            isSelected: isSelected,
            style: style,
            showsCheckmark: showsCheckmark,
            accessibilityLabel: accessibilityLabel ?? text,
            onTap: onTap
        ) {
            Text(text)
        }
    }

    public init(
        isSelected: Bool = false,
        style: ChipStyle = .default,
        showsCheckmark: Bool = true,
        accessibilityLabel: String? = nil,
        onTap: (() -> Void)? = nil,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.isSelected = isSelected
        self.style = style
        self.showsCheckmark = showsCheckmark
        self.onTap = onTap
        self.accessibilityLabel = accessibilityLabel
        self.content = content
    }

    public var body: some View {
        Group {
            if let onTap {
                Button(action: {
                    PulseHaptic.selection()
                    onTap()
                }) {
                    chipSurface
                }
                .buttonStyle(.plain)
                .accessibilityLabel(accessibilityLabel ?? "Chip")
                .accessibilityAddTraits(isSelected ? .isSelected : [])
                .accessibilityHint("Tap to toggle")
            } else {
                chipSurface
                    .accessibilityElement(children: .ignore)
                    .accessibilityLabel(accessibilityLabel ?? "Chip")
                    .accessibilityAddTraits(isSelected ? .isSelected : [])
            }
        }
    }

    private var chipSurface: some View {
        HStack(spacing: theme.spacing.xs) {
            if isSelected && showsCheckmark {
                Image(systemName: "checkmark")
                    .font(.system(size: 11, weight: .bold))
                    .foregroundStyle(foregroundColor)
                    .transition(.scale.combined(with: .opacity))
            }

            content()
                .font(theme.typography.subheadline.font)
                .fontWeight(isSelected ? .semibold : .regular)
                .foregroundStyle(foregroundColor)
                .lineLimit(1)
        }
        .padding(.horizontal, 14)
        .padding(.vertical, 8)
        .background(backgroundView)
        .overlay(overlayView)
        .clipShape(Capsule())
        .scaleEffect(isPressed ? 0.95 : 1)
        .contentShape(Capsule())
        .onLongPressGesture(minimumDuration: .infinity, pressing: { pressing in
            if onTap != nil {
                withAnimation(reduceMotion ? .none : theme.motion.springSnappy) {
                    isPressed = pressing
                }
            }
        }, perform: {})
        .animation(reduceMotion ? .none : theme.motion.springSnappy, value: isSelected)
        .pulseEntrance(scale: 0.94, offsetY: 6)
    }

    @ViewBuilder
    private var backgroundView: some View {
        switch style {
        case .default:
            if isSelected {
                theme.colors.primary
            } else {
                theme.colors.backgroundSecondary
            }
        case .tinted:
            if isSelected {
                theme.colors.accent.opacity(0.15)
            } else {
                theme.colors.backgroundSecondary
            }
        case .outlined:
            Color.clear
        case .glass:
            Color.clear.pulseGlassCapsule(.interactive)
        }
    }

    @ViewBuilder
    private var overlayView: some View {
        switch style {
        case .outlined:
            Capsule()
                .strokeBorder(
                    isSelected ? theme.colors.accent : theme.colors.border,
                    lineWidth: isSelected ? 1.5 : 1
                )
        case .default:
            if isSelected {
                Capsule()
                    .strokeBorder(Color.white.opacity(0.2), lineWidth: 0.5)
            }
        default:
            EmptyView()
        }
    }

    private var foregroundColor: Color {
        switch style {
        case .default:
            return isSelected ? theme.colors.primaryForeground : theme.colors.foreground
        case .tinted:
            return isSelected ? theme.colors.accent : theme.colors.foreground
        case .outlined:
            return isSelected ? theme.colors.accent : theme.colors.foreground
        case .glass:
            return theme.colors.foreground
        }
    }
}

// MARK: - Styles

public enum ChipStyle: Sendable {
    case `default`
    case tinted
    case outlined
    case glass
}