Compare commits

...

3 Commits

Author SHA1 Message Date
Mark Otto b962c3d8f9 Improvements to stepper, fix other playgrounds while here 2026-01-06 20:09:33 -08:00
Mark Otto c609b16099 more 2026-01-06 15:54:34 -08:00
Mark Otto c78afa2bf9 New Stepper component 2026-01-06 15:54:34 -08:00
8 changed files with 679 additions and 8 deletions
+143
View File
@@ -0,0 +1,143 @@
@use "sass:map";
@use "config" as *;
@use "variables" as *;
@use "layout/breakpoints" as *;
@use "mixins/border-radius" as *;
@use "mixins/box-shadow" as *;
@use "mixins/gradients" as *;
@use "mixins/transition" as *;
// scss-docs-start stepper-variables
$stepper-size: 2rem !default;
$stepper-gap: 1rem !default;
$stepper-track-size: .25rem !default;
$stepper-bg: var(--bg-2) !default;
$stepper-active-fg: var(--primary-contrast) !default;
$stepper-active-bg: var(--primary-bg) !default;
// $stepper-vertical-gap: .5rem !default;
// scss-docs-end stepper-variables
// scss-docs-start stepper-horizontal-mixin
@mixin stepper-horizontal() {
display: inline-grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
.stepper-item {
grid-template-rows: repeat(2, var(--stepper-size));
grid-template-columns: auto;
justify-items: 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;
height: var(--stepper-track-size);
}
&:last-child::after {
right: 50%;
}
}
}
// scss-docs-end stepper-horizontal-mixin
// scss-docs-start stepper-css
.stepper {
// scss-docs-start stepper-css-vars
--stepper-size: #{$stepper-size};
--stepper-gap: #{$stepper-gap};
--stepper-bg: #{$stepper-bg};
--stepper-track-size: #{$stepper-track-size};
--stepper-active-color: #{$stepper-active-fg};
--stepper-active-bg: #{$stepper-active-bg};
// 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;
}
.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 {
position: relative;
z-index: 1;
display: inline-block;
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%);
}
// 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: "";
background-color: var(--stepper-bg);
}
// Avoid sibling selector for easier CSS overrides
&:first-child::after {
display: none;
}
&.active {
&::before,
&::after {
color: var(--theme-contrast, var(--stepper-active-color));
background-color: var(--theme-bg, var(--stepper-active-bg));
}
}
}
@each $breakpoint in map.keys($grid-breakpoints) {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
.stepper-horizontal#{$infix} {
@include stepper-horizontal();
}
}
}
// 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
+1
View File
@@ -29,6 +29,7 @@
@forward "popover";
@forward "progress";
@forward "spinner";
@forward "stepper";
@forward "toasts";
@forward "tooltip";
@forward "transitions";
+1
View File
@@ -106,6 +106,7 @@
- title: Progress
- title: Scrollspy
- title: Spinner
- title: Stepper
- title: Toasts
- title: Toggler
- title: Tooltip
@@ -25,7 +25,7 @@ const rounded = ['default', 'pill', 'square']
</symbol>
</svg>
<div class="bg-1 p-3 rounded-3">
<div class="bg-1 p-3 fs-sm rounded-3">
<div class="d-flex flex-wrap gap-3">
<div class="vstack gap-1">
<label class="form-label fw-semibold mb-0">Color</label>
@@ -38,7 +38,7 @@ const rounded = ['default', 'pill', 'square']
aria-expanded="false"
data-color="primary"
>
Primary
<span>Primary</span>
<svg class="bi ms-1" width="16" height="16" aria-hidden="true">
<use href="#chevron-expand" />
</svg>
@@ -89,7 +89,7 @@ const rounded = ['default', 'pill', 'square']
aria-expanded="false"
data-size=""
>
Medium
<span>Medium</span>
<svg class="bi ms-1" width="16" height="16" aria-hidden="true">
<use href="#chevron-expand" />
</svg>
@@ -270,7 +270,8 @@ const rounded = ['default', 'pill', 'square']
const colorTitle = item.textContent?.trim() || 'Primary'
// Update button text and data attribute
colorDropdownButton.textContent = colorTitle
const labelSpan = colorDropdownButton.querySelector('span')
if (labelSpan) labelSpan.textContent = colorTitle
colorDropdownButton.dataset.color = colorName
// Update selected state
@@ -301,7 +302,8 @@ const rounded = ['default', 'pill', 'square']
const sizeLabel = item.textContent?.trim() || 'Medium'
// Update button text and data attribute
sizeDropdownButton.textContent = sizeLabel
const labelSpan = sizeDropdownButton.querySelector('span')
if (labelSpan) labelSpan.textContent = sizeLabel
sizeDropdownButton.dataset.size = sizeValue
// Update selected state
@@ -28,7 +28,7 @@ const logicalPlacements = [
]
---
<div class="bg-1 p-3 rounded-3 mb-3">
<div class="bg-1 p-3 fs-sm rounded-3 mb-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">Placement type</label>
@@ -67,7 +67,10 @@ const logicalPlacements = [
data-placement="bottom-start"
style="min-width: 160px;"
>
bottom-start
<span>bottom-start</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="placement-dropdown">
{physicalPlacements.map((p) => (
@@ -148,7 +151,8 @@ const logicalPlacements = [
if (!placementDropdownButton || !previewToggle) return
// Update the placement selector button
placementDropdownButton.textContent = placement
const labelSpan = placementDropdownButton.querySelector('span')
if (labelSpan) labelSpan.textContent = placement
placementDropdownButton.dataset.placement = placement
// Update active state in dropdown
@@ -0,0 +1,380 @@
---
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 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 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 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">&nbsp;</label>
<b-checkgroup class="py-2">
<div class="switch">
<input type="checkbox" value="" id="stepper-fullwidth" switch>
</div>
<label for="stepper-fullwidth">Full width</label>
</b-checkgroup>
</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>
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
if (typeof window !== 'undefined' && (window as any).Prism) {
(window as any).Prism.highlightElement(codeSnippet)
}
}
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 = bootstrap.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 = bootstrap.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 = bootstrap.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>
@@ -0,0 +1,139 @@
---
title: Stepper
description: Create timelines, wizards, or step-by-step progress bars. Ideal for shopping carts, sign-up forms, and more.
toc: true
---
## Examples
Stepper is built with CSS Grid and `<ol>` elements. By default, steps are displayed vertically. You can transform them into horizontal lists with responsive modifier classes.
### Basic
Here's a simple example of a vertical stepper.
<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>`} />
### 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.
<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>`} />
### Gap
Customize the gap with styles that override the `--bs-stepper-gap` CSS variable.
<Example code={`<ol class="stepper" style="--bs-stepper-gap: 3rem">
<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>`} />
### Variants
<Example code={`<ol class="stepper stepper-horizontal">
<li class="stepper-item active theme-accent">Create account</li>
<li class="stepper-item">Confirm email</li>
<li class="stepper-item active theme-success">Update profile</li>
<li class="stepper-item active theme-danger">Finish</li>
</ol>`} />
### 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.
<Example code={`<div class="stepper-overflow">
<ol class="stepper stepper-horizontal">
<li class="stepper-item active">Create account</li>
<li class="stepper-item active">Verify email address</li>
<li class="stepper-item">Complete profile setup</li>
<li class="stepper-item">Add payment method</li>
<li class="stepper-item">Review and confirm</li>
<li class="stepper-item">Finish onboarding</li>
</ol>
</div>`} />
## Playground
Experiment with stepper options including orientation, breakpoints, step count, and more.
<StepperPlayground />
### 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.
<Example code={`<ol class="stepper stepper-horizontal">
<li class="stepper-item active">Default stepper</li>
<li class="stepper-item active">Confirm email</li>
<li class="stepper-item">Update profile</li>
<li class="stepper-item">Finish</li>
</ol>`} />
<Example class="text-center" code={`<ol class="stepper stepper-horizontal">
<li class="stepper-item active">Center stepper</li>
<li class="stepper-item active">Confirm email</li>
<li class="stepper-item">Update profile</li>
<li class="stepper-item">Finish</li>
</ol>`} />
<Example class="text-end" code={`<ol class="stepper stepper-horizontal">
<li class="stepper-item active">End stepper</li>
<li class="stepper-item active">Confirm email</li>
<li class="stepper-item">Update profile</li>
<li class="stepper-item">Finish</li>
</ol>`} />
Apply `.w-100` to the stepper to make it full width. Stepper items will be stretched to fill the available space. Alignment doesn't affect full-width steppers.
<Example code={`<ol class="stepper stepper-horizontal w-100">
<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>`} />
### 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>
</div>`} />
## CSS
### Variables
Steppers use [CSS variables]([[docsref:/customize/css-variables]]) for easier customization.
<ScssDocs name="stepper-css-vars" file="scss/_stepper.scss" />
### Sass variables
<ScssDocs name="stepper-variables" file="scss/_stepper.scss" />
### Sass mixin
<ScssDocs name="stepper-horizontal-mixin" file="scss/_stepper.scss" />
### Overflow wrapper
<ScssDocs name="stepper-overflow" file="scss/_stepper.scss" />
+1
View File
@@ -21,6 +21,7 @@ export declare global {
export const JsDocs: typeof import('@shortcodes/JsDocs.astro').default
export const Placeholder: typeof import('@shortcodes/Placeholder.astro').default
export const ScssDocs: typeof import('@shortcodes/ScssDocs.astro').default
export const StepperPlayground: typeof import('@shortcodes/StepperPlayground.astro').default
export const Swatch: typeof import('@shortcodes/Swatch.astro').default
export const Table: typeof import('@shortcodes/Table.astro').default
}