Stepper
Stepperは入力フォームや申請フローなど、ステップごとの進捗状況を視覚的に示しながら操作をガイドするためのコンポーネントです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Button from '$lib/components/ui/atoms/Button.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = $state<StepperSteps>([{ status: 'current' }, { status: 'upcoming' }, { status: 'upcoming' }]);
let currentIndex = $state(0);
function syncStatuses() {
for (let i = 0; i < steps.length; i++) {
steps[i].status = i < currentIndex ? 'finished' : i === currentIndex ? 'current' : 'upcoming';
}
}
$effect(() => {
syncStatuses();
});
function onClickNext() {
if (currentIndex < steps.length - 1) currentIndex++;
}
function onClickPrev() {
if (currentIndex > 0) currentIndex--;
}
const isFirst = $derived(currentIndex === 0);
const isLast = $derived(currentIndex === steps.length - 1);
</script>
<div class="flex flex-col w-full">
<div class="mb-12">
<Stepper {steps} />
</div>
<div class="flex w-full gap-2">
<Button class="w-full" size="medium" tone="solid" variant="secondary" onclick={onClickPrev} disabled={isFirst}>戻る</Button>
<Button class="w-full" size="medium" tone="solid" variant="primary" onclick={onClickNext} disabled={isLast}>次へ</Button>
</div>
</div>
プロパティ
Stepperは、以下のプロパティをサポートしています。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
steps |
StepperStatus |
表示するSteps | |
isEquallySpaced |
boolean |
true |
等間隔か。初期値は等間隔 |
direction |
StepperDirection |
horizontal |
表示向き。初期値はhorizontal(横表示) |
finishedIcon |
Component |
Check |
finishedステータス時に表示するアイコン。初期はCheck |
errorIcon |
Component |
X |
errorステータス時に表示するアイコン。初期はX |
インストールの手順
以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。
atoms/Stepper.svelte
<!--
@component
## 概要
- Stepperは入力フォームや申請フローなど、ステップごとの進捗状況を視覚的に示しながら操作をガイドするためのコンポーネントです。
## 機能
- ステップごとの状態(未着手・進行中・完了・エラー)を表示
- カスタムアイコンやレイアウトの切替が可能
## Props
- steps: ステップ情報(2つ以上の配列)を指定します
- isEquallySpaced: ステップの幅を等間隔にするかを指定します(デフォルト: true)
- direction: ステップの表示方向を指定します('horizontal' または 'vertical'、デフォルト: 'horizontal')
- finishedIcon: 完了状態のカスタムアイコンを指定できます(デフォルト: ✓)
- errorIcon: エラー状態のカスタムアイコンを指定できます(デフォルト: ✕)
## Usage
```svelte
<Stepper steps={steppers} isEquallySpaced={false} direction="vertical" />
```
-->
<script module lang="ts">
import type { Component, Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { cva, type VariantProps } from 'class-variance-authority';
export const stepperStepVariants = cva('flex flex-col items-center justify-center shrink-0 size-8 rounded-full', {
variants: {
status: {
upcoming: ['bg-base-container-muted text-base-foreground-muted'],
current: ['bg-primary text-base-foreground-on-fill-bright'],
finished: ['bg-base-container-default border border-primary text-primary'],
error: ['bg-base-container-default border border-destructive text-destructive'],
},
},
defaultVariants: {
status: 'upcoming',
},
});
export type StepperStepVariants = VariantProps<typeof stepperStepVariants>;
/** ステップの状態(未完了・現在・完了・エラー) */
export type StepperStatus = NonNullable<StepperStepVariants['status']>;
/** ステップの定義 */
export interface StepperStep {
/** ステップ全体に適用するクラス */
class?: ClassValue;
/** ステップの状態 */
status: StepperStatus;
/** ステップの開始部分(横向き:上、縦向き:左) */
startContent?: Snippet<[]>;
/** ステップの終了部分(横向き:下、縦向き:右) */
endContent?: Snippet<[]>;
}
/** ステッパーの方向(横 or 縦) */
export type StepperDirection = 'horizontal' | 'vertical';
/** 2ステップ以上の配列であることを保証 */
export type StepperSteps = [StepperStep, StepperStep, ...StepperStep[]];
/** ステッパーのコンポーネント Props */
export interface StepperProps {
/** 表示するステップ一覧(2つ以上) */
steps: StepperSteps;
/** ステップを等間隔に配置するか(デフォルト: true) */
isEquallySpaced?: boolean;
/** 表示方向(デフォルト: 'horizontal') */
direction?: StepperDirection;
/** 完了アイコンのカスタマイズ(デフォルト: '✓') */
finishedIcon?: Component;
/** エラー時アイコンのカスタマイズ(デフォルト: '✕') */
errorIcon?: Component;
}
</script>
<script lang="ts">
import { Check, X } from '@lucide/svelte';
let { steps, isEquallySpaced = true, direction = 'horizontal', finishedIcon = Check, errorIcon = X }: StepperProps = $props();
const isHorizontal = $derived(direction === 'horizontal');
const hasEnoughSteps = $derived(steps.length >= 2);
$effect(() => {
if (!hasEnoughSteps) {
console.warn('[Stepper] stepsは2つ以上必要です');
}
});
function resolveStatusIcon(status: StepperStatus) {
if (status === 'finished') return finishedIcon;
if (status === 'error') return errorIcon;
return null;
}
function connectorColor(status: StepperStatus) {
return status === 'upcoming' ? 'bg-base-container-muted' : 'bg-primary';
}
</script>
<div class={isHorizontal ? ['flex items-start', isEquallySpaced ? 'w-full' : 'overflow-x-auto'] : ['flex flex-col items-stretch ', isEquallySpaced ? 'h-full' : 'overflow-y-auto']}>
{#if isHorizontal}
{#each steps as step, i}
<div class={[step.class, 'flex flex-col items-stretch min-w-8', isEquallySpaced ? 'flex-1' : !step.class ? 'max-w-8' : '']}>
{#if step.startContent}
<div class="place-self-center w-fit mb-2">
{@render step.startContent()}
</div>
{/if}
<div class="grid grid-cols-[1fr_auto_1fr] items-center w-full">
<div class={['h-px w-full', i === 0 ? 'opacity-0' : connectorColor(step.status)]}></div>
{@render stepCircle({ index: i, status: step.status })}
<div class={['h-px w-full', i === steps.length - 1 ? 'opacity-0' : connectorColor(steps[i + 1].status)]}></div>
</div>
{#if step.endContent}
<div class="place-self-center w-fit mt-2">
{@render step.endContent()}
</div>
{/if}
</div>
{/each}
{:else}
{#each steps as step, i}
<div class={['grid grid-cols-[auto_auto_1fr] items-center min-h-8', isEquallySpaced ? 'flex-1' : !step.class ? 'max-h-8' : '', step.class]}>
{#if step.startContent}
<div class={['place-self-center mr-2 overflow-auto', !isEquallySpaced && !step.class ? 'max-h-8' : '']}>
{@render step.startContent()}
</div>
{/if}
<div class="flex flex-col items-center self-stretch">
<div class={['w-px', i === 0 ? 'opacity-0' : connectorColor(step.status), isEquallySpaced ? 'flex-1' : 'h-full']}></div>
{@render stepCircle({ index: i, status: step.status })}
<div class={['w-px', i === steps.length - 1 ? 'opacity-0' : connectorColor(steps[i + 1].status), isEquallySpaced ? 'flex-1' : 'h-full']}></div>
</div>
{#if step.endContent}
<div class={['place-self-center ml-2 overflow-auto', !isEquallySpaced && !step.class ? 'max-h-8' : '']}>
{@render step.endContent()}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{#snippet stepCircle({ index, status })}
<div class={stepperStepVariants({ status })}>
{#if resolveStatusIcon(status)}
{@const StatusIcon = resolveStatusIcon(status)}
<StatusIcon size="1rem" />
{:else}
<span class="text-xs">{index + 1}</span>
{/if}
</div>
{/snippet}
使い方
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Button from '$lib/components/ui/atoms/Button.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = $state<StepperSteps>([{ status: 'current' }, { status: 'upcoming' }, { status: 'upcoming' }]);
let currentIndex = $state(0);
function syncStatuses() {
for (let i = 0; i < steps.length; i++) {
steps[i].status = i < currentIndex ? 'finished' : i === currentIndex ? 'current' : 'upcoming';
}
}
$effect(() => {
syncStatuses();
});
function onClickNext() {
if (currentIndex < steps.length - 1) currentIndex++;
}
function onClickPrev() {
if (currentIndex > 0) currentIndex--;
}
const isFirst = $derived(currentIndex === 0);
const isLast = $derived(currentIndex === steps.length - 1);
</script>
<div class="flex flex-col w-full">
<div class="mb-12">
<Stepper {steps} />
</div>
<div class="flex w-full gap-2">
<Button class="w-full" size="medium" tone="solid" variant="secondary" onclick={onClickPrev} disabled={isFirst}>戻る</Button>
<Button class="w-full" size="medium" tone="solid" variant="primary" onclick={onClickNext} disabled={isLast}>次へ</Button>
</div>
</div>
サンプル
Default
3つのステップをデフォルト設定である横向き・等間隔で表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Button from '$lib/components/ui/atoms/Button.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = $state<StepperSteps>([{ status: 'current' }, { status: 'upcoming' }, { status: 'upcoming' }]);
let currentIndex = $state(0);
function syncStatuses() {
for (let i = 0; i < steps.length; i++) {
steps[i].status = i < currentIndex ? 'finished' : i === currentIndex ? 'current' : 'upcoming';
}
}
$effect(() => {
syncStatuses();
});
function onClickNext() {
if (currentIndex < steps.length - 1) currentIndex++;
}
function onClickPrev() {
if (currentIndex > 0) currentIndex--;
}
const isFirst = $derived(currentIndex === 0);
const isLast = $derived(currentIndex === steps.length - 1);
</script>
<div class="flex flex-col w-full">
<div class="mb-12">
<Stepper {steps} />
</div>
<div class="flex w-full gap-2">
<Button class="w-full" size="medium" tone="solid" variant="secondary" onclick={onClickPrev} disabled={isFirst}>戻る</Button>
<Button class="w-full" size="medium" tone="solid" variant="primary" onclick={onClickNext} disabled={isLast}>次へ</Button>
</div>
</div>
HorizontalStartContent
3つのステップをデフォルト設定である横向き・等間隔で表示し上にコンテンツを表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'current',
startContent: startContentCurrent,
},
{
status: 'upcoming',
startContent: startContentUpcoming,
},
{
status: 'upcoming',
startContent: startContentUpcoming,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} />
{#snippet startContentCurrent()}
<div class="text-center">
<div class="font-semibold leading-tight text-xs mb-0.5">ラベル</div>
<div class="text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet startContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
HorizontalEndContent
3つのステップをデフォルト設定である横向き・等間隔で表示し下にコンテンツを表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'current',
endContent: endContentCurrent,
},
{
status: 'upcoming',
endContent: endContentUpcoming,
},
{
status: 'upcoming',
endContent: endContentUpcoming,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} />
{#snippet endContentCurrent()}
<div class="text-center">
<div class="font-semibold leading-tight text-xs mb-0.5">ラベル</div>
<div class="text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet endContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
HorizontalBothContents
3つのステップをデフォルト設定である横向き・等間隔で表示し上下にコンテンツを表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'current',
startContent: bothContentCurrent,
endContent: bothContentCurrent,
},
{
status: 'upcoming',
startContent: bothContentUpcoming,
endContent: bothContentUpcoming,
},
{
status: 'upcoming',
startContent: bothContentUpcoming,
endContent: bothContentUpcoming,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} />
{#snippet bothContentCurrent()}
<div class="text-center">
<div class="font-semibold leading-tight text-xs mb-0.5">ラベル</div>
<div class="text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet bothContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
Vertical
3つのステップを縦向き・等間隔で表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'finished',
},
{
status: 'current',
},
{
status: 'upcoming',
},
] satisfies StepperSteps;
</script>
<Stepper {steps} direction="vertical" />
VerticalStartContent
3つのステップを縦向き・等間隔で表示し左にコンテンツを表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'current',
startContent: startContentCurrent,
},
{
status: 'upcoming',
startContent: startContentUpcoming,
},
{
status: 'upcoming',
startContent: startContentUpcoming,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} direction="vertical" />
{#snippet startContentCurrent()}
<div class="text-center">
<div class="font-semibold leading-tight text-xs mb-0.5">ラベル</div>
<div class="text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet startContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
VerticalEndContent
3つのステップを縦向き・等間隔で表示し右にコンテンツを表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'current',
endContent: endContentCurrent,
},
{
status: 'upcoming',
endContent: endContentUpcoming,
},
{
status: 'upcoming',
endContent: endContentUpcoming,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} direction="vertical" />
{#snippet endContentCurrent()}
<div class="text-center">
<div class="font-semibold leading-tight text-xs mb-0.5">ラベル</div>
<div class="text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet endContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
VerticalBothContents
3つのステップを縦向き・等間隔で表示し左右にコンテンツを表示したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'current',
startContent: bothContentCurrent,
endContent: bothContentCurrent,
},
{
status: 'upcoming',
startContent: bothContentUpcoming,
endContent: bothContentUpcoming,
},
{
status: 'upcoming',
startContent: bothContentUpcoming,
endContent: bothContentUpcoming,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} direction="vertical" />
{#snippet bothContentCurrent()}
<div class="text-center">
<div class="font-semibold leading-tight text-xs mb-0.5">ラベル</div>
<div class="text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet bothContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
HorizontalSize
3つのステップで、横向きでサイズを指定したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'finished',
endContent,
class: 'w-80',
},
{
status: 'current',
endContent,
class: 'w-80',
},
{
status: 'upcoming',
endContent: endContentUpcoming,
class: 'w-80',
},
] satisfies StepperSteps;
</script>
<Stepper {steps} isEquallySpaced={false} />
{#snippet endContent()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-default text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-default text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet endContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
VerticalSize
3つのステップで、縦向きでサイズを指定したパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'finished',
endContent,
class: 'h-80',
},
{
status: 'current',
endContent,
class: 'h-80',
},
{
status: 'upcoming',
endContent: endContentUpcoming,
class: 'h-80',
},
] satisfies StepperSteps;
</script>
<div class="h-[288px]">
<Stepper {steps} direction="vertical" isEquallySpaced={false} />
</div>
{#snippet endContent()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-default text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-default text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet endContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
Error
3つのステップのうち、エラー状態を含むパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'finished',
endContent: endContentFinished,
},
{
status: 'error',
endContent: endErrorContent,
},
{
status: 'upcoming',
endContent,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} />
{#snippet endContent()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet endContentFinished()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-default text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-default text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet endErrorContent()}
<div class="text-center text-destructive">
<div class="font-semibold leading-tight text-xs mb-0.5">ラベル</div>
<div class="text-xs">ここにエラーメッセージが入ります。</div>
</div>
{/snippet}
Upcoming
3つのステップで、2番目のステップをスキップしているパターンです。
<script lang="ts">
import type { StepperSteps } from '$lib/components/ui/atoms/Stepper.svelte';
import Stepper from '$lib/components/ui/atoms/Stepper.svelte';
let steps = [
{
status: 'finished',
endContent,
},
{
status: 'upcoming',
endContent: endContentUpcoming,
},
{
status: 'current',
endContent,
},
] satisfies StepperSteps;
</script>
<Stepper {steps} />
{#snippet endContent()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-default text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-default text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}
{#snippet endContentUpcoming()}
<div class="text-center">
<div class="font-semibold leading-tight text-base-foreground-muted text-xs mb-0.5">ラベル</div>
<div class="text-base-foreground-muted text-xs">ここに補足文が入ります。</div>
</div>
{/snippet}