Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Onboarding

An intro carousel screen composed from touchcn components.

A horizontally snapped intro carousel: three slides with an icon, headline and body, page dots that track the active slide, and a skip / next flow where the last slide’s button becomes “Get started”. It emits a single done event on completion.

Installation

npx touchcn add onboarding

This also adds the components the block composes:

button · page

Usage

<tcn-onboarding-block (done)="finishOnboarding()" />
import { TcnOnboardingBlock } from '@/components/blocks/onboarding';

<TcnOnboardingBlock onDone={finishOnboarding} />
<script setup lang="ts">
import { TcnOnboardingBlock } from '@/components/blocks/onboarding';
</script>

<template>
  <TcnOnboardingBlock @done="finishOnboarding" />
</template>

The active-slide tracking (a scroll listener over a snap container) is inlined in the block because it is screen-specific. If several screens need snapped slides, that is the seam to extract into a shared carousel primitive.

Source · Angular
export * from './tcn-onboarding-block';
import { Component, computed, ElementRef, output, signal, viewChild } from '@angular/core';
import { TcnButton } from '@/components/ui/button';
import { TcnPage } from '@/components/ui/page';

interface Slide {
  title: string;
  body: string;
}

/**
 * Onboarding carousel — horizontally snapped intro slides with page dots and a
 * skip / next flow that emits `done` on completion.
 *
 * The active-slide tracking (scroll listener + snap container) is inlined here
 * because it is screen-specific. If more screens need snapped slides, this is
 * the seam to extract into a reusable carousel primitive in the engine package.
 */
@Component({
  selector: 'tcn-onboarding-block',
  imports: [TcnPage, TcnButton],
  template: `
    <tcn-page>
      <div class="relative mx-auto flex min-h-full max-w-[430px] flex-col">
        @if (active() > 0) {
          <button
            tcnButton
            variant="ghost"
            type="button"
            aria-label="Back"
            class="absolute left-2 top-[calc(env(safe-area-inset-top,0px)+0.5rem)] z-10 !size-10 !p-0"
            (click)="back()"
          >
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
          </button>
        }
        <div
          #scroller
          (scroll)="onScroll()"
          class="flex flex-1 snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
        >
          @for (slide of slides; track $index) {
            <section class="flex w-full shrink-0 snap-center flex-col items-center justify-center gap-6 px-8 pt-20 text-center">
              <div class="flex size-24 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] text-[var(--color-primary)]">
                @switch ($index) {
                  @case (0) {
                    <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
                      <path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z" stroke-linejoin="round" />
                      <path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" stroke-linejoin="round" />
                    </svg>
                  }
                  @case (1) {
                    <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
                      <path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z" stroke-linejoin="round" />
                    </svg>
                  }
                  @default {
                    <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
                      <path d="M12 21s-7-4.35-9.5-8.5C1 9 3 5 6.5 5 8.7 5 10 6.5 12 9c2-2.5 3.3-4 5.5-4C21 5 23 9 21.5 12.5 19 16.65 12 21 12 21z" stroke-linejoin="round" />
                    </svg>
                  }
                }
              </div>
              <div>
                <h2 class="text-2xl font-semibold">{{ slide.title }}</h2>
                <p class="mt-2 text-[var(--color-on-surface-variant)]">{{ slide.body }}</p>
              </div>
            </section>
          }
        </div>

        <div class="flex flex-col gap-6 px-8 pt-4 pb-10">
          <div class="flex justify-center gap-2">
            @for (slide of slides; track $index) {
              <button
                type="button"
                class="flex cursor-pointer items-center justify-center py-2 -my-2"
                [attr.aria-label]="'Go to slide ' + ($index + 1)"
                [attr.aria-current]="$index === active() ? 'true' : null"
                (click)="goTo($index)"
              >
                <span
                  class="h-2 rounded-full transition-all"
                  [class]="
                    $index === active()
                      ? 'w-6 bg-[var(--color-primary)]'
                      : 'w-2 bg-[color-mix(in_srgb,var(--color-on-surface)_20%,transparent)]'
                  "
                ></span>
              </button>
            }
          </div>
          <div class="flex items-center justify-between">
            <button tcnButton variant="ghost" type="button" (click)="done.emit()">Skip</button>
            <button tcnButton type="button" (click)="next()">{{ isLast() ? 'Get started' : 'Next' }}</button>
          </div>
        </div>
      </div>
    </tcn-page>
  `,
})
export class TcnOnboardingBlock {
  /** Fires when the user finishes (last slide) or skips onboarding. */
  readonly done = output<void>();

