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';