Stepper Ready
This template shows how to use Duet’s Stepper and Step components to create a purchase flow. Please note that the example below does not represent a real use case.
Hint: Press F
on your keyboard to view both templates and components in fullscreen and ESC
to exit the fullscreen mode. You can also open the template in a new browser window.
<!DOCTYPE html>
<html class="duet-bg-gradient duet-sticky-footer" lang="fi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>LähiTapiola</title>
<link rel="stylesheet" href="https://cdn.duetds.com/api/fonts/3.0.51/lib/localtapiola.css" integrity="sha384-5JYmtSD7nykpUvSmTW1CHMoBDkBZUpUmG0vuh+NUVtZag3F75Kr7+/JU3J7JV6Wq" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.duetds.com/api/css/4.0.45/lib/duet.min.css" integrity="sha384-UoMJnpXiN8f7fKVnTzfKfyi7LzQlApQ+WTS9O3PXlYr6CO9yzou4glfsHV747f3v" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdn.duetds.com/api/tokens/4.0.59/lib/tokens.custom-properties.css" integrity="sha384-AexjbYNj18dJLZR54wNVU44b/akdDc+tpbLIWhAnzjMAbwFSli2DHpSP+7NCK+Xw" crossorigin="anonymous" />
<script type="module" src="https://cdn.duetds.com/api/components/8.7.1/lib/duet/duet.esm.js" integrity="sha384-c3hLghWHTPntUxndMlK4myD7d59stA2U0EvBp2rPb3ibtacAwh56geBpq6z7nXLn" crossorigin="anonymous"></script>
<script nomodule src="https://cdn.duetds.com/api/components/8.7.1/lib/duet/duet.js" integrity="sha384-H7RH4Ssj/LmElXa1VCHFSIjvtCSoLXMiPXG/KQZTS9EvYujTo7tWaj0V6/GGkY16" crossorigin="anonymous"></script>
</head>
<body>
<style>
html {
scroll-behavior: smooth;
scroll-padding-top: calc(var(--size-header) * 2);
}
.price,
#links {
display: none;
}
.has-price .price,
.has-price #links {
display: block;
}
.has-price .price-placeholder {
display: none;
}
.campaign,
.campaignrebate {
transition: 500ms ease;
max-height: 0;
opacity: 0;
visibility: hidden;
overflow: hidden;
font-weight: var(--font-weight-semi-bold);
font-size: var(--font-size-small);
padding: var(--space-x-small) 0;
justify-content: space-between;
margin: 0px !important;
padding: 0px !important;
}
.campaignrebate {
border-top: 1px solid var(--color-gray-light);
margin-top: var(--space-x-small);
background-color: rgb(242, 249, 247);
}
.campaign.active,
.campaignrebate.active {
max-height: 250px;
opacity: 1;
visibility: visible;
margin: var(--space-small) 0;
padding: var(--space-small);
}
campaign-bn-top {
margin: 16px 0 0 0;
}
.alert {
transition: 300ms ease;
visibility: hidden;
transform: scale(0.9);
height: 0;
opacity: 0;
}
.alert.active {
opacity: 1;
transform: scale(0.999);
visibility: visible;
height: auto;
}
.breakdown-list {
list-style: none;
font-weight: var(--font-weight-semi-bold);
padding: 0;
margin: 0;
margin-bottom: var(--space-small);
}
.breakdown-list li {
font-size: var(--font-size-small);
}
.breakdown-list li + li {
margin-top: var(--space-xx-small);
}
.link-list a {
border-bottom: 1px solid var(--color-gray-light);
font-weight: var(--font-weight-semi-bold);
font-size: var(--font-size-small);
padding: var(--space-x-small) 0;
justify-content: space-between;
text-decoration: none;
align-items: center;
display: flex;
width: 100%;
}
.link-list-sidebar a:first-of-type {
border-top: 1px solid var(--color-gray-light);
}
.price-container {
position: relative;
}
.price-spinner {
position: absolute;
right: 0;
}
.price-container.price-loading .price-value {
opacity: 0;
}
.notes li + li {
margin-top: var(--space-x-small);
}
</style>
<duet-header
language="fi"
skip-to-id="#content"
language-items='[
{ "label": "Suomeksi", "country": "fi", "href": "/?lang=fi" },
{ "label": "På Svenska", "country": "sv", "href": "/?lang=sv" },
{ "label": "In English", "country": "en", "href": "/?lang=en" }
]'
contact-items='[
{ "label": "Lähetä viesti", "href": "/viestit/laheta" },
{ "label": "Avaa chat", "href": "/chat/" },
{ "label": "Yhteystiedot", "href": "/yhteystiedot/", "external": true }
]'
session='{ "label": "Kirjaudu ulos", "href": "/?logout", "type": "logout" }'
user='{ "label": "Elina", "href": "/?userId=elina" }'
></duet-header>
<duet-hero
heading="Eläinvakuutus. Vaivattomasti. Vain muutamassa minuutissa."
image="https://cdn.duetds.com/api/assets/illustrations/placeholder-dark.svg"
button-label="Aloita tästä"
variation="light"
button-url="#start"
margin="none"
icon="navigation-arrow-down"
icon-right
id="content"
>
</duet-hero>
<duet-layout id="start" sticky sticky-distance="without-links">
<div slot="main">
<duet-tray class="price">
<duet-grid alignment="center">
<duet-grid-item margin="none" fill>
<duet-paragraph size="small" margin="none">Vuosimaksu</duet-paragraph>
</duet-grid-item>
<duet-grid-item margin="none">
<div class="price-container">
<span class="price-value duet-font-size-medium duet-font-weight-semi-bold">189,90 €</span>
</div>
</duet-grid-item>
</duet-grid>
<duet-grid
distribution="space-between"
alignment="center"
aria-live="polite"
aria-atomic="true"
class="campaignrebate"
>
<duet-grid-item margin="none">
<span class="price-text duet-font-weight-semi-bold duet-font-size-small">Kampanj</span>
</duet-grid-item>
<duet-grid-item margin="none">
<div class="price-container-">
<span class="price-value duet-font-size-small duet-font-weight-semi-bold">-10,90 €</span>
</div>
</duet-grid-item>
</duet-grid>
<duet-spacer></duet-spacer>
<duet-button
class="campaign-bn button campaign-bn-top"
variation="plain"
icon="messaging-discount"
icon-size="large"
size="small"
>
Onko sinulle kampanjakoodi?
</duet-button>
<div class="campaign">
<form novalidate class="form campaign-form">
<duet-grid responsive breakpoint="x-large" alignment="bottom">
<duet-input class="code" label="Syötä koodi" placeholder="Kampanjakoodi" expand></duet-input>
<duet-button
style="min-width: 8rem"
wrapping="none"
size="small"
submit
expand
variation="primary"
class="use-code"
>Käytä</duet-button
>
</duet-grid>
</form>
</div>
<div slot="additional">
<div class="link-list">
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Liikennevakuutuksen tiedot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Kaskovakuutuksen tiedot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Liikennevakuutusehdot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Kaskovakuutuksehdot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Tuoteseloste</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
</div>
</div>
</duet-tray>
<duet-stepper>
<duet-step heading="Perustiedot">
<form>
<duet-caption>
Tee valinnat ja täytä kaikki kentät, ellei toisin mainita. Huomaathan, että vakuutuksen myöntämiseen
vaikuttaa eläimen terveyden tila.
</duet-caption>
<duet-caption size="small">
<ol class="notes">
<li>Changes to inputs are tracked so that the user is warned if leaving midway through the process.</li>
<li>Submitting the form triggers an asynchronous process.</li>
<li>While submitting, the button is put into a <code>loading</code> state.</li>
<li>While submitting, all input fields are <code>disabled</code>.</li>
<li>When submitting completes, the next step is shown.</li>
</ol>
</duet-caption>
<duet-spacer size="x-small"></duet-spacer>
<duet-choice-group value="1" label="Millainen lemmikkisi on?" direction="horizontal" name="pet" responsive>
<duet-choice label="Koira" type="radio" value="1" expand></duet-choice>
<duet-choice label="Kissa" type="radio" value="2" expand></duet-choice>
<duet-choice label="Hevonen" type="radio" value="3" expand></duet-choice>
</duet-choice-group>
<duet-input label="Rotu" name="breed" placeholder="Labradorinnoutaja" expand required></duet-input>
<duet-grid responsive breakpoint="medium" direction="horizontal" alignment="stretch">
<duet-input label="Syntymäaika" name="dob" placeholder="24.8.2011" expand required></duet-input>
<duet-grid-item min-width="calc(66.666% + 8px)" fill></duet-grid-item>
</duet-grid>
<duet-grid responsive breakpoint="medium" direction="horizontal" alignment="stretch">
<duet-input label="Nimi" name="name" placeholder="Lila" expand required></duet-input>
<duet-grid-item min-width="calc(33.333% + 8px)" fill></duet-grid-item>
</duet-grid>
<duet-spacer size="large"></duet-spacer>
<duet-grid responsive breakpoint="medium" direction="horizontal" alignment="stretch">
<duet-button variation="primary" submit expand wrapping="none">Laske hinta</duet-button>
<duet-grid-item min-width="calc(66.666% + 8px)" fill></duet-grid-item>
</duet-grid>
</form>
</duet-step>
<duet-step heading="Tarkemmat tiedot">
<form>
<duet-caption>
Lorem ipsum dolor sit amet consectetuer adipiscing elit no nummy laoreet ipsum dolor sit amet consectetuer
adipiscing elit no nummy laoreet consectetuer adipiscing.
</duet-caption>
<duet-caption size="small">
<ol class="notes">
<li>Clicking a choice starts an asynchronous process that updates prices in both the sidebar and tray</li>
<li>The primary button is <code>disabled</code> whilst new price is loading.</li>
<li>
<code>aria-live</code> and <code>aria-atomic</code> are used on the price in the sidebar, so that any
changes are announced to screen readers.
</li>
<li>
Stepping backwards is disabled until loading is complete (via <code>duet-stepper</code>'s
<code>backDisabled</code> property).
</li>
</ol>
</duet-caption>
<duet-choice-group value="1" label="Favorite nut?" direction="horizontal" name="fruits" responsive>
<duet-choice label="Strawberry" type="radio" value="1" expand></duet-choice>
<duet-choice label="Almond" type="radio" value="2" expand></duet-choice>
<duet-choice label="Peanut" type="radio" value="3" expand></duet-choice>
</duet-choice-group>
<duet-button variation="primary" submit>Submit</duet-button>
</form>
</duet-step>
<duet-step heading="Yhteenveto">
<form>
<duet-caption>
Lorem ipsum dolor sit amet consectetuer adipiscing elit no nummy laoreet ipsum dolor sit amet consectetuer
adipiscing elit no nummy laoreet consectetuer adipiscing.
</duet-caption>
<duet-button variation="primary" submit>Seuraava</duet-button>
</form>
</duet-step>
<duet-step heading="Terveysselvitys">
<duet-caption>
Lorem ipsum dolor sit amet consectetuer adipiscing elit no nummy laoreet ipsum dolor sit amet consectetuer
adipiscing elit no nummy laoreet consectetuer adipiscing.
</duet-caption>
</duet-step>
</duet-stepper>
</div>
<div slot="sidebar">
<duet-card padding="small" id="price">
<duet-heading level="h2" visual-level="h4">Vakuutusmaksu</duet-heading>
<div class="price">
<ul class="breakdown-list" role="list">
<li>Matkustajan hoitoturva</li>
<li>Tapaturmaisen pysyvän haitan turva</li>
<li>Tapaturmaisen kuoleman turva</li>
<li>Matkatavaravakuutus</li>
</ul>
<duet-grid distribution="space-between" alignment="center" aria-live="polite" aria-atomic="true">
<duet-grid-item margin="none">
<span class="price-text duet-font-weight-semi-bold duet-font-size-small">Yhteensä</span>
</duet-grid-item>
<duet-grid-item margin="none">
<div class="price-container">
<span class="price-value duet-font-size-small duet-font-weight-semi-bold">189,90 €</span>
</div>
</duet-grid-item>
</duet-grid>
<duet-grid
distribution="space-between"
alignment="center"
aria-live="polite"
aria-atomic="true"
class="campaignrebate"
>
<duet-grid-item margin="none">
<span class="price-text duet-font-weight-semi-bold duet-font-size-small">Kampanj</span>
</duet-grid-item>
<duet-grid-item margin="none">
<div class="price-container">
<span class="price-value duet-font-size-small duet-font-weight-semi-bold">-10,90 €</span>
</div>
</duet-grid-item>
</duet-grid>
<duet-spacer></duet-spacer>
<duet-button
class="campaign-bn button"
variation="plain"
icon="messaging-discount"
icon-size="large"
size="small"
>
Onko sinulle kampanjakoodi?
</duet-button>
<div class="campaign">
<form novalidate class="form campaign-form">
<duet-grid responsive breakpoint="x-large" alignment="bottom">
<duet-input class="code" label="Syötä koodi" placeholder="Kampanjakoodi" expand></duet-input>
<duet-spacer size="small" direction="horizontal"></duet-spacer>
<duet-button style="min-width: 8rem" wrapping="none" submit variation="primary" class="use-code"
>Käytä</duet-button
>
</duet-grid>
</form>
<duet-alert class="alert" variation="success">Koodi käytetty onnistuneesti!</duet-alert>
</div>
</div>
<duet-caption class="price-placeholder">Täytä perustiedot nähdäksesi hinta.</duet-caption>
</duet-card>
<duet-card id="links" padding="small">
<duet-heading level="h2" visual-level="h4">Vakuutukseen liittyvät asiakirjat</duet-heading>
<duet-spacer size="x-small"></duet-spacer>
<div class="link-list link-list-sidebar">
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Liikennevakuutuksen tiedot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Kaskovakuutuksen tiedot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Liikennevakuutusehdot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Kaskovakuutuksehdot</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
<a href="#" target="_blank">
<span class="duet-mr-xx-small">Tuoteseloste</span>
<duet-icon name="action-new-window-small" size="xx-small" margin="none"></duet-icon>
<duet-visually-hidden>Aukeaa uuteen ikkunaan</duet-visually-hidden>
</a>
</div>
</duet-card>
</div>
</duet-layout>
<duet-footer
logo-href="#"
language="fi"
items='[
{ "label": "Hae korvausta", "href": "#", "icon": "navigation-make-claim" },
{ "label": "Osta vakuutus", "href": "#", "icon": "action-buy-insurance" },
{ "label": "Yhteystiedot", "href": "#", "icon": "form-tel" }
]'
menu='[
{ "label": "Turvallisuus ja käyttöehdot", "href": "#" },
{ "label": "Evästeet", "href": "#" },
{ "label": "Henkilötietojen käsittely", "href": "#" }
]'
></duet-footer>
<script>
function createApp() {
var state = {
currentStep: 0,
isStepLoading: false,
isPriceLoading: false,
isDirty: false,
price: 189.9,
fruit: "1",
}
function setState(newState) {
state = Object.assign({}, state, newState)
updateUI(state)
}
var actions = {
nextStep(e) {
e.preventDefault()
setState({ isStepLoading: true })
setTimeout(setState, 2000, {
currentStep: state.currentStep + 1,
isStepLoading: false,
})
},
goToStep(e) {
setState({ currentStep: e.detail.toStep })
},
markDirty() {
setState({ isDirty: true })
},
fetchPrice(e) {
setState({ isPriceLoading: true, fruit: e.detail.value })
setTimeout(setState, 1000, {
price: 100 + Math.random() * 100, // random price to simulate data fetch
isPriceLoading: false,
})
},
warnIfDirty(e) {
if (state.isDirty) {
e.preventDefault()
e.returnValue = ""
}
},
}
var stepper = document.querySelector("duet-stepper")
var tray = document.querySelector("duet-tray")
var stepElements = stepper.querySelectorAll("duet-step")
var steps = [
createStep1(stepElements[0], actions),
createStep2(stepElements[1], actions),
createStep3(stepElements[2], actions),
]
var priceElements = document.querySelectorAll(".price-container")
var prices = [createPrice(priceElements[0]), createPrice(priceElements[1])]
// handle navigation to previous steps
stepper.addEventListener("duetStepChange", actions.goToStep)
// warn user about leaving if midway through process
window.addEventListener("beforeunload", actions.warnIfDirty)
// responsible for coordinating all updates to the UI whenever state changes
function updateUI(state) {
// scroll into view, only when the step has changed
if (stepper.selected !== state.currentStep) {
stepper.selected = state.currentStep
stepper.scrollIntoView()
}
// price and tray should only be shown after first step
tray.active = state.currentStep !== 0
document.documentElement.classList.toggle("has-price", state.currentStep !== 0)
// disable back in stepper when loading
stepper.backDisabled = state.isStepLoading || state.isPriceLoading
// update the DOM for each step and price
steps.forEach(function (updateStep) {
updateStep(state)
})
prices.forEach(function (updatePrice) {
updatePrice(state)
})
}
}
// responsible for updating the UI for a price on the page
function createPrice(priceContainer) {
var value = priceContainer.querySelector(".price-value")
var spinner = document.createElement("duet-spinner")
spinner.className = "price-spinner"
spinner.color = "primary"
spinner.setAttribute("aria-hidden", "true")
function showSpinner() {
priceContainer.appendChild(spinner)
priceContainer.classList.add("price-loading")
}
function hideSpinner() {
spinner.remove()
priceContainer.classList.remove("price-loading")
}
return function updateUI(state) {
state.isPriceLoading ? showSpinner() : hideSpinner()
value.textContent = state.price.toLocaleString("fi", {
style: "currency",
currency: "EUR",
})
}
}
// responsible for handling events and updating UI in step 1
function createStep1(step, actions) {
var form = step.querySelector("form")
var button = form.querySelector("duet-button")
form.addEventListener("submit", actions.nextStep)
form.querySelector("[name='pet']").addEventListener("duetChange", actions.markDirty)
form.querySelector("[name='breed']").addEventListener("duetChange", actions.markDirty)
form.querySelector("[name='dob']").addEventListener("duetChange", actions.markDirty)
form.querySelector("[name='name']").addEventListener("duetChange", actions.markDirty)
return function updateUI(state) {
button.loading = state.isStepLoading
Array.from(form.elements).forEach(function (input) {
input.disabled = state.isStepLoading
})
}
}
// responsible for handling events and updating UI in step 2
function createStep2(step, actions) {
var form = step.querySelector("form")
var button = form.querySelector("duet-button")
var choiceGroup = form.querySelector("[name='fruits']")
var choices = Array.from(form.querySelectorAll("duet-choice"))
form.addEventListener("submit", actions.nextStep)
choiceGroup.addEventListener("duetChange", actions.fetchPrice)
return function updateUI(state) {
button.loading = state.isStepLoading
button.disabled = state.isPriceLoading
// disable sibling choices so focus doesn't get lost
// and values can't change whilst price is loading
choices
.filter(function (choice) {
return choice.value !== state.fruit
})
.forEach(function (choice) {
choice.disabled = state.isPriceLoading
})
}
}
// responsible for handling events and updating UI in step 3
function createStep3(step, actions) {
var form = step.querySelector("form")
var button = form.querySelector("duet-button")
form.addEventListener("submit", actions.nextStep)
return function updateUI(state) {
button.loading = state.isStepLoading
}
}
const campaignButtons = document.querySelectorAll(".campaign-bn")
// Toggle campaign code panel on button click
campaignButtons.forEach(campaignBn => {
campaignBn.addEventListener("click", function (e) {
e.preventDefault()
const container = e.currentTarget.closest(".price")
const closeCode = container.querySelector(".code")
const campaignForm = container.querySelector(".campaign")
campaignForm.classList.toggle("active", true)
setTimeout(function () {
if (closeCode.classList.contains("active")) {
closeCode.querySelector(".code").setFocus()
}
}, 300)
})
})
const campaignForms = document.querySelectorAll(".campaign-form")
// Toggle campaign code panel on button click
campaignForms.forEach(campaignForm => {
campaignForm.addEventListener("submit", function (e) {
e.preventDefault()
const container = this.closest(".price")
const campaignRebate = container.querySelectorAll(".campaignrebate")
const campaignElem = container.querySelector(".campaign")
campaignRebate.forEach(campaignElem => {campaignElem.classList.toggle("active", true)})
campaignElem.classList.toggle("active", false)
})
})
createApp()
</script>
</body>
</html>
Integration
To install this template’s dependencies into your project, run:
npm install @duetds/components
npm install @duetds/css
npm install @duetds/fonts
For further guidelines, please see each package’s documentation.
Tutorials
Follow these practical tutorials to learn how to build simple page layouts using Duet’s CSS Framework, Web Components and other features:
Tutorials
Building Layouts
TutorialsUsing CLI Tools
TutorialsCreating Custom Patterns
TutorialsServer Side Rendering
TutorialsSharing Prototypes
TutorialsUsage With Markdown
Troubleshooting
If you experience any issues while using a template, please head over to the Support page for more guidelines and help.