  private readonly scroller = viewChild.required<ElementRef<HTMLDivElement>>('scroller');
  protected readonly active = signal(0);

  protected readonly slides: Slide[] = [
    { title: 'Welcome aboard', body: 'Everything you need in one place, tuned to feel native on every platform.' },
    { title: 'Move faster', body: 'Thoughtful defaults and shortcuts help you get things done in fewer taps.' },
    { title: 'Made for you', body: 'Personalize the experience and pick up right where you left off.' },
  ];

  protected readonly isLast = computed(() => this.active() === this.slides.length - 1);

  /** Keep the dots in sync when the user swipes the slides directly. */
  protected onScroll(): void {
    const el = this.scroller().nativeElement;
    this.active.set(Math.round(el.scrollLeft / el.clientWidth));
  }

  // Jump instantly rather than smooth-scrolling: a programmatic smooth scroll on
  // a `snap-mandatory` container is snapped back by the browser. Manual swiping
  // still animates natively. State stays the source of truth — the scroll
  // listener keeps it in sync when the user swipes directly.
  protected goTo(index: number): void {
    this.active.set(index);
    const el = this.scroller().nativeElement;
    el.scrollLeft = index * el.clientWidth;
  }

  protected next(): void {
    if (this.isLast()) {
      this.done.emit();
      return;
    }
    this.goTo(this.active() + 1);
  }

  protected back(): void {
    if (this.active() > 0) {
      this.goTo(this.active() - 1);
    }
  }
}
Source · React
export * from './tcn-onboarding-block';
import { useRef, useState } from 'react';
import type { ReactNode, UIEvent } from 'react';
import { TcnButton } from '@/components/ui/button';
import { TcnPage } from '@/components/ui/page';

interface Slide {
  icon: ReactNode;
  title: string;
  body: string;
}

export interface TcnOnboardingBlockProps {
  /** Fires when the user finishes (last slide) or skips onboarding. */
  onDone?(): void;
}

const iconWrap = 'flex size-24 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] text-[var(--color-primary)]';

const slides: Slide[] = [
  {
    icon: (
      <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
        <path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z" strokeLinejoin="round" />
        <path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" strokeLinejoin="round" />
      </svg>
    ),
    title: 'Welcome aboard',
    body: 'Everything you need in one place, tuned to feel native on every platform.',
  },
  {
    icon: (
      <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
        <path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z" strokeLinejoin="round" />
      </svg>
    ),
    title: 'Move faster',
    body: 'Thoughtful defaults and shortcuts help you get things done in fewer taps.',
  },
  {
    icon: (
      <svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
        <path d="M12 21s-7-4.35-9.5-8.5C1 9 3 5 6.5 5 8.7 5 10 6.5 12 9c2-2.5 3.3-4 5.5-4C21 5 23 9 21.5 12.5 19 16.65 12 21 12 21z" strokeLinejoin="round" />
      </svg>
    ),
    title: 'Made for you',
    body: 'Personalize the experience and pick up right where you left off.',
  },
];

/**
 * Onboarding carousel — horizontally snapped intro slides with page dots and a
 * skip / next flow that emits `onDone` on completion.
 *
 * The active-slide tracking (scroll listener + snap container) is inlined here
 * because it is screen-specific. If more screens need snapped slides, this is
 * the seam to extract into a reusable carousel primitive in the engine package.
 */
