-@use "sass:map";
@use "config" as *;
@use "functions" as *;
@use "layout/breakpoints" as *;
(
--stepper-size: 2rem,
--stepper-gap: 1rem,
+ --stepper-font-size: var(--font-size-sm),
+ --stepper-text-gap: .5rem,
+ --stepper-track-size: .125rem,
--stepper-bg: var(--bg-2),
- --stepper-track-size: .25rem,
--stepper-active-color: var(--primary-contrast),
--stepper-active-bg: var(--primary-bg),
),
grid-auto-flow: column;
.stepper-item {
- grid-template-rows: repeat(2, var(--stepper-size));
+ grid-template-rows: var(--stepper-size) auto;
grid-template-columns: auto;
+ align-items: start;
justify-items: center;
+ text-align: center;
&::after {
- top: calc((var(--stepper-size) * .5) - (var(--stepper-track-size) * .5));
- right: 0;
- bottom: auto;
- left: calc(-50% - var(--stepper-gap));
- width: auto;
+ inset-block-start: calc((var(--stepper-size) * .5) - (var(--stepper-track-size) * .5));
+ inset-block-end: auto;
+ inset-inline-start: 50%;
+ inset-inline-end: 100%;
+ width: calc(100% + var(--stepper-gap));
height: var(--stepper-track-size);
}
&:last-child::after {
- right: 50%;
+ right: 100%;
}
}
}
// scss-docs-end stepper-horizontal-mixin
-// scss-docs-start stepper-css
-.stepper {
- // scss-docs-start stepper-css-vars
- @include tokens($stepper-tokens);
- // scss-docs-end stepper-css-vars
-
- display: grid;
- grid-auto-rows: 1fr;
- grid-auto-flow: row;
- gap: var(--stepper-gap);
- padding-left: 0;
- list-style: none;
- counter-reset: stepper;
-}
+@layer components {
+ .stepper {
+ @include tokens($stepper-tokens);
+
+ display: grid;
+ grid-auto-rows: 1fr;
+ grid-auto-flow: row;
+ gap: var(--stepper-gap);
+ padding-inline-start: 0;
+ list-style: none;
+ counter-reset: stepper;
+ }
-.stepper-item {
- position: relative;
- display: grid;
- grid-template-rows: auto;
- grid-template-columns: var(--stepper-size) auto;
- gap: .5rem;
- place-items: center;
- justify-items: start;
- text-align: center;
- text-decoration: none;
-
- // The counter
- &::before {
+ .stepper-item {
position: relative;
- z-index: 1;
- display: flex;
- flex-shrink: 0;
+ display: grid;
+ grid-template-rows: auto;
+ grid-template-columns: var(--stepper-size) auto;
+ gap: var(--stepper-text-gap);
align-items: center;
- justify-content: center;
- width: var(--stepper-size);
- height: var(--stepper-size);
- padding: .5rem;
- font-weight: 600;
- line-height: 1;
- text-align: center;
- content: counter(stepper);
- counter-increment: stepper;
- background-color: var(--stepper-bg);
- @include border-radius(50%);
+ text-decoration: none;
+
+ // The counter
+ &::before {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: var(--stepper-size);
+ height: var(--stepper-size);
+ padding: .5rem;
+ font-size: var(--stepper-font-size);
+ font-weight: 600;
+ line-height: 1;
+ text-align: center;
+ content: counter(stepper);
+ counter-increment: stepper;
+ background-color: var(--stepper-bg);
+ @include border-radius(50%);
+ }
+
+ // Connecting lines
+ &::after {
+ position: absolute;
+ inset-block-start: 50%;
+ inset-block-end: 100%;
+ inset-inline-start: calc((var(--stepper-size) * .5) - (var(--stepper-track-size) * .5));
+ width: var(--stepper-track-size);
+ height: calc(100% + var(--stepper-gap));
+ content: "";
+ background-color: var(--stepper-bg);
+ }
+
+ // Avoid sibling selector for easier CSS overrides
+ &:last-child::after {
+ display: none;
+ }
+
+ &.active {
+ &::before,
+ &::after {
+ color: var(--theme-contrast, var(--stepper-active-color));
+ background-color: var(--theme-bg, var(--stepper-active-bg));
+ }
+ }
}
- // Connecting lines
- &::after {
- position: absolute;
- top: calc(var(--stepper-gap) * -1);
- bottom: 100%;
- left: calc((var(--stepper-size) * .5) - (var(--stepper-track-size) * .5));
- width: var(--stepper-track-size);
- content: "";
+ // Targets the last .active element from a sequence of active elements
+ .stepper-item.active:not(:has(+ .stepper-item.active))::after {
background-color: var(--stepper-bg);
}
- // Avoid sibling selector for easier CSS overrides
- &:first-child::after {
- display: none;
+ .stepper-horizontal {
+ @include stepper-horizontal();
}
- &.active {
- &::before,
- &::after {
- color: var(--theme-contrast, var(--stepper-active-color));
- background-color: var(--theme-bg, var(--stepper-active-bg));
+ @include loop-breakpoints-down() using ($breakpoint, $next, $infix) {
+ @if $next {
+ .stepper-horizontal#{$infix} {
+ @include container-breakpoint-up($next) {
+ @include stepper-horizontal();
+ }
+ }
}
}
-}
-@each $breakpoint in map.keys($breakpoints) {
- @include media-breakpoint-up($breakpoint) {
- $infix: breakpoint-infix($breakpoint, $breakpoints);
+ // scss-docs-start stepper-overflow
+ .stepper-overflow {
+ container-type: inline-size;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
- .stepper-horizontal#{$infix} {
- @include stepper-horizontal();
+ > .stepper {
+ width: max-content;
+ min-width: 100%;
}
}
+ // scss-docs-end stepper-overflow
}
-
-// scss-docs-start stepper-overflow
-.stepper-overflow {
- container-type: inline-size;
- overflow-x: auto;
- overscroll-behavior-x: contain;
- -webkit-overflow-scrolling: touch;
-
- > .stepper {
- width: max-content;
- min-width: 100%;
- }
-}
-// scss-docs-end stepper-overflow
+++ /dev/null
----
-import { getData } from '@libs/data'
-import Example from '@components/shortcodes/Example.astro'
-
-const breakpoints = getData('breakpoints')
-const orientations = [
- { value: 'vertical', label: 'Vertical' },
- { value: 'horizontal', label: 'Horizontal' }
-]
-const stepCounts = [3, 4, 5, 6]
----
-
-<div class="bg-1 p-3 fs-sm rounded-3">
- <div class="d-flex flex-wrap gap-3 align-items-end">
- <div class="vstack gap-1">
- <label class="form-label fw-semibold mb-0">Orientation</label>
- <div class="btn-group" role="group" aria-label="Stepper orientation">
- {orientations.map((orientation) => (
- <label class="btn-check btn-outline theme-secondary">
- <input
- type="radio"
- name="stepper-orientation"
- value={orientation.value}
- checked={orientation.value === 'vertical'}
- data-orientation={orientation.value}
- />
- {orientation.label}
- </label>
- ))}
- </div>
- </div>
-
- <div class="vstack gap-1" id="breakpoint-control">
- <label class="form-label fw-semibold mb-0">Breakpoint</label>
- <div class="dropdown">
- <button
- type="button"
- class="btn-outline theme-secondary dropdown-toggle w-100 justify-content-between"
- id="stepper-breakpoint-dropdown"
- data-bs-toggle="dropdown"
- aria-expanded="false"
- data-breakpoint=""
- disabled
- style="min-width: 120px;"
- >
- <span>All sizes</span>
- <svg class="bi ms-1" width="16" height="16" aria-hidden="true">
- <use href="#chevron-expand" />
- </svg>
- </button>
- <ul class="dropdown-menu" aria-labelledby="stepper-breakpoint-dropdown">
- {breakpoints.map((bp) => (
- <li>
- <a
- class:list={['dropdown-item', { 'active': bp.abbr === '' }]}
- href="#"
- data-breakpoint={bp.abbr}
- >
- {bp.abbr === '' ? 'All sizes' : `${bp.name} (${bp.breakpoint})`}
- </a>
- </li>
- ))}
- </ul>
- </div>
- </div>
-
- <div class="vstack gap-1">
- <label class="form-label fw-semibold mb-0">Steps</label>
- <div class="dropdown">
- <button
- type="button"
- class="btn-outline theme-secondary dropdown-toggle w-100 justify-content-between"
- id="stepper-count-dropdown"
- data-bs-toggle="dropdown"
- aria-expanded="false"
- data-count="4"
- style="min-width: 80px;"
- >
- <span>4</span>
- <svg class="bi ms-1" width="16" height="16" aria-hidden="true">
- <use href="#chevron-expand" />
- </svg>
- </button>
- <ul class="dropdown-menu" aria-labelledby="stepper-count-dropdown">
- {stepCounts.map((count) => (
- <li>
- <a
- class:list={['dropdown-item', { 'active': count === 4 }]}
- href="#"
- data-count={count}
- >
- {count}
- </a>
- </li>
- ))}
- </ul>
- </div>
- </div>
-
- <div class="vstack gap-1">
- <label class="form-label fw-semibold mb-0">Active step</label>
- <div class="dropdown">
- <button
- type="button"
- class="btn-outline theme-secondary dropdown-toggle w-100 justify-content-between"
- id="stepper-active-dropdown"
- data-bs-toggle="dropdown"
- aria-expanded="false"
- data-active="2"
- style="min-width: 80px;"
- >
- <span>2</span>
- <svg class="bi ms-1" width="16" height="16" aria-hidden="true">
- <use href="#chevron-expand" />
- </svg>
- </button>
- <ul class="dropdown-menu" aria-labelledby="stepper-active-dropdown">
- {stepCounts.map((count) => (
- <li>
- <a
- class:list={['dropdown-item', { 'active': count === 2 }]}
- href="#"
- data-active={count}
- >
- {count}
- </a>
- </li>
- ))}
- </ul>
- </div>
- </div>
-
- <div class="vstack gap-1">
- <label class="form-label fw-semibold mb-0 user-select-none"> </label>
- <div class="checkgroup py-2">
- <div class="switch">
- <input type="checkbox" value="" id="stepper-fullwidth" role="switch" switch>
- </div>
- <label for="stepper-fullwidth">Full width</label>
- </div>
- </div>
- </div>
-</div>
-
-<Example
- code={`<ol class="stepper">
- <li class="stepper-item active">Create account</li>
- <li class="stepper-item active">Confirm email</li>
- <li class="stepper-item">Update profile</li>
- <li class="stepper-item">Finish</li>
-</ol>`}
- id="stepper-preview"
-/>
-
-<script>
- import { Dropdown } from '../../../../dist/js/bootstrap.bundle.js'
-
- const orientationInputs = document.querySelectorAll('input[name="stepper-orientation"]')
- const breakpointDropdownButton = document.querySelector('#stepper-breakpoint-dropdown') as HTMLButtonElement
- const breakpointDropdownItems = document.querySelectorAll('#stepper-breakpoint-dropdown + .dropdown-menu .dropdown-item')
- const countDropdownButton = document.querySelector('#stepper-count-dropdown') as HTMLButtonElement
- const countDropdownItems = document.querySelectorAll('#stepper-count-dropdown + .dropdown-menu .dropdown-item')
- const activeDropdownButton = document.querySelector('#stepper-active-dropdown') as HTMLButtonElement
- const activeDropdownItems = document.querySelectorAll('#stepper-active-dropdown + .dropdown-menu .dropdown-item')
- const fullwidthSwitch = document.querySelector('#stepper-fullwidth') as HTMLInputElement
- const breakpointControl = document.querySelector('#breakpoint-control') as HTMLElement
- const previewContainer = document.querySelector('#stepper-preview') as HTMLElement
- const codeSnippet = document.querySelector('#stepper-preview')?.closest('.bd-example-snippet')?.querySelector('.highlight code') as HTMLElement
-
- const stepLabels = ['Create account', 'Confirm email', 'Update profile', 'Finish', 'Complete', 'Done']
-
- function getOrientation(): string {
- return (document.querySelector('input[name="stepper-orientation"]:checked') as HTMLInputElement)?.value || 'vertical'
- }
-
- function getBreakpoint(): string {
- return breakpointDropdownButton?.dataset.breakpoint || ''
- }
-
- function getStepCount(): number {
- return parseInt(countDropdownButton?.dataset.count || '4', 10)
- }
-
- function getActiveStep(): number {
- return parseInt(activeDropdownButton?.dataset.active || '2', 10)
- }
-
- function isFullWidth(): boolean {
- return fullwidthSwitch?.checked || false
- }
-
- function buildStepperClass(): string {
- const classes = ['stepper']
- const orientation = getOrientation()
- const breakpoint = getBreakpoint()
- const fullWidth = isFullWidth()
-
- if (orientation === 'horizontal') {
- classes.push(`stepper-horizontal${breakpoint}`)
- }
-
- if (fullWidth && orientation === 'horizontal') {
- classes.push('w-100')
- }
-
- return classes.join(' ')
- }
-
- function generateHTML(): string {
- const stepCount = getStepCount()
- const activeStep = getActiveStep()
- const stepperClass = buildStepperClass()
-
- let html = `<ol class="${stepperClass}">\n`
-
- for (let i = 1; i <= stepCount; i++) {
- const isActive = i <= activeStep
- const activeClass = isActive ? ' active' : ''
- const label = stepLabels[i - 1] || `Step ${i}`
- html += ` <li class="stepper-item${activeClass}">${label}</li>\n`
- }
-
- html += '</ol>'
- return html
- }
-
- function updatePreview() {
- if (!previewContainer) return
-
- const stepperElement = previewContainer.querySelector('.stepper')
- if (!stepperElement) return
-
- const stepCount = getStepCount()
- const activeStep = getActiveStep()
- const stepperClass = buildStepperClass()
-
- // Update stepper classes
- stepperElement.className = stepperClass
-
- // Update step items
- let stepsHtml = ''
- for (let i = 1; i <= stepCount; i++) {
- const isActive = i <= activeStep
- const activeClass = isActive ? ' active' : ''
- const label = stepLabels[i - 1] || `Step ${i}`
- stepsHtml += `<li class="stepper-item${activeClass}">${label}</li>`
- }
- stepperElement.innerHTML = stepsHtml
- }
-
- function updateCodeSnippet() {
- if (!codeSnippet) return
-
- const htmlCode = generateHTML()
- codeSnippet.className = 'language-html'
- codeSnippet.textContent = htmlCode
- }
-
- function updateActiveDropdown() {
- const stepCount = getStepCount()
- const currentActive = getActiveStep()
-
- // Update dropdown items visibility
- activeDropdownItems.forEach((item) => {
- const value = parseInt((item as HTMLElement).dataset.active || '0', 10)
- const li = item.parentElement as HTMLElement
- if (value > stepCount) {
- li.classList.add('d-none')
- } else {
- li.classList.remove('d-none')
- }
- })
-
- // Reset active step if current is higher than step count
- if (currentActive > stepCount) {
- activeDropdownButton.dataset.active = String(stepCount)
- const labelSpan = activeDropdownButton.querySelector('span')
- if (labelSpan) labelSpan.textContent = String(stepCount)
- activeDropdownItems.forEach((item) => {
- const value = parseInt((item as HTMLElement).dataset.active || '0', 10)
- item.classList.toggle('active', value === stepCount)
- })
- }
- }
-
- function updateBreakpointState() {
- const orientation = getOrientation()
- const isHorizontal = orientation === 'horizontal'
-
- breakpointDropdownButton.disabled = !isHorizontal
- breakpointControl.classList.toggle('opacity-50', !isHorizontal)
-
- // Disable fullwidth when vertical
- fullwidthSwitch.disabled = !isHorizontal
- fullwidthSwitch.closest('.vstack')?.classList.toggle('opacity-50', !isHorizontal)
- }
-
- function update() {
- updateBreakpointState()
- updateActiveDropdown()
- updatePreview()
- updateCodeSnippet()
- }
-
- // Initialize dropdowns
- if (breakpointDropdownButton) {
- const breakpointDropdown = Dropdown.getOrCreateInstance(breakpointDropdownButton)
-
- breakpointDropdownItems.forEach((item) => {
- item.addEventListener('click', (e) => {
- e.preventDefault()
- const breakpoint = (item as HTMLElement).dataset.breakpoint || ''
- const label = item.textContent?.trim() || 'All sizes'
-
- const labelSpan = breakpointDropdownButton.querySelector('span')
- if (labelSpan) labelSpan.textContent = label
- breakpointDropdownButton.dataset.breakpoint = breakpoint
-
- breakpointDropdownItems.forEach((i) => i.classList.remove('active'))
- item.classList.add('active')
-
- breakpointDropdown.hide()
- update()
- })
- })
- }
-
- if (countDropdownButton) {
- const countDropdown = Dropdown.getOrCreateInstance(countDropdownButton)
-
- countDropdownItems.forEach((item) => {
- item.addEventListener('click', (e) => {
- e.preventDefault()
- const count = (item as HTMLElement).dataset.count || '4'
-
- const labelSpan = countDropdownButton.querySelector('span')
- if (labelSpan) labelSpan.textContent = count
- countDropdownButton.dataset.count = count
-
- countDropdownItems.forEach((i) => i.classList.remove('active'))
- item.classList.add('active')
-
- countDropdown.hide()
- update()
- })
- })
- }
-
- if (activeDropdownButton) {
- const activeDropdown = Dropdown.getOrCreateInstance(activeDropdownButton)
-
- activeDropdownItems.forEach((item) => {
- item.addEventListener('click', (e) => {
- e.preventDefault()
- const active = (item as HTMLElement).dataset.active || '2'
-
- const labelSpan = activeDropdownButton.querySelector('span')
- if (labelSpan) labelSpan.textContent = active
- activeDropdownButton.dataset.active = active
-
- activeDropdownItems.forEach((i) => i.classList.remove('active'))
- item.classList.add('active')
-
- activeDropdown.hide()
- update()
- })
- })
- }
-
- orientationInputs.forEach((input) => {
- input.addEventListener('change', update)
- })
-
- fullwidthSwitch?.addEventListener('change', update)
-
- // Initial update
- update()
-</script>
### Responsive
-Steppers sometimes need to be responsive, so you can use the responsive modifier classes to change them from vertical to horizontal at different breakpoints. Responsive modifier classes are available for `sm`, `md`, `lg`, `xl`, and `2xl` breakpoints.
+Steppers can be made horizontal at specific breakpoints using the `contains-inline` utility on a parent element and by adding the responsive `.stepper-horizontal-{breakpoint}` modifier classes on the stepper. The extra parent element is required when using container queries.
-<Example code={`<ol class="stepper stepper-horizontal-md">
- <li class="stepper-item active">Create account</li>
- <li class="stepper-item active">Confirm email</li>
- <li class="stepper-item">Update profile</li>
- <li class="stepper-item">Finish</li>
- </ol>`} />
+<ResizableExample code={`<div class="contains-inline">
+ <ol class="stepper stepper-horizontal-sm">
+ <li class="stepper-item active">Create account</li>
+ <li class="stepper-item active">Confirm email</li>
+ <li class="stepper-item">Update profile</li>
+ <li class="stepper-item">Finish</li>
+ </ol>
+ </div>`} />
### Gap
<li class="stepper-item">Finish</li>
</ol>`} />
-### Variants
+## Variants
<Example code={`<ol class="stepper stepper-horizontal">
<li class="stepper-item active theme-accent">Create account</li>
<li class="stepper-item active theme-danger">Finish</li>
</ol>`} />
-### Overflow
+## Overflow
Wrap your horizontal stepper in a `.stepper-overflow` container to enable horizontal scrolling when the stepper overflows its parent. Uses `container-type: inline-size` for container query support as opposed to a viewport-based media query.
<li class="stepper-item">Finish onboarding</li>
</ol>
</div>`} />
-
-## Playground
-
-Experiment with stepper options including orientation, breakpoints, step count, and more.
-
-<StepperPlayground />
-
-### Alignment
+## Alignment
Use [text alignment utilities]([[docsref:/utilities/text-alignment]]) (because we use `display: inline-grid`) to align the steps. The inline grid arrangement allows us to keep the steps equal width and ensures the connecting lines are rendered correctly.
<li class="stepper-item">Finish</li>
</ol>`} />
-### With anchors
+## With anchors
Use anchor elements to build your stepper if it links across multiple pages. Add `role="button"` or use `<button>` elements if you're linking across sections in the same document.
Consider using our [link utilities]([[docsref:/utilities/link]]) for quick color control.
<Example code={`<div class="stepper">
- <a href="#" role="button" class="stepper-item link-body-emphasis active">Create account</a>
- <a href="#" role="button" class="stepper-item link-body-emphasis active">Confirm email</a>
- <a href="#" role="button" class="stepper-item link-secondary">Update profile</a>
- <a href="#" role="button" class="stepper-item link-secondary">Finish</a>
+ <a href="#" role="button" class="stepper-item active">Create account</a>
+ <a href="#" role="button" class="stepper-item active">Confirm email</a>
+ <a href="#" role="button" class="stepper-item theme-secondary">Update profile</a>
+ <a href="#" role="button" class="stepper-item theme-secondary">Finish</a>
</div>`} />
## CSS