suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Components82
  • Overview
  • Accordion
  • Alert
  • Alert Dialog
  • Alert Sheet
  • Amount Input
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Bottom Bar
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Chip
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • FAB
  • Field
  • Fullscreen Menu
  • Haptics
  • Header
  • Hover Card
  • Icon
  • Input
  • Input Accessory Bar
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Keyboard Scroll View
  • Keypad
  • Label
  • Marker
  • Menubar
  • Message
  • Message Scroller
  • Native Select
  • Navigation Menu
  • Page Indicator
  • Pagination
  • Pin Pad
  • Popover
  • Progress
  • QR Scanner
  • Questionnaire
  • Radio Group
  • Resizable
  • Screen
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature Pad
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Tab Bar
  • Table
  • Tabs
  • Text
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Wheel Picker
  • Widget

suiss/ui · Component

Questionnaire

A step-by-step form that shows one question at a time, with validation, skipping and keyboard shortcuts.

Questionnaire is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export default function QuestionnaireDemo() {
  return (
    <div className="w-[28rem]">
      <Questionnaire onSubmit={(event) => event.preventDefault()}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="role" required>
          <QuestionnaireTitle>
            What best describes your role?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            This helps us tailor the setup to your team.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="engineering">
              Engineering
            </QuestionnaireChoice>
            <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
            <QuestionnaireChoice value="product">Product</QuestionnaireChoice>
            <QuestionnaireChoice value="other">
              Something else
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="team-size">
          <QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
            <QuestionnaireChoice value="small">
              2 to 10 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="medium">
              11 to 50 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="large">
              More than 50 people
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="company" required>
          <QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
          <QuestionnaireInput placeholder="Acme Inc." />
          <QuestionnaireError>
            Enter a company name to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
    </div>
  )
}

Questionnaire renders a form that presents a sequence of questions, one at a time. Each QuestionnaireItem is a fieldset with a QuestionnaireTitle, an optional QuestionnaireDescription, an answer area and a QuestionnaireError. Answers are either QuestionnaireChoices holding QuestionnaireChoice cards (with optional QuestionnaireChoiceDescription), or a QuestionnaireInput.

QuestionnaireProgress shows the current position, and QuestionnaireActions lays out QuestionnairePrevious, QuestionnaireSkip, QuestionnaireNext and QuestionnaireSubmit, which show and hide themselves based on the current question. Use Questionnaire for onboarding flows and surveys; use Field and regular form controls when all fields should be visible at once.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/questionnaire

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add questionnaire

Manual

  1. Install the dependencies and add the cn helper:
$ pnpm add @base-ui/react @phosphor-icons/react
  1. Add the pieces it builds on: Button, use-questionnaire.

  2. Copy the source into components/ui/questionnaire.tsx:

  1. Update the import paths to match your project.

Usage

import { Questionnaire, QuestionnaireProgress, QuestionnaireItem, QuestionnaireTitle, QuestionnaireDescription, QuestionnaireChoices, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireInput, QuestionnaireError, QuestionnaireActions, QuestionnairePrevious, QuestionnaireSkip, QuestionnaireNext, QuestionnaireSubmit } 
<div












































Give every item a unique name; answers are submitted as form data under that name. The first item is shown by default. Use defaultItem to start elsewhere, or item with onItemChange to control the active question.

A required item blocks Next and Submit until it is answered. An optional item shows the Skip button. Set multiple on an item to render its choices as checkboxes instead of radio buttons.

Pass items to define the sequence, which items are required or disabled, and the order used for shortcut keys, instead of deriving them from the rendered items. Native browser validation is off by default (noValidate); the component runs its own validation on Next and Submit.

Examples

With shortcuts

Choices labelled with letter keys, so pressing a letter selects the matching answer.

Number shortcuts

Choices labelled with number keys from 1 to 9.

Multiple choice

A multiple item with checkboxes, a default selection, a disabled choice and a custom error.

With choice descriptions

Choice cards with a title and a secondary description.

Text input

A required email question answered with QuestionnaireInput.

Start on later question

A questionnaire that opens on its second question with defaultItem.

API Reference

Anatomy10 parts
<Questionnaire>
<QuestionnaireProgress />
<QuestionnaireItem>
<QuestionnaireTitle />
<QuestionnaireDescription />
<QuestionnaireChoices>
<QuestionnaireChoice
01

Questionnaire

The form root that tracks the active question and handles navigation and submission.

PropTypeDefaultDescription
defaultItemstring-The name of the item shown first when uncontrolled.
itemstring-The name of the active item, for controlled use.
itemsreadonly QuestionnaireItemDefinition[]-Optional item definitions with name, required, disabled and choices, used to define the sequence and shortcut order.
onItemChange(item: string) => void-Called with the next item's name when the active question changes.
shortcutsQuestionnaireShortcutMode-Enables single-key answer shortcuts using letters (A to Z) or numbers (1 to 9).

Also accepts the props of React.ComponentProps<"form">.

02

QuestionnaireProgress

Shows the position as text, such as Question 2 of 3, and exposes it to assistive technology with the progressbar role.

Also accepts the props of useRender.ComponentProps<"div", QuestionnaireState>.

03

QuestionnaireItem

A fieldset for one question, visible only while it is active.

PropTypeDefaultDescription
invalidbooleanfalseForces the item into the invalid state.
multiplebooleanfalseAllows several answers, rendering choices as checkboxes.
namestringRequiredThe unique item name, used for navigation and as the form field name.
onStatusChange(status: QuestionnaireItemStatus) => void-Called when the item's status changes between unanswered, answered and skipped.
requiredbooleanfalseRequires an answer before moving on and hides the Skip button.

Also accepts the props of React.ComponentProps<"fieldset">.

04

QuestionnaireTitle

The question text, rendered as the fieldset's legend.

Also accepts the props of useRender.ComponentProps<"legend">.

05

QuestionnaireDescription

Supporting text that describes the question.

Also accepts the props of useRender.ComponentProps<"p">.

06

QuestionnaireChoices

The grid that holds the answer choices.

Also accepts the props of useRender.ComponentProps< "div", { shortcuts: QuestionnaireShortcutMode | null } >.

07

QuestionnaireChoice

A selectable answer card containing a radio button or checkbox.

PropTypeDefaultDescription
checkedboolean-The checked state, for controlled use.
defaultCheckedbooleanfalseWhether the choice starts checked when uncontrolled.
disabledbooleanfalsePrevents the choice from being selected.
onChangeReact.ChangeEventHandler<HTMLInputElement>-Called when the underlying input changes.
valuestringRequiredThe value submitted for this choice.

Also accepts the props of useRender.ComponentProps<"label", QuestionnaireChoiceState>.

08

QuestionnaireChoiceDescription

Secondary text inside a choice that describes it.

Also accepts the props of React.ComponentProps<"span">.

09

QuestionnaireInput

A text-style input for free-form answers.

PropTypeDefaultDescription
typeQuestionnaireInputType"text"The input type, such as text, email, number, date or url.

Also accepts the props of useRender.ComponentProps<"input", QuestionnaireInputState>.

10

QuestionnaireError

The validation message shown when the question needs an answer.

Also accepts the props of useRender.ComponentProps<"p", { invalid: boolean }>.

11

QuestionnaireActions

Lays out the navigation buttons in one row.

Also accepts the props of React.ComponentProps<"div">.

12

QuestionnairePrevious

Goes back to the previous question; hidden on the first.

Also accepts the props of QuestionnaireNavigationProps.

13

QuestionnaireSkip

Skips the current optional question; hidden when the question is required.

Also accepts the props of QuestionnaireNavigationProps.

14

QuestionnaireNext

Validates the current question and moves to the next; hidden on the last.

Also accepts the props of QuestionnaireNavigationProps.

15

QuestionnaireSubmit

Submits the form; shown only on the last question.

Also accepts the props of QuestionnaireNavigationProps.

Accessibility

  • Each item is a fieldset named by its legend title; inactive items are hidden and inert.
  • Choices are native radio buttons or checkboxes inside a label. A choice description is linked with aria-describedby.
  • QuestionnaireInput is labelled by the question title unless you pass aria-label.
  • The item's description and, when invalid, its error are linked with aria-describedby. The error uses role="alert" and focus moves to the invalid answer.
  • QuestionnaireProgress has role="progressbar", an aria-label of Questionnaire progress, aria-valuetext and aria-live="polite".
  • Keyboard: Enter on an answered control advances or submits; Command or Control plus Enter advances from anywhere; ArrowUp and ArrowDown move between answers; outside text fields and radio buttons, ArrowLeft goes back and ArrowRight goes forward once the question has an answer. The active item lists these in aria-keyshortcuts.
  • Hidden navigation buttons are aria-hidden, inert and removed from the tab order.

Notes

  • Styling hooks: every part sets a data-slot, such as questionnaire, questionnaire-item, questionnaire-choice, questionnaire-choice-indicator, questionnaire-choice-shortcut, questionnaire-input and questionnaire-error.
  • Choices expose data-checked or data-unchecked, plus data-type, data-disabled, data-invalid and data-shortcut. Inputs expose data-filled or data-empty. Navigation buttons expose data-visible or data-hidden.
  • Shortcut badges are hidden below the md breakpoint.
  • The default error text is Choose an answer to continue. for required items; pass children to customize it.
  • Uses the primary, border, secondary, destructive and accent colors.

Questionnaire is a View that tracks which question is active, collects answers and validates them. Each QuestionnaireItem is one question; only the active item is shown, and moving between items slides the content horizontally with a Reanimated keyframe that follows the layout direction.

Items hold QuestionnaireChoice rows, which are Pressable radios or, with multiple, checkboxes, or a QuestionnaireInput for a typed answer. QuestionnaireActions holds the Previous, Skip, Next and Submit buttons, each of which only appears when it applies.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/questionnaire

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add questionnaire
$ pnpm dlx @esuiss/ui@latest add questionnaire --platform swift
NextRadio GroupPreviousQR Scanner

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • With shortcuts
    • Number shortcuts
    • Multiple choice
    • With choice descriptions
    • Text input
    • Start on later question
  4. 04API Reference
    • Questionnaire
    • QuestionnaireProgress
    • QuestionnaireItem
    • QuestionnaireTitle
    • QuestionnaireDescription
    • QuestionnaireChoices
    • QuestionnaireChoice
    • QuestionnaireChoiceDescription
    • QuestionnaireInput
    • QuestionnaireError
    • QuestionnaireActions
    • QuestionnairePrevious
    • QuestionnaireSkip
    • QuestionnaireNext
    • QuestionnaireSubmit
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Mid flow
    • Invalid
    • Multiple choice
    • With choice descriptions
    • Text input
  10. 10API Reference
    • Questionnaire
    • QuestionnaireProgress
    • QuestionnaireItem
    • QuestionnaireTitle
    • QuestionnaireDescription
    • QuestionnaireChoices
    • QuestionnaireChoice
    • QuestionnaireChoiceDescription
    • QuestionnaireInput
    • QuestionnaireError
    • QuestionnaireActions
    • QuestionnairePrevious
    • QuestionnaireSkip
    • QuestionnaireNext
    • QuestionnaireSubmit
  11. 11Accessibility
  12. 12Notes
from
"@/components/ui/questionnaire"
className
=
"w-[28rem]"
>
<Questionnaire onSubmit={(event) => event.preventDefault()}>
<QuestionnaireProgress />
<QuestionnaireItem name="role" required>
<QuestionnaireTitle>What best describes your role?</QuestionnaireTitle>
<QuestionnaireDescription>
This helps us tailor the setup to your team.
</QuestionnaireDescription>
<QuestionnaireChoices>
<QuestionnaireChoice value="engineering">
Engineering
</QuestionnaireChoice>
<QuestionnaireChoice value="design">Design</QuestionnaireChoice>
<QuestionnaireChoice value="product">Product</QuestionnaireChoice>
<QuestionnaireChoice value="other">
Something else
</QuestionnaireChoice>
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireItem name="team-size">
<QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
<QuestionnaireChoices>
<QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
<QuestionnaireChoice value="small">
2 to 10 people
</QuestionnaireChoice>
<QuestionnaireChoice value="medium">
11 to 50 people
</QuestionnaireChoice>
<QuestionnaireChoice value="large">
More than 50 people
</QuestionnaireChoice>
</QuestionnaireChoices>
<QuestionnaireError />
</QuestionnaireItem>
<QuestionnaireItem name="company" required>
<QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
<QuestionnaireInput placeholder="Acme Inc." />
<QuestionnaireError>
Enter a company name to continue.
</QuestionnaireError>
</QuestionnaireItem>
<Actions />
</Questionnaire>
</div>
>
<QuestionnaireChoiceDescription />
</QuestionnaireChoice>
</QuestionnaireChoices>
<QuestionnaireError />
<QuestionnaireInput />
</QuestionnaireItem>
</Questionnaire>
$ pnpm dlx @esuiss/ui@latest add questionnaire --platform kotlin

The CLI also adds what it builds on: Button, Icon, Input, Text.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Input, Text, and the primitives components/primitives/haptics.tsx, components/primitives/hooks.tsx, components/primitives/use-render.tsx.

  2. Copy the source into components/ui/questionnaire.tsx:

uniwind/components/ui/questionnaire.tsx
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import {
  type GestureResponderEvent,
  I18nManager,
  Pressable,
  View,
} from "react-native"
import Animated, { Keyframe, ReduceMotion } from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { useEffectEvent } from "@/components/primitives/hooks"
import { resolveClassName } from "@/components/primitives/use-render"
import { Button, type ButtonProps } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Input, Text, and the primitives components/primitives/haptics.tsx, components/primitives/hooks.tsx, components/primitives/use-render.tsx.

  2. Copy the source into components/ui/questionnaire.tsx:

  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Input, Text, and the primitives components/primitives/haptics.tsx, components/primitives/hooks.tsx, components/primitives/use-render.tsx.

  2. Copy the source into components/ui/questionnaire.tsx:

  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Components/Questionnaire.swift
import Observation
import PhosphorSwift
import SwiftUI

public enum QuestionnaireItemStatus: CaseIterable, Sendable {
  case unanswered, answered, skipped
}

public enum QuestionnaireDirection: Sendable {
  case forward, backward
}

public struct QuestionnaireItemDefinition: Hashable, Sendable {
  public var name: String
  public var required: Bool
  public var multiple: Bool
swift/Components/Button.swift
import SwiftUI

public struct VariantButtonStyle: ButtonStyle {
  public enum Variant: CaseIterable, Sendable {
    case `default`, outline, secondary, tinted, ghost, plain, destructive, link
  }

  public enum Size: CaseIterable, Sendable {
    case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl

    var height: CGFloat {
      switch self {
      case .xs, .iconXs: 24
      case .sm, .iconSm: 32
      case .default, .icon: 36
      case .lg, .iconLg: 44
swift/Components/ButtonGroup.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var buttonGroup: Axis? = nil
  @Entry var buttonGroupSize: CGSize = .zero
}

enum ButtonGroupSpace: Hashable {
  case group
}

struct ButtonGroupFrame {
  let bounds: Anchor<CGRect>
  let outlined: Bool
}
swift/Components/Field.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var isInvalid = false
  @Entry var fieldInset = false
  @Entry var fieldGroupInset = false
  @Entry var fieldSetInset = false
}

extension View {
  public func invalid(_ isInvalid: Bool = true) -> some View {
    environment(\.isInvalid, isInvalid)
  }
}

public enum FieldOrientation: CaseIterable, Sendable {
swift/Components/Haptics.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var hapticsEnabled = true
}

extension View {
  public func haptics(_ enabled: Bool) -> some View {
    environment(\.hapticsEnabled, enabled)
  }

  func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
    modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
  }

  func hapticFeedback<Trigger: Equatable>(
swift/Components/Separator.swift
import SwiftUI

public struct Separator: View {
  let orientation: Axis

  public init(_ orientation: Axis = .horizontal) {
    self.orientation = orientation
  }

  public var body: some View {
    Rectangle()
      .fill(EsuissColor.border)
      .frame(width: orientation == .vertical ? 1 : nil, height: orientation == .horizontal ? 1 : nil)
      .frame(maxWidth: orientation == .horizontal ? .infinity : nil, maxHeight: orientation == .vertical ? .infinity : nil)
      .accessibilityHidden(true)
  }
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/components/Questionnaire.kt
package com.esuiss.ui.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
kotlin/components/Button.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
kotlin/components/ButtonGroup.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
kotlin/components/Checkbox.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.triStateToggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/Field.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.LayoutScopeMarker
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Haptics.kt
package com.esuiss.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

val LocalHapticsEnabled = staticCompositionLocalOf { true }

@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}
kotlin/components/Icon.kt
package com.esuiss.ui.components

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Icon(
    imageVector: ImageVector,
kotlin/components/Input.kt
package com.esuiss.ui.components

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
kotlin/components/Label.kt
package com.esuiss.ui.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
kotlin/components/Separator.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Separator(
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { Questionnaire, QuestionnaireProgress, QuestionnaireItem, QuestionnaireTitle, QuestionnaireDescription, QuestionnaireChoices, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireInput, QuestionnaireError, QuestionnaireActions, QuestionnairePrevious, QuestionnaireSkip, QuestionnaireNext, QuestionnaireSubmit } from "@/components/ui/questionnaire"
<Onboarding />

Give every QuestionnaireItem a unique name; answers are collected under that name. Mark items required to block moving on without an answer; optional items show the Skip button. QuestionnaireError appears only when the item is invalid and has a default message you can replace.

The active item is uncontrolled through defaultItem or controlled with item and onItemChange. onSubmit receives the collected values: a string for single-answer items and a string array for multiple items.

QuestionnaireChoice supports defaultChecked, or controlled checked with onCheckedChange. QuestionnaireInput takes a type that sets the keyboard, autocomplete and secure entry. QuestionnaireProgress shows "Question 1 of 3" by default and accepts a render function. Use useQuestionnaireContext to build custom controls, or useQuestionnaire for the underlying state.

Examples

Mid flow

Starts on the second question with defaultItem.

iOS
Questionnaire (mid flow) on iOS in light modeQuestionnaire (mid flow) on iOS in dark mode
import * as React from "react"
import { View } from "react-native"
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle, type QuestionnaireValues } from "@/components/ui/questionnaire"
import { Text } from "@/components/ui/text"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

function Result({ values }: { values: QuestionnaireValues | null }) {
  if (!values) return null
  return (
    <Text role="status" className="text-sm text-muted-foreground">
      {`Submitted: ${JSON.stringify(values)}`}
    </Text>
  )
}

function Onboarding({
  defaultItem,
  attempted = false,
}: {
  defaultItem?: string
  attempted?: boolean
}) {
  const [values, setValues] = React.useState<QuestionnaireValues | null>(null)
  return (
    <View className="w-full gap-4">
      <Questionnaire defaultItem={defaultItem} onSubmit={setValues}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="role" required invalid={attempted}>
          <QuestionnaireTitle>
            What best describes your role?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            This helps us tailor the setup to your team.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="engineering">
              Engineering
            </QuestionnaireChoice>
            <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
            <QuestionnaireChoice value="product">Product</QuestionnaireChoice>
            <QuestionnaireChoice value="other">
              Something else
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="team-size">
          <QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
            <QuestionnaireChoice value="small" defaultChecked>
              2 to 10 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="medium">
              11 to 50 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="large">
              More than 50 people
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="company" required>
          <QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
          <QuestionnaireInput placeholder="Acme Inc." />
          <QuestionnaireError>
            Enter a company name to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
      <Result values={values} />
    </View>
  )
}

export function QuestionnaireMidFlowExample() {
  return (
    <Onboarding defaultItem="team-size" />
  )
}
import EsuissUI
import SwiftUI

struct QuestionnaireMidFlowExample: View {
  var body: some View {
    QuestionnaireOnboarding(defaultItem: "team-size")
  }
}

struct QuestionnaireStoryActions: View {
  var body: some View {
    QuestionnaireActions {
      QuestionnairePrevious()
      QuestionnaireSkip()
      QuestionnaireNext()
      QuestionnaireSubmit()
    }
  }
}

struct QuestionnaireOnboarding: View {
  var attempted = false

  @State private var controller: QuestionnaireController
  @State private var submitted: String?

  init(defaultItem: String? = nil, attempted: Bool = false) {
    self.attempted = attempted
    _controller = State(initialValue: QuestionnaireController(
      defaultItem: defaultItem,
      defaultValues: ["team-size": ["small"]]
    ))
  }

  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      Questionnaire(controller: controller) {
        QuestionnaireProgress()
        QuestionnaireItem(name: "role", required: true, invalid: attempted) {
          QuestionnaireTitle("What best describes your role?")
          QuestionnaireDescription("This helps us tailor the setup to your team.")
          QuestionnaireChoices {
            QuestionnaireChoice("Engineering", value: "engineering")
            QuestionnaireChoice("Design", value: "design")
            QuestionnaireChoice("Product", value: "product")
            QuestionnaireChoice("Something else", value: "other")
          }
          QuestionnaireError()
        }
        QuestionnaireItem(name: "team-size") {
          QuestionnaireTitle("How large is your team?")
          QuestionnaireChoices {
            QuestionnaireChoice("Just me", value: "solo")
            QuestionnaireChoice("2 to 10 people", value: "small")
            QuestionnaireChoice("11 to 50 people", value: "medium")
            QuestionnaireChoice("More than 50 people", value: "large")
          }
          QuestionnaireError()
        }
        QuestionnaireItem(name: "company", required: true) {
          QuestionnaireTitle("What is your company called?")
          QuestionnaireInput("Acme Inc.")
          QuestionnaireError("Enter a company name to continue.")
        }
        QuestionnaireStoryActions()
      }
      if let submitted {
        Text(verbatim: submitted)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .onAppear {
      let controller = controller
      controller.onSubmit = { answers in
        let fields = controller.sequence.compactMap { name -> String? in
          guard let value = answers[name]?.first else { return nil }
          return "\"\(name)\":\"\(value)\""
        }
        submitted = "Submitted: {\(fields.joined(separator: ","))}"
      }
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Questionnaire
import com.esuiss.ui.components.QuestionnaireActions
import com.esuiss.ui.components.QuestionnaireChoice
import com.esuiss.ui.components.QuestionnaireChoices
import com.esuiss.ui.components.QuestionnaireDescription
import com.esuiss.ui.components.QuestionnaireError
import com.esuiss.ui.components.QuestionnaireInput
import com.esuiss.ui.components.QuestionnaireItem
import com.esuiss.ui.components.QuestionnaireNext
import com.esuiss.ui.components.QuestionnairePrevious
import com.esuiss.ui.components.QuestionnaireProgress
import com.esuiss.ui.components.QuestionnaireSkip
import com.esuiss.ui.components.QuestionnaireSubmit
import com.esuiss.ui.components.QuestionnaireTitle
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberQuestionnaireState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun QuestionnaireMidFlowExample() {
    QuestionnaireOnboarding(defaultItem = "team-size")
}

fun questionnaireJson(values: Map<String, List<String>>): String =
    values.entries.joinToString(",", "{", "}") { (key, list) ->
        "\"$key\":" + list.joinToString(",", "[", "]") { "\"$it\"" }
    }

@Composable
fun QuestionnaireActionsRow() {
    QuestionnaireActions {
        QuestionnairePrevious()
        QuestionnaireSkip()
        QuestionnaireNext()
        QuestionnaireSubmit()
    }
}

@Composable
fun QuestionnaireResult(values: String?) {
    if (values == null) return
    Text(
        "Submitted: $values",
        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
        style = Typography.sm,
        color = EsuissTheme.colors.mutedForeground,
    )
}

@Composable
fun QuestionnaireOnboarding(defaultItem: String? = null, attempted: Boolean = false) {
    var values by rememberSaveable { mutableStateOf<String?>(null) }
    val state = rememberQuestionnaireState(defaultItem = defaultItem)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        Questionnaire(state = state, onSubmit = { values = questionnaireJson(it) }) {
            QuestionnaireProgress()
            QuestionnaireItem(name = "role", required = true, invalid = attempted) {
                QuestionnaireTitle("What best describes your role?")
                QuestionnaireDescription("This helps us tailor the setup to your team.")
                QuestionnaireChoices {
                    QuestionnaireChoice("Engineering", value = "engineering")
                    QuestionnaireChoice("Design", value = "design")
                    QuestionnaireChoice("Product", value = "product")
                    QuestionnaireChoice("Something else", value = "other")
                }
                QuestionnaireError()
            }
            QuestionnaireItem(name = "team-size") {
                QuestionnaireTitle("How large is your team?")
                QuestionnaireChoices {
                    QuestionnaireChoice("Just me", value = "solo")
                    QuestionnaireChoice("2 to 10 people", value = "small", defaultChecked = true)
                    QuestionnaireChoice("11 to 50 people", value = "medium")
                    QuestionnaireChoice("More than 50 people", value = "large")
                }
                QuestionnaireError()
            }
            QuestionnaireItem(name = "company", required = true) {
                QuestionnaireTitle("What is your company called?")
                QuestionnaireInput(placeholder = "Acme Inc.")
                QuestionnaireError("Enter a company name to continue.")
            }
            QuestionnaireActionsRow()
        }
        QuestionnaireResult(values)
    }
}

Invalid

A required question shown in its invalid state with an error message.

iOS
Questionnaire (invalid) on iOS in light modeQuestionnaire (invalid) on iOS in dark mode
import * as React from "react"
import { View } from "react-native"
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle, type QuestionnaireValues } from "@/components/ui/questionnaire"
import { Text } from "@/components/ui/text"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

function Result({ values }: { values: QuestionnaireValues | null }) {
  if (!values) return null
  return (
    <Text role="status" className="text-sm text-muted-foreground">
      {`Submitted: ${JSON.stringify(values)}`}
    </Text>
  )
}

function Onboarding({
  defaultItem,
  attempted = false,
}: {
  defaultItem?: string
  attempted?: boolean
}) {
  const [values, setValues] = React.useState<QuestionnaireValues | null>(null)
  return (
    <View className="w-full gap-4">
      <Questionnaire defaultItem={defaultItem} onSubmit={setValues}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="role" required invalid={attempted}>
          <QuestionnaireTitle>
            What best describes your role?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            This helps us tailor the setup to your team.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="engineering">
              Engineering
            </QuestionnaireChoice>
            <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
            <QuestionnaireChoice value="product">Product</QuestionnaireChoice>
            <QuestionnaireChoice value="other">
              Something else
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="team-size">
          <QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
            <QuestionnaireChoice value="small" defaultChecked>
              2 to 10 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="medium">
              11 to 50 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="large">
              More than 50 people
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="company" required>
          <QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
          <QuestionnaireInput placeholder="Acme Inc." />
          <QuestionnaireError>
            Enter a company name to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
      <Result values={values} />
    </View>
  )
}

export function QuestionnaireInvalidExample() {
  return (
    <Onboarding attempted />
  )
}
import EsuissUI
import SwiftUI

struct QuestionnaireInvalidExample: View {
  var body: some View {
    QuestionnaireOnboarding(attempted: true)
  }
}

struct QuestionnaireStoryActions: View {
  var body: some View {
    QuestionnaireActions {
      QuestionnairePrevious()
      QuestionnaireSkip()
      QuestionnaireNext()
      QuestionnaireSubmit()
    }
  }
}

struct QuestionnaireOnboarding: View {
  var attempted = false

  @State private var controller: QuestionnaireController
  @State private var submitted: String?

  init(defaultItem: String? = nil, attempted: Bool = false) {
    self.attempted = attempted
    _controller = State(initialValue: QuestionnaireController(
      defaultItem: defaultItem,
      defaultValues: ["team-size": ["small"]]
    ))
  }

  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      Questionnaire(controller: controller) {
        QuestionnaireProgress()
        QuestionnaireItem(name: "role", required: true, invalid: attempted) {
          QuestionnaireTitle("What best describes your role?")
          QuestionnaireDescription("This helps us tailor the setup to your team.")
          QuestionnaireChoices {
            QuestionnaireChoice("Engineering", value: "engineering")
            QuestionnaireChoice("Design", value: "design")
            QuestionnaireChoice("Product", value: "product")
            QuestionnaireChoice("Something else", value: "other")
          }
          QuestionnaireError()
        }
        QuestionnaireItem(name: "team-size") {
          QuestionnaireTitle("How large is your team?")
          QuestionnaireChoices {
            QuestionnaireChoice("Just me", value: "solo")
            QuestionnaireChoice("2 to 10 people", value: "small")
            QuestionnaireChoice("11 to 50 people", value: "medium")
            QuestionnaireChoice("More than 50 people", value: "large")
          }
          QuestionnaireError()
        }
        QuestionnaireItem(name: "company", required: true) {
          QuestionnaireTitle("What is your company called?")
          QuestionnaireInput("Acme Inc.")
          QuestionnaireError("Enter a company name to continue.")
        }
        QuestionnaireStoryActions()
      }
      if let submitted {
        Text(verbatim: submitted)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .onAppear {
      let controller = controller
      controller.onSubmit = { answers in
        let fields = controller.sequence.compactMap { name -> String? in
          guard let value = answers[name]?.first else { return nil }
          return "\"\(name)\":\"\(value)\""
        }
        submitted = "Submitted: {\(fields.joined(separator: ","))}"
      }
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Questionnaire
import com.esuiss.ui.components.QuestionnaireActions
import com.esuiss.ui.components.QuestionnaireChoice
import com.esuiss.ui.components.QuestionnaireChoices
import com.esuiss.ui.components.QuestionnaireDescription
import com.esuiss.ui.components.QuestionnaireError
import com.esuiss.ui.components.QuestionnaireInput
import com.esuiss.ui.components.QuestionnaireItem
import com.esuiss.ui.components.QuestionnaireNext
import com.esuiss.ui.components.QuestionnairePrevious
import com.esuiss.ui.components.QuestionnaireProgress
import com.esuiss.ui.components.QuestionnaireSkip
import com.esuiss.ui.components.QuestionnaireSubmit
import com.esuiss.ui.components.QuestionnaireTitle
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberQuestionnaireState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun QuestionnaireInvalidExample() {
    QuestionnaireOnboarding(attempted = true)
}

fun questionnaireJson(values: Map<String, List<String>>): String =
    values.entries.joinToString(",", "{", "}") { (key, list) ->
        "\"$key\":" + list.joinToString(",", "[", "]") { "\"$it\"" }
    }

@Composable
fun QuestionnaireActionsRow() {
    QuestionnaireActions {
        QuestionnairePrevious()
        QuestionnaireSkip()
        QuestionnaireNext()
        QuestionnaireSubmit()
    }
}

@Composable
fun QuestionnaireResult(values: String?) {
    if (values == null) return
    Text(
        "Submitted: $values",
        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
        style = Typography.sm,
        color = EsuissTheme.colors.mutedForeground,
    )
}

@Composable
fun QuestionnaireOnboarding(defaultItem: String? = null, attempted: Boolean = false) {
    var values by rememberSaveable { mutableStateOf<String?>(null) }
    val state = rememberQuestionnaireState(defaultItem = defaultItem)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        Questionnaire(state = state, onSubmit = { values = questionnaireJson(it) }) {
            QuestionnaireProgress()
            QuestionnaireItem(name = "role", required = true, invalid = attempted) {
                QuestionnaireTitle("What best describes your role?")
                QuestionnaireDescription("This helps us tailor the setup to your team.")
                QuestionnaireChoices {
                    QuestionnaireChoice("Engineering", value = "engineering")
                    QuestionnaireChoice("Design", value = "design")
                    QuestionnaireChoice("Product", value = "product")
                    QuestionnaireChoice("Something else", value = "other")
                }
                QuestionnaireError()
            }
            QuestionnaireItem(name = "team-size") {
                QuestionnaireTitle("How large is your team?")
                QuestionnaireChoices {
                    QuestionnaireChoice("Just me", value = "solo")
                    QuestionnaireChoice("2 to 10 people", value = "small", defaultChecked = true)
                    QuestionnaireChoice("11 to 50 people", value = "medium")
                    QuestionnaireChoice("More than 50 people", value = "large")
                }
                QuestionnaireError()
            }
            QuestionnaireItem(name = "company", required = true) {
                QuestionnaireTitle("What is your company called?")
                QuestionnaireInput(placeholder = "Acme Inc.")
                QuestionnaireError("Enter a company name to continue.")
            }
            QuestionnaireActionsRow()
        }
        QuestionnaireResult(values)
    }
}

Multiple choice

A multiple item that accepts several checked choices.

iOS
Questionnaire (multiple choice) on iOS in light modeQuestionnaire (multiple choice) on iOS in dark mode
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle } from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export function QuestionnaireMultipleChoiceExample() {
  return (
    <Questionnaire>
      <QuestionnaireProgress />
      <QuestionnaireItem name="features" multiple required>
        <QuestionnaireTitle>
          Which features do you plan to use?
        </QuestionnaireTitle>
        <QuestionnaireDescription>
          Select all that apply.
        </QuestionnaireDescription>
        <QuestionnaireChoices>
          <QuestionnaireChoice value="analytics" defaultChecked>
            Analytics
          </QuestionnaireChoice>
          <QuestionnaireChoice value="billing" defaultChecked>
            Billing
          </QuestionnaireChoice>
          <QuestionnaireChoice value="automations">
            Automations
          </QuestionnaireChoice>
          <QuestionnaireChoice value="integrations" disabled>
            Integrations
          </QuestionnaireChoice>
        </QuestionnaireChoices>
        <QuestionnaireError>Choose at least one feature.</QuestionnaireError>
      </QuestionnaireItem>
      <Actions />
    </Questionnaire>
  )
}
import EsuissUI
import SwiftUI

struct QuestionnaireStoryActions: View {
  var body: some View {
    QuestionnaireActions {
      QuestionnairePrevious()
      QuestionnaireSkip()
      QuestionnaireNext()
      QuestionnaireSubmit()
    }
  }
}

struct QuestionnaireMultipleChoice: View {
  @State private var controller = QuestionnaireController(defaultValues: ["features": ["analytics", "billing"]])

  var body: some View {
    Questionnaire(controller: controller) {
      QuestionnaireProgress()
      QuestionnaireItem(name: "features", required: true, multiple: true) {
        QuestionnaireTitle("Which features do you plan to use?")
        QuestionnaireDescription("Select all that apply.")
        QuestionnaireChoices {
          QuestionnaireChoice("Analytics", value: "analytics")
          QuestionnaireChoice("Billing", value: "billing")
          QuestionnaireChoice("Automations", value: "automations")
          QuestionnaireChoice("Integrations", value: "integrations", disabled: true)
        }
        QuestionnaireError("Choose at least one feature.")
      }
      QuestionnaireStoryActions()
    }
  }
}
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.Questionnaire
import com.esuiss.ui.components.QuestionnaireActions
import com.esuiss.ui.components.QuestionnaireChoice
import com.esuiss.ui.components.QuestionnaireChoices
import com.esuiss.ui.components.QuestionnaireDescription
import com.esuiss.ui.components.QuestionnaireError
import com.esuiss.ui.components.QuestionnaireItem
import com.esuiss.ui.components.QuestionnaireNext
import com.esuiss.ui.components.QuestionnairePrevious
import com.esuiss.ui.components.QuestionnaireProgress
import com.esuiss.ui.components.QuestionnaireSkip
import com.esuiss.ui.components.QuestionnaireSubmit
import com.esuiss.ui.components.QuestionnaireTitle

@Composable
fun QuestionnaireActionsRow() {
    QuestionnaireActions {
        QuestionnairePrevious()
        QuestionnaireSkip()
        QuestionnaireNext()
        QuestionnaireSubmit()
    }
}

@Composable
fun QuestionnaireMultipleChoice() {
    Questionnaire(Modifier.fillMaxWidth()) {
        QuestionnaireProgress()
        QuestionnaireItem(name = "features", multiple = true, required = true) {
            QuestionnaireTitle("Which features do you plan to use?")
            QuestionnaireDescription("Select all that apply.")
            QuestionnaireChoices {
                QuestionnaireChoice("Analytics", value = "analytics", defaultChecked = true)
                QuestionnaireChoice("Billing", value = "billing", defaultChecked = true)
                QuestionnaireChoice("Automations", value = "automations")
                QuestionnaireChoice("Integrations", value = "integrations", enabled = false)
            }
            QuestionnaireError("Choose at least one feature.")
        }
        QuestionnaireActionsRow()
    }
}

With choice descriptions

Choices with secondary text from QuestionnaireChoiceDescription.

iOS
Questionnaire (with choice descriptions) on iOS in light modeQuestionnaire (with choice descriptions) on iOS in dark mode
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoiceDescription, QuestionnaireChoices, QuestionnaireError, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle } from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export function QuestionnaireWithChoiceDescriptionsExample() {
  return (
    <Questionnaire>
      <QuestionnaireProgress />
      <QuestionnaireItem name="plan" required>
        <QuestionnaireTitle>Which plan fits you best?</QuestionnaireTitle>
        <QuestionnaireChoices>
          <QuestionnaireChoice value="starter">
            Starter
            <QuestionnaireChoiceDescription>
              For individuals trying things out.
            </QuestionnaireChoiceDescription>
          </QuestionnaireChoice>
          <QuestionnaireChoice value="team" defaultChecked>
            Team
            <QuestionnaireChoiceDescription>
              Shared workspaces and roles for growing teams.
            </QuestionnaireChoiceDescription>
          </QuestionnaireChoice>
          <QuestionnaireChoice value="enterprise">
            Enterprise
            <QuestionnaireChoiceDescription>
              Single sign-on, audit logs, and priority support.
            </QuestionnaireChoiceDescription>
          </QuestionnaireChoice>
        </QuestionnaireChoices>
        <QuestionnaireError />
      </QuestionnaireItem>
      <Actions />
    </Questionnaire>
  )
}
import EsuissUI
import SwiftUI

struct QuestionnaireStoryActions: View {
  var body: some View {
    QuestionnaireActions {
      QuestionnairePrevious()
      QuestionnaireSkip()
      QuestionnaireNext()
      QuestionnaireSubmit()
    }
  }
}

struct QuestionnaireChoiceDescriptions: View {
  @State private var controller = QuestionnaireController(defaultValues: ["plan": ["team"]])

  var body: some View {
    Questionnaire(controller: controller) {
      QuestionnaireProgress()
      QuestionnaireItem(name: "plan", required: true) {
        QuestionnaireTitle("Which plan fits you best?")
        QuestionnaireChoices {
          QuestionnaireChoice(value: "starter") {
            Text("Starter")
            QuestionnaireChoiceDescription("For individuals trying things out.")
          }
          QuestionnaireChoice(value: "team") {
            Text("Team")
            QuestionnaireChoiceDescription("Shared workspaces and roles for growing teams.")
          }
          QuestionnaireChoice(value: "enterprise") {
            Text("Enterprise")
            QuestionnaireChoiceDescription("Single sign-on, audit logs, and priority support.")
          }
        }
        QuestionnaireError()
      }
      QuestionnaireStoryActions()
    }
  }
}
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.Questionnaire
import com.esuiss.ui.components.QuestionnaireActions
import com.esuiss.ui.components.QuestionnaireChoice
import com.esuiss.ui.components.QuestionnaireChoiceDescription
import com.esuiss.ui.components.QuestionnaireChoices
import com.esuiss.ui.components.QuestionnaireError
import com.esuiss.ui.components.QuestionnaireItem
import com.esuiss.ui.components.QuestionnaireNext
import com.esuiss.ui.components.QuestionnairePrevious
import com.esuiss.ui.components.QuestionnaireProgress
import com.esuiss.ui.components.QuestionnaireSkip
import com.esuiss.ui.components.QuestionnaireSubmit
import com.esuiss.ui.components.QuestionnaireTitle
import com.esuiss.ui.components.Text

@Composable
fun QuestionnaireActionsRow() {
    QuestionnaireActions {
        QuestionnairePrevious()
        QuestionnaireSkip()
        QuestionnaireNext()
        QuestionnaireSubmit()
    }
}

@Composable
fun QuestionnaireWithChoiceDescriptions() {
    Questionnaire(Modifier.fillMaxWidth()) {
        QuestionnaireProgress()
        QuestionnaireItem(name = "plan", required = true) {
            QuestionnaireTitle("Which plan fits you best?")
            QuestionnaireChoices {
                QuestionnaireChoice(value = "starter") {
                    Text("Starter")
                    QuestionnaireChoiceDescription("For individuals trying things out.")
                }
                QuestionnaireChoice(value = "team", defaultChecked = true) {
                    Text("Team")
                    QuestionnaireChoiceDescription("Shared workspaces and roles for growing teams.")
                }
                QuestionnaireChoice(value = "enterprise") {
                    Text("Enterprise")
                    QuestionnaireChoiceDescription("Single sign-on, audit logs, and priority support.")
                }
            }
            QuestionnaireError()
        }
        QuestionnaireActionsRow()
    }
}

Text input

A required question answered with QuestionnaireInput.

iOS
Questionnaire (text input) on iOS in light modeQuestionnaire (text input) on iOS in dark mode
import { Questionnaire, QuestionnaireActions, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle } from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export function QuestionnaireTextInputExample() {
  return (
    <Questionnaire>
      <QuestionnaireProgress />
      <QuestionnaireItem name="email" required>
        <QuestionnaireTitle>
          Where should we send your results?
        </QuestionnaireTitle>
        <QuestionnaireDescription>
          We only use your email to share the report.
        </QuestionnaireDescription>
        <QuestionnaireInput type="email" placeholder="[email protected]" />
        <QuestionnaireError>
          Enter an email address to continue.
        </QuestionnaireError>
      </QuestionnaireItem>
      <Actions />
    </Questionnaire>
  )
}
import EsuissUI
import SwiftUI

struct QuestionnaireStoryActions: View {
  var body: some View {
    QuestionnaireActions {
      QuestionnairePrevious()
      QuestionnaireSkip()
      QuestionnaireNext()
      QuestionnaireSubmit()
    }
  }
}

struct QuestionnaireTextInput: View {
  @State private var controller = QuestionnaireController()

  var body: some View {
    Questionnaire(controller: controller) {
      QuestionnaireProgress()
      QuestionnaireItem(name: "email", required: true) {
        QuestionnaireTitle("Where should we send your results?")
        QuestionnaireDescription("We only use your email to share the report.")
        QuestionnaireInput("[email protected]", keyboard: .emailAddress, contentType: .emailAddress)
        QuestionnaireError("Enter an email address to continue.")
      }
      QuestionnaireStoryActions()
    }
  }
}
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import com.esuiss.ui.components.Questionnaire
import com.esuiss.ui.components.QuestionnaireActions
import com.esuiss.ui.components.QuestionnaireDescription
import com.esuiss.ui.components.QuestionnaireError
import com.esuiss.ui.components.QuestionnaireInput
import com.esuiss.ui.components.QuestionnaireItem
import com.esuiss.ui.components.QuestionnaireNext
import com.esuiss.ui.components.QuestionnairePrevious
import com.esuiss.ui.components.QuestionnaireProgress
import com.esuiss.ui.components.QuestionnaireSkip
import com.esuiss.ui.components.QuestionnaireSubmit
import com.esuiss.ui.components.QuestionnaireTitle

@Composable
fun QuestionnaireActionsRow() {
    QuestionnaireActions {
        QuestionnairePrevious()
        QuestionnaireSkip()
        QuestionnaireNext()
        QuestionnaireSubmit()
    }
}

@Composable
fun QuestionnaireTextInput() {
    Questionnaire(Modifier.fillMaxWidth()) {
        QuestionnaireProgress()
        QuestionnaireItem(name = "email", required = true) {
            QuestionnaireTitle("Where should we send your results?")
            QuestionnaireDescription("We only use your email to share the report.")
            QuestionnaireInput(
                placeholder = "[email protected]",
                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
            )
            QuestionnaireError("Enter an email address to continue.")
        }
        QuestionnaireActionsRow()
    }
}

API Reference

01

Questionnaire

Root that holds the state and the active question.

PropTypeDefaultDescription
itemsQuestionnaireItemDefinition[]-The questions in order, each with a name and optional required and disabled.
itemstring-The name of the current question, for controlled use.
defaultItemstring-The question to start on when uncontrolled.
onItemChange(item: string) => void-Called when the current question changes.
onSubmit(values: QuestionnaireValues) => void-Called with the answers when the last question is submitted.

Also accepts the props of React.ComponentProps<typeof View>, React.RefAttributes<View>, UseQuestionnaireParameters.

02

QuestionnaireProgress

Shows the current question number and total.

PropTypeDefaultDescription
children| React.ReactNode | ((state: QuestionnaireState) => React.ReactNode)-

Also accepts the props of React.ComponentProps<typeof View>, React.RefAttributes<View>.

03

QuestionnaireItem

One question; shown only while active.

PropTypeDefaultDescription
namestringRequiredUnique key for the answer.
requiredbooleanfalseRequires an answer before moving on and hides Skip.
disabledbooleanfalseRemoves the item from the sequence.
invalidbooleanfalseForces the invalid state.
multiplebooleanfalseAllows more than one choice.
onStatusChange(status: QuestionnaireItemStatus) => void-Called when the status changes to unanswered, answered or skipped.

Also accepts the props of React.ComponentProps<typeof View>, React.RefAttributes<View>.

04

QuestionnaireTitle

The question heading.

Also accepts the props of React.ComponentProps<typeof Text>, React.RefAttributes<typeof Text>.

05

QuestionnaireDescription

Secondary text under the title.

Also accepts the props of React.ComponentProps<typeof Text>, React.RefAttributes<typeof Text>.

06

QuestionnaireChoices

Stack of choice rows.

Also accepts the props of React.ComponentProps<typeof View>, React.RefAttributes<View>.

07

QuestionnaireChoice

A selectable answer row.

PropTypeDefaultDescription
valuestringRequiredValue stored when the choice is selected.
checkedboolean-Controlled checked state.
defaultCheckedbooleanfalseInitial checked state.
disabledbooleanfalseDisables the choice.
onCheckedChange(checked: boolean) => void-Called with the next checked state.
childrenReact.ReactNode-

Also accepts the props of React.ComponentProps<typeof Pressable>, React.RefAttributes<View>.

08

QuestionnaireChoiceDescription

Secondary text inside a choice.

Also accepts the props of React.ComponentProps<typeof Text>, React.RefAttributes<typeof Text>.

09

QuestionnaireInput

Text field for a typed answer.

PropTypeDefaultDescription
typeQuestionnaireInputType"text"text, email, number, tel, url, password or search; sets keyboard and autofill props.

Also accepts the props of React.ComponentProps<typeof Input>.

10

QuestionnaireError

Validation message shown when the item is invalid.

Also accepts the props of React.ComponentProps<typeof Text>, React.RefAttributes<typeof Text>.

11

QuestionnaireActions

Row for the navigation buttons.

Also accepts the props of React.ComponentProps<typeof View>, React.RefAttributes<View>.

12

QuestionnairePrevious

Goes to the previous question.

Also accepts the props of QuestionnaireNavigationProps.

13

QuestionnaireSkip

Skips the active question when it is optional.

Also accepts the props of QuestionnaireNavigationProps.

14

QuestionnaireNext

Validates and moves to the next question.

Also accepts the props of QuestionnaireNavigationProps.

15

QuestionnaireSubmit

Validates all questions and calls onSubmit on the last step.

Also accepts the props of QuestionnaireNavigationProps.

Accessibility

  • Each item has the radiogroup role, or group when multiple, and is labelled by its QuestionnaireTitle, which has the heading role.
  • Choices have the radio or checkbox role with checked and disabled states.
  • QuestionnaireInput is labelled by the title unless you pass an accessibilityLabel.
  • QuestionnaireProgress has the progressbar role, a value such as "Question 2 of 3" and a polite live region.
  • QuestionnaireError has the alert role with a polite live region.
  • Slide transitions respect the system reduce motion setting.

Notes

  • Selecting a choice plays a selection haptic when a haptics adapter is provided through HapticsContext.
  • Selected choices use border-primary and bg-primary; invalid items use border-destructive.
  • The slide direction flips for right-to-left layouts.
  • Light and dark colors follow the system appearance.
iOS
Questionnaire on iOS in light modeQuestionnaire on iOS in dark mode
import * as React from "react"
import { View } from "react-native"
import { Questionnaire, QuestionnaireActions, QuestionnaireChoice, QuestionnaireChoices, QuestionnaireDescription, QuestionnaireError, QuestionnaireInput, QuestionnaireItem, QuestionnaireNext, QuestionnairePrevious, QuestionnaireProgress, QuestionnaireSkip, QuestionnaireSubmit, QuestionnaireTitle, type QuestionnaireValues } from "@/components/ui/questionnaire"
import { Text } from "@/components/ui/text"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

function Result({ values }: { values: QuestionnaireValues | null }) {
  if (!values) return null
  return (
    <Text role="status" className="text-sm text-muted-foreground">
      {`Submitted: ${JSON.stringify(values)}`}
    </Text>
  )
}

function Onboarding({
  defaultItem,
  attempted = false,
}: {
  defaultItem?: string
  attempted?: boolean
}) {
  const [values, setValues] = React.useState<QuestionnaireValues | null>(null)
  return (
    <View className="w-full gap-4">
      <Questionnaire defaultItem={defaultItem} onSubmit={setValues}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="role" required invalid={attempted}>
          <QuestionnaireTitle>
            What best describes your role?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            This helps us tailor the setup to your team.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="engineering">
              Engineering
            </QuestionnaireChoice>
            <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
            <QuestionnaireChoice value="product">Product</QuestionnaireChoice>
            <QuestionnaireChoice value="other">
              Something else
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="team-size">
          <QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
            <QuestionnaireChoice value="small" defaultChecked>
              2 to 10 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="medium">
              11 to 50 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="large">
              More than 50 people
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="company" required>
          <QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
          <QuestionnaireInput placeholder="Acme Inc." />
          <QuestionnaireError>
            Enter a company name to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
      <Result values={values} />
    </View>
  )
}

export function QuestionnaireExample() {
  return (
    <Onboarding />
  )
}
import EsuissUI
import SwiftUI

struct QuestionnaireStoryActions: View {
  var body: some View {
    QuestionnaireActions {
      QuestionnairePrevious()
      QuestionnaireSkip()
      QuestionnaireNext()
      QuestionnaireSubmit()
    }
  }
}

struct QuestionnaireOnboarding: View {
  var attempted = false

  @State private var controller: QuestionnaireController
  @State private var submitted: String?

  init(defaultItem: String? = nil, attempted: Bool = false) {
    self.attempted = attempted
    _controller = State(initialValue: QuestionnaireController(
      defaultItem: defaultItem,
      defaultValues: ["team-size": ["small"]]
    ))
  }

  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      Questionnaire(controller: controller) {
        QuestionnaireProgress()
        QuestionnaireItem(name: "role", required: true, invalid: attempted) {
          QuestionnaireTitle("What best describes your role?")
          QuestionnaireDescription("This helps us tailor the setup to your team.")
          QuestionnaireChoices {
            QuestionnaireChoice("Engineering", value: "engineering")
            QuestionnaireChoice("Design", value: "design")
            QuestionnaireChoice("Product", value: "product")
            QuestionnaireChoice("Something else", value: "other")
          }
          QuestionnaireError()
        }
        QuestionnaireItem(name: "team-size") {
          QuestionnaireTitle("How large is your team?")
          QuestionnaireChoices {
            QuestionnaireChoice("Just me", value: "solo")
            QuestionnaireChoice("2 to 10 people", value: "small")
            QuestionnaireChoice("11 to 50 people", value: "medium")
            QuestionnaireChoice("More than 50 people", value: "large")
          }
          QuestionnaireError()
        }
        QuestionnaireItem(name: "company", required: true) {
          QuestionnaireTitle("What is your company called?")
          QuestionnaireInput("Acme Inc.")
          QuestionnaireError("Enter a company name to continue.")
        }
        QuestionnaireStoryActions()
      }
      if let submitted {
        Text(verbatim: submitted)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .onAppear {
      let controller = controller
      controller.onSubmit = { answers in
        let fields = controller.sequence.compactMap { name -> String? in
          guard let value = answers[name]?.first else { return nil }
          return "\"\(name)\":\"\(value)\""
        }
        submitted = "Submitted: {\(fields.joined(separator: ","))}"
      }
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Questionnaire
import com.esuiss.ui.components.QuestionnaireActions
import com.esuiss.ui.components.QuestionnaireChoice
import com.esuiss.ui.components.QuestionnaireChoices
import com.esuiss.ui.components.QuestionnaireDescription
import com.esuiss.ui.components.QuestionnaireError
import com.esuiss.ui.components.QuestionnaireInput
import com.esuiss.ui.components.QuestionnaireItem
import com.esuiss.ui.components.QuestionnaireNext
import com.esuiss.ui.components.QuestionnairePrevious
import com.esuiss.ui.components.QuestionnaireProgress
import com.esuiss.ui.components.QuestionnaireSkip
import com.esuiss.ui.components.QuestionnaireSubmit
import com.esuiss.ui.components.QuestionnaireTitle
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberQuestionnaireState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

fun questionnaireJson(values: Map<String, List<String>>): String =
    values.entries.joinToString(",", "{", "}") { (key, list) ->
        "\"$key\":" + list.joinToString(",", "[", "]") { "\"$it\"" }
    }

@Composable
fun QuestionnaireActionsRow() {
    QuestionnaireActions {
        QuestionnairePrevious()
        QuestionnaireSkip()
        QuestionnaireNext()
        QuestionnaireSubmit()
    }
}

@Composable
fun QuestionnaireResult(values: String?) {
    if (values == null) return
    Text(
        "Submitted: $values",
        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
        style = Typography.sm,
        color = EsuissTheme.colors.mutedForeground,
    )
}

@Composable
fun QuestionnaireOnboarding(defaultItem: String? = null, attempted: Boolean = false) {
    var values by rememberSaveable { mutableStateOf<String?>(null) }
    val state = rememberQuestionnaireState(defaultItem = defaultItem)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        Questionnaire(state = state, onSubmit = { values = questionnaireJson(it) }) {
            QuestionnaireProgress()
            QuestionnaireItem(name = "role", required = true, invalid = attempted) {
                QuestionnaireTitle("What best describes your role?")
                QuestionnaireDescription("This helps us tailor the setup to your team.")
                QuestionnaireChoices {
                    QuestionnaireChoice("Engineering", value = "engineering")
                    QuestionnaireChoice("Design", value = "design")
                    QuestionnaireChoice("Product", value = "product")
                    QuestionnaireChoice("Something else", value = "other")
                }
                QuestionnaireError()
            }
            QuestionnaireItem(name = "team-size") {
                QuestionnaireTitle("How large is your team?")
                QuestionnaireChoices {
                    QuestionnaireChoice("Just me", value = "solo")
                    QuestionnaireChoice("2 to 10 people", value = "small", defaultChecked = true)
                    QuestionnaireChoice("11 to 50 people", value = "medium")
                    QuestionnaireChoice("More than 50 people", value = "large")
                }
                QuestionnaireError()
            }
            QuestionnaireItem(name = "company", required = true) {
                QuestionnaireTitle("What is your company called?")
                QuestionnaireInput(placeholder = "Acme Inc.")
                QuestionnaireError("Enter a company name to continue.")
            }
            QuestionnaireActionsRow()
        }
        QuestionnaireResult(values)
    }
}
components/ui/questionnaire.tsx
"use client"

import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { CheckIcon } from "@phosphor-icons/react"
import * as React from "react"
import { type Button, buttonVariants } from "@/components/ui/button"
import {
  type QuestionnaireChoiceState,
  QuestionnaireContext,
  type QuestionnaireInputState,
  QuestionnaireItemContext,
  type QuestionnaireItemDefinition,
  type QuestionnaireItemStatus,
  type QuestionnaireShortcutMode,
  type QuestionnaireState,
import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export default function QuestionnaireWithShortcuts() {
  return (
    <div className="w-[28rem]">
      <Questionnaire
        onSubmit={(event) => event.preventDefault()}
        shortcuts="letters"
      >
        <QuestionnaireProgress />
        <QuestionnaireItem name="role" required>
          <QuestionnaireTitle>
            What best describes your role?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            This helps us tailor the setup to your team.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="engineering">
              Engineering
            </QuestionnaireChoice>
            <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
            <QuestionnaireChoice value="product">Product</QuestionnaireChoice>
            <QuestionnaireChoice value="other">
              Something else
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="team-size">
          <QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
            <QuestionnaireChoice value="small">
              2 to 10 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="medium">
              11 to 50 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="large">
              More than 50 people
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="company" required>
          <QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
          <QuestionnaireInput placeholder="Acme Inc." />
          <QuestionnaireError>
            Enter a company name to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
    </div>
  )
}
import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export default function QuestionnaireNumberShortcuts() {
  return (
    <div className="w-[28rem]">
      <Questionnaire
        onSubmit={(event) => event.preventDefault()}
        shortcuts="numbers"
      >
        <QuestionnaireProgress />
        <QuestionnaireItem name="role" required>
          <QuestionnaireTitle>
            What best describes your role?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            This helps us tailor the setup to your team.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="engineering">
              Engineering
            </QuestionnaireChoice>
            <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
            <QuestionnaireChoice value="product">Product</QuestionnaireChoice>
            <QuestionnaireChoice value="other">
              Something else
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="team-size">
          <QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
            <QuestionnaireChoice value="small">
              2 to 10 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="medium">
              11 to 50 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="large">
              More than 50 people
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="company" required>
          <QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
          <QuestionnaireInput placeholder="Acme Inc." />
          <QuestionnaireError>
            Enter a company name to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
    </div>
  )
}
import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export default function QuestionnaireMultipleChoice() {
  return (
    <div className="w-[28rem]">
      <Questionnaire onSubmit={(event) => event.preventDefault()}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="features" multiple required>
          <QuestionnaireTitle>
            Which features do you plan to use?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            Select all that apply.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="analytics" defaultChecked>
              Analytics
            </QuestionnaireChoice>
            <QuestionnaireChoice value="billing">Billing</QuestionnaireChoice>
            <QuestionnaireChoice value="automations">
              Automations
            </QuestionnaireChoice>
            <QuestionnaireChoice value="integrations" disabled>
              Integrations
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError>Choose at least one feature.</QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
    </div>
  )
}
import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoiceDescription,
  QuestionnaireChoices,
  QuestionnaireError,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export default function QuestionnaireWithChoiceDescriptions() {
  return (
    <div className="w-[28rem]">
      <Questionnaire onSubmit={(event) => event.preventDefault()}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="plan" required>
          <QuestionnaireTitle>Which plan fits you best?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="starter">
              Starter
              <QuestionnaireChoiceDescription>
                For individuals trying things out.
              </QuestionnaireChoiceDescription>
            </QuestionnaireChoice>
            <QuestionnaireChoice value="team" defaultChecked>
              Team
              <QuestionnaireChoiceDescription>
                Shared workspaces and roles for growing teams.
              </QuestionnaireChoiceDescription>
            </QuestionnaireChoice>
            <QuestionnaireChoice value="enterprise">
              Enterprise
              <QuestionnaireChoiceDescription>
                Single sign-on, audit logs, and priority support.
              </QuestionnaireChoiceDescription>
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
    </div>
  )
}
import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export default function QuestionnaireTextInput() {
  return (
    <div className="w-[28rem]">
      <Questionnaire onSubmit={(event) => event.preventDefault()}>
        <QuestionnaireProgress />
        <QuestionnaireItem name="email" required>
          <QuestionnaireTitle>
            Where should we send your results?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            We only use your email to share the report.
          </QuestionnaireDescription>
          <QuestionnaireInput type="email" placeholder="[email protected]" />
          <QuestionnaireError>
            Enter an email address to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
    </div>
  )
}
import {
  Questionnaire,
  QuestionnaireActions,
  QuestionnaireChoice,
  QuestionnaireChoices,
  QuestionnaireDescription,
  QuestionnaireError,
  QuestionnaireInput,
  QuestionnaireItem,
  QuestionnaireNext,
  QuestionnairePrevious,
  QuestionnaireProgress,
  QuestionnaireSkip,
  QuestionnaireSubmit,
  QuestionnaireTitle,
} from "@/components/ui/questionnaire"

function Actions() {
  return (
    <QuestionnaireActions>
      <QuestionnairePrevious />
      <QuestionnaireSkip />
      <QuestionnaireNext />
      <QuestionnaireSubmit />
    </QuestionnaireActions>
  )
}

export default function QuestionnaireStartOnLaterQuestion() {
  return (
    <div className="w-[28rem]">
      <Questionnaire
        onSubmit={(event) => event.preventDefault()}
        defaultItem="team-size"
      >
        <QuestionnaireProgress />
        <QuestionnaireItem name="role" required>
          <QuestionnaireTitle>
            What best describes your role?
          </QuestionnaireTitle>
          <QuestionnaireDescription>
            This helps us tailor the setup to your team.
          </QuestionnaireDescription>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="engineering">
              Engineering
            </QuestionnaireChoice>
            <QuestionnaireChoice value="design">Design</QuestionnaireChoice>
            <QuestionnaireChoice value="product">Product</QuestionnaireChoice>
            <QuestionnaireChoice value="other">
              Something else
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="team-size">
          <QuestionnaireTitle>How large is your team?</QuestionnaireTitle>
          <QuestionnaireChoices>
            <QuestionnaireChoice value="solo">Just me</QuestionnaireChoice>
            <QuestionnaireChoice value="small">
              2 to 10 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="medium">
              11 to 50 people
            </QuestionnaireChoice>
            <QuestionnaireChoice value="large">
              More than 50 people
            </QuestionnaireChoice>
          </QuestionnaireChoices>
          <QuestionnaireError />
        </QuestionnaireItem>
        <QuestionnaireItem name="company" required>
          <QuestionnaireTitle>What is your company called?</QuestionnaireTitle>
          <QuestionnaireInput placeholder="Acme Inc." />
          <QuestionnaireError>
            Enter a company name to continue.
          </QuestionnaireError>
        </QuestionnaireItem>
        <Actions />
      </Questionnaire>
    </div>
  )
}
nativewind/components/ui/questionnaire.tsx
import { cn } from "cn"
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import {
  type GestureResponderEvent,
  I18nManager,
  Pressable,
  View,
} from "react-native"
import Animated, { Keyframe, ReduceMotion } from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { useEffectEvent } from "@/components/primitives/hooks"
import { resolveClassName } from "@/components/primitives/use-render"
import { Button, type ButtonProps } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
stylesheet/components/ui/questionnaire.tsx
import { CheckIcon } from "phosphor-react-native"
import * as React from "react"
import {
  type GestureResponderEvent,
  I18nManager,
  Pressable,
  type PressableStateCallbackType,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  View,
  type ViewStyle,
} from "react-native"
import Animated, { Keyframe, ReduceMotion } from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { useEffectEvent } from "@/components/primitives/hooks"