export function TcnOnboardingBlock({ onDone }: TcnOnboardingBlockProps) {
  const scrollerRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(0);
  const isLast = active === slides.length - 1;

  // Keep the dots in sync when the user swipes the slides directly.
  const handleScroll = (event: UIEvent<HTMLDivElement>) => {
    const el = event.currentTarget;
    setActive(Math.round(el.scrollLeft / el.clientWidth));
  };

  // Jump instantly rather than smooth-scrolling: a programmatic smooth scroll on
  // a `snap-mandatory` container is snapped back by the browser. Manual swiping
  // still animates natively.
  const goTo = (index: number) => {
    setActive(index);
    const el = scrollerRef.current;
    if (el) {
      el.scrollLeft = index * el.clientWidth;
    }
  };

  const next = () => {
    if (isLast) {
      onDone?.();
      return;
    }
    goTo(active + 1);
  };

  const back = () => {
    if (active > 0) {
      goTo(active - 1);
    }
  };

  return (
    <TcnPage>
      <div className="relative mx-auto flex min-h-full max-w-[430px] flex-col">
        {active > 0 && (
          <TcnButton
            variant="ghost"
            type="button"
            aria-label="Back"
            className="absolute left-2 top-[calc(env(safe-area-inset-top,0px)+0.5rem)] z-10 !size-10 !p-0"
            onClick={back}
          >
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </TcnButton>
        )}
        <div
          ref={scrollerRef}
          onScroll={handleScroll}
          className="flex flex-1 snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
        >
          {slides.map((slide, index) => (
            <section
              key={index}
              className="flex w-full shrink-0 snap-center flex-col items-center justify-center gap-6 px-8 pt-20 text-center"
            >
              <div className={iconWrap}>{slide.icon}</div>
              <div>
                <h2 className="text-2xl font-semibold">{slide.title}</h2>
                <p className="mt-2 text-[var(--color-on-surface-variant)]">{slide.body}</p>
              </div>
            </section>
          ))}
        </div>

        <div className="flex flex-col gap-6 px-8 pt-4 pb-10">
          <div className="flex justify-center gap-2">
            {slides.map((_, index) => (
              <button
                key={index}
                type="button"
                className="flex cursor-pointer items-center justify-center py-2 -my-2"
                aria-label={`Go to slide ${index + 1}`}
                aria-current={index === active ? 'true' : undefined}
                onClick={() => goTo(index)}
              >
                <span
                  className={`h-2 rounded-full transition-all ${
                    index === active
                      ? 'w-6 bg-[var(--color-primary)]'
                      : 'w-2 bg-[color-mix(in_srgb,var(--color-on-surface)_20%,transparent)]'
                  }`}
                />
              </button>
            ))}
          </div>
          <div className="flex items-center justify-between">
            <TcnButton variant="ghost" onClick={() => onDone?.()}>
              Skip
            </TcnButton>
            <TcnButton onClick={next}>{isLast ? 'Get started' : 'Next'}</TcnButton>
          </div>
        </div>
      </div>
    </TcnPage>
  );
}
Source · Vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { TcnButton } from '@/components/ui/button';
import { TcnPage } from '@/components/ui/page';

/**
 * Onboarding carousel — horizontally snapped intro slides with page dots and a
 * skip / next flow that emits `done` on completion.
 *
 * The active-slide tracking (scroll listener + snap container) is inlined here
 * because it is screen-specific. If more screens need snapped slides, this is
 * the seam to extract into a reusable carousel primitive in the engine package.
 */
const emit = defineEmits<{
  /** Fires when the user finishes (last slide) or skips onboarding. */
  done: [];
}>();

interface Slide {
  title: string;
  body: string;
}

const iconWrap =
  'flex size-24 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] text-[var(--color-primary)]';

const slides: Slide[] = [
  {
    title: 'Welcome aboard',
    body: 'Everything you need in one place, tuned to feel native on every platform.',
  },
  {
    title: 'Move faster',
    body: 'Thoughtful defaults and shortcuts help you get things done in fewer taps.',
  },
  {
    title: 'Made for you',
    body: 'Personalize the experience and pick up right where you left off.',
  },
];

const scrollerEl = ref<HTMLElement | null>(null);
const active = ref(0);
const isLast = computed(() => active.value === slides.length - 1);

// Keep the dots in sync when the user swipes the slides directly.
const handleScroll = (event: Event): void => {
  const el = event.currentTarget as HTMLElement;
  active.value = Math.round(el.scrollLeft / el.clientWidth);
};

// Jump instantly rather than smooth-scrolling: a programmatic smooth scroll on a
// `snap-mandatory` container is snapped back by the browser. Manual swiping
// still animates natively.
const goTo = (index: number): void => {
  active.value = index;
  const el = scrollerEl.value;
  if (el) {
    el.scrollLeft = index * el.clientWidth;
  }
};

const next = (): void => {
  if (isLast.value) {
    emit('done');
    return;
  }
  goTo(active.value + 1);
};

const back = (): void => {
  if (active.value > 0) {
    goTo(active.value - 1);
  }
};
</script>

<template>
  <TcnPage>
    <div class="relative mx-auto flex min-h-full max-w-[430px] flex-col">
      <TcnButton
        v-if="active > 0"
        variant="ghost"
        type="button"
        aria-label="Back"
        class="absolute left-2 top-[calc(env(safe-area-inset-top,0px)+0.5rem)] z-10 !size-10 !p-0"
        @click="back"
      >
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
        </svg>
      </TcnButton>

      <div
        ref="scrollerEl"
        class="flex flex-1 snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
        @scroll="handleScroll"
      >
        <section
          v-for="(slide, index) in slides"
          :key="index"
          class="flex w-full shrink-0 snap-center flex-col items-center justify-center gap-6 px-8 pt-20 text-center"
        >
          <div :class="iconWrap">
            <svg v-if="index === 0" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
              <path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z" stroke-linejoin="round" />
              <path d="M12 12l8-4.5M12 12v9M12 12L4 7.5" stroke-linejoin="round" />
            </svg>
            <svg v-else-if="index === 1" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
              <path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z" stroke-linejoin="round" />
            </svg>
            <svg v-else width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
              <path d="M12 21s-7-4.35-9.5-8.5C1 9 3 5 6.5 5 8.7 5 10 6.5 12 9c2-2.5 3.3-4 5.5-4C21 5 23 9 21.5 12.5 19 16.65 12 21 12 21z" stroke-linejoin="round" />
            </svg>
          </div>
          <div>
            <h2 class="text-2xl font-semibold">{{ slide.title }}</h2>
            <p class="mt-2 text-[var(--color-on-surface-variant)]">{{ slide.body }}</p>
          </div>
        </section>
      </div>

      <div class="flex flex-col gap-6 px-8 pt-4 pb-10">
        <div class="flex justify-center gap-2">
          <button
            v-for="(_, index) in slides"
            :key="index"
            type="button"
            class="flex cursor-pointer items-center justify-center py-2 -my-2"
            :aria-label="`Go to slide ${index + 1}`"
            :aria-current="index === active ? 'true' : undefined"
            @click="goTo(index)"
          >
            <span
              class="h-2 rounded-full transition-all"
              :class="index === active ? 'w-6 bg-[var(--color-primary)]' : 'w-2 bg-[color-mix(in_srgb,var(--color-on-surface)_20%,transparent)]'"
            />
          </button>
        </div>
        <div class="flex items-center justify-between">
          <TcnButton variant="ghost" @click="emit('done')">Skip</TcnButton>
          <TcnButton @click="next">{{ isLast ? 'Get started' : 'Next' }}</TcnButton>
        </div>
      </div>
    </div>
  </TcnPage>
</template>
export { default as TcnOnboardingBlock } from './TcnOnboardingBlock.vue';

Last updated on July 24, 2026

Was this page helpful?