AMP Conf 2019. April 17/18. Tokyo.
AMP
  • websites

Multi Page Flow

Introduction

This sample demonstrates different approaches for how to implement a multi-step flow in AMP. These could

be used for checkout flows, sign-ups or surveys.

Setup

We use amp-bind to coordinate the page transitions...

<script async custom-element="amp-bind" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js"></script>

... and amp-selector for implementing a simple survey.

<script async custom-element="amp-selector" src="https://cdn.ampproject.org/v0/amp-selector-0.1.js"></script>

A simple Dialog

We use an implicit state variable currentPage to keep track of the current view. Views are identified by numbers starting with 0 in their order of appearance. The view state is bound to each view using AMP's hidden attribute:

<section [hidden]="currentPage > 0"> ... </section>

...and for initially hidden views we add the hidden attribute as a default:

<section hidden [hidden]="currentPage != 1"> ... </section>

We update the currentPage variable to progress forward in the dialog. In this sample we're using AMP.pushState(...) instead of AMP.setState(...). AMP.pushState(...) pushes a new entry onto the browser history stack, which allows the user to navigate back in the to use the browser's back button to move backwards in the dialog:

<button on="tap:AMP.pushState({ currentPage: currentPage + 1 })">
  next
</button>
Step 1
Here is some content ...
<div class="stepper simple">
  <section [hidden]="currentPage > 0">
    <div class="top-bar">
      Step 1
    </div>
    <div class="content">Here is some content ...</div>
    <div class="bottom-bar">
      <div class="step-dots">
        <i class="step-dot active"></i>
        <i class="step-dot"></i>
        <i class="step-dot"></i>
      </div>
      <button on="tap:AMP.pushState({ currentPage: currentPage + 1 })"
        class="button-next">next</button>
    </div>
  </section>
  <section hidden
    [hidden]="currentPage != 1">
    <div class="top-bar">
      Step 2
    </div>
    <div class="content">Here is some more content ...</div>
    <div class="bottom-bar">
      <button class="button-prev"
        on="tap:AMP.pushState({ currentPage: currentPage - 1 })">back</button>
      <div class="step-dots">
        <i class="step-dot active"></i>
        <i class="step-dot active"></i>
        <i class="step-dot"></i>
      </div>
      <button on="tap:AMP.pushState({ currentPage: currentPage + 1 })"
        class="button-next">next</button>
    </div>
  </section>
  <section hidden
    [hidden]="currentPage != 2">
    <div class="top-bar">
      Step 3
    </div>
    <div class="content">Done!</div>
    <div class="bottom-bar">
      <button class="button-prev"
        on="tap:AMP.pushState({ currentPage: currentPage - 1 })">back</button>
      <div class="step-dots">
        <i class="step-dot active"></i>
        <i class="step-dot active"></i>
        <i class="step-dot active"></i>
      </div>
    </div>
  </section>
</div>

A vertical Stepper

Vertical steppers work well if steps depend on each other. The stepper is implemented similar to the first sample using a currentStep variable to keep track of the currently active step. We additionally define a step title button which is always visible and shows the status of the current step. The title needs to reflect three different states (active, complete, disabled). To avoid too complex amp-bind expressions, the three states are split into three different bindings:

  • The title class gets updated if the current step is active.
  • The title gets a disabled attribute if the previous step has not yet been completed
  • The nested icon's class gets set to step-complete or step-incomplete based on whether the step has finished.

Clicking on the title will go to the corresponding step (if already possible):

<button class="step-title"
    [class]="currentStep != 1 ? 'step-title' : 'step-title step-active'"
     disabled [disabled]="!animalSelected"
     on="tap:AMP.pushState({ currentStep: 1 })">
  <i class="step-incomplete"
     [class]="colorSelected ? 'step-complete' : 'step-incomplete'" data-step-nr="2"></i>
     Color
</button>

By default, the Continue button is disabled. Only when the step is completed (in this case when a selection has been made), will the button be enabled:

<button disabled [disabled]="!animalSelected"
        on="tap:AMP.pushState({ currentStep: currentStep + 1 })">
  continue
</button>

Here is the full example:

What's your favorite animal?

Cat
Dog
Horse
<div class="stepper vertical">
  <button class="step-title step-active"
    [class]="currentStep > 0 ? 'step-title' : 'step-title step-active'"
    on="tap:AMP.pushState({ currentStep: 0 })">
    <i class="step-incomplete"
      [class]="animalSelected ? 'step-complete' : 'step-incomplete'"
      data-step-nr="1"></i>
    Animal
  </button>
  <section [hidden]="currentStep > 0">
    <div class="content">
      <p>What's your favorite animal?</p>
      <amp-selector on="select:AMP.setState({animalSelected: true})"
        class="poll"
        name="animal-poll">
        <div option="cat">Cat</div>
        <div option="dog">Dog</div>
        <div option="horse">Horse</div>
      </amp-selector>
      <button disabled
        [disabled]="!animalSelected"
        on="tap:AMP.pushState({ currentStep: currentStep + 1 })">
        continue
      </button>
    </div>
  </section>
  <button class="step-title"
    [class]="currentStep != 1 ? 'step-title' : 'step-title step-active'"
    disabled
    [disabled]="!animalSelected"
    on="tap:AMP.pushState({ currentStep: 1 })">
    <i class="step-incomplete"
      [class]="colorSelected ? 'step-complete' : 'step-incomplete'"
      data-step-nr="2"></i>
    Color
  </button>
  <section hidden
    [hidden]="currentStep != 1">
    <div class="content">
      <p>What's your favorite color?</p>
      <amp-selector on="select:AMP.setState({colorSelected: true})"
        class="poll"
        name="color-poll">
        <div option="blue">Blue</div>
        <div option="green">Green</div>
        <div option="yellow">Yellow</div>
      </amp-selector>
      <button disabled
        [disabled]="!colorSelected"
        on="tap:AMP.pushState({ currentStep: currentStep + 1 })">
        continue
      </button>
    </div>
  </section>
  <button class="step-title"
    [class]="currentStep != 2 ? 'step-title' : 'step-title step-active'"
    disabled
    [disabled]="!colorSelected"
    on="tap:AMP.pushState({ currentStep: 2 })">
    <i class="step-incomplete"
      [class]="fruitSelected ? 'step-complete' : 'step-incomplete'"
      data-step-nr="3"></i>
    Fruit
  </button>
  <section hidden
    [hidden]="currentStep != 2">
    <div class="content">
      <p>What's your favorite fruit?</p>
      <amp-selector on="select:AMP.setState({fruitSelected: true})"
        class="poll"
        name="fruit-poll">
        <div option="apple">Apple</div>
        <div option="banana">Banana</div>
        <div option="cheery">Cheery</div>
      </amp-selector>
      <button disabled
        [disabled]="!fruitSelected"
        on="tap:AMP.pushState({ currentStep: currentStep + 1 })">
        continue
      </button>
    </div>
  </section>
</div>

Stepper with Sliding Animation

This sample demonstrates a simple sliding animation visualising the dialog progress. The basic approach is the same as in the previous two samples: a variable keeps track of the current page. The only difference is that here we can't use the hidden attribute as we want to transition between the different pages. The hidden attribute uses display: none, which cannot be animated in CSS. Instead we use three different CSS classes (active, next and previous) to slide between the different pages:

  .page.active {
    transform: translateX(0%);
    pointer-events: auto;
  }
  .page:not(.active) {
    opacity: 0.5;
    pointer-events: none;
  }
  .page.next {
    transform: translateX(100%);
  }
  .page.previous {
    transform: translateX(-100%);
  }

For each page we assign the matching class based on whether the page index is smaller, same or larger:

    <section class="page next"
             [class]="slidingStepperPage < 1 ? 'page next' :
                      (slidingStepperPage > 1 ? 'page previous' : 'page active')"> ...</section>

To avoid accidentally revealing hidden steps via keyboard focus, we need to make sure to explicitly disable all input elements

in hidden steps, e.g.:

     <input type="text" value="" name="password"
            disabled [disabled]="slidingStepperPage != 1" ...>

We sync the entered email address between the two steps using an amp-state variable email:

      <input type="email" value="" name="email"
               on="input-debounced: AMP.setState({ email: event.value })" ...>
        ...
      <button class="back" [text]="email" ...></button>

Here is the full example:

Sign in

Welcome

Submitting ...

Success

You did it!

Something went wrong.

<form class="stepper sliding"
  method="post"
  action-xhr="/components/amp-form/submit-form-input-text-xhr"
  novalidate
  on="submit: AMP.setState({ slidingStepperPage: 2 });
          submit-success: AMP.setState({ slidingStepperPage: 3 });
          submit-error: AMP.setState({ slidingStepperPage: 4 });
      ">
  <section class="page active"
    [class]="slidingStepperPage > 0 ? 'page previous' : 'page active'">
    <h3>Sign in</h3>
    <div class="input">
      <input type="email"
        value=""
        name="email"
        autocomplete="email"
        id="id1"
        placeholder="Enter your Email"
        on="input-debounced: AMP.setState({ email: event.value })">
      <label for="ip1"
        aria-hidden="true">
        Enter your Email
      </label>
    </div>
    <button type="button"
      on="tap:AMP.pushState({ slidingStepperPage: slidingStepperPage + 1 })"
      disabled
      [disabled]="!email">next</button>
  </section>
  <section class="page next"
    [class]="slidingStepperPage < 1 ? 'page next' :
           (slidingStepperPage > 1 ? 'page previous' : 'page active')">
    <h3>Welcome</h3>
    <button class="back"
      [text]="email"
      on="tap:AMP.pushState({ slidingStepperPage: slidingStepperPage - 1 })"
      disabled
      [disabled]="slidingStepperPage != 1"
      type="button"></button>
    <div class="input">
      <input type="password"
        value=""
        name="password"
        id="id2"
        placeholder="Enter your Password"
        disabled
        [disabled]="slidingStepperPage != 1"
        on="input-debounced: AMP.setState({ password: event.value })">
      <label for="ip2"
        aria-hidden="true">
        Enter your Password
      </label>
    </div>
    <button disabled
      [disabled]="slidingStepperPage != 1 || !password"
      type="submit">submit</button>
  </section>
  <section class="page next"
    [class]="slidingStepperPage < 2 ? 'page next' :
           (slidingStepperPage > 2 ? 'page previous' : 'page active')">
    <p>Submitting ...</p>
  </section>
  <section class="page next"
    [class]="slidingStepperPage < 3 ? 'page next' :
           (slidingStepperPage > 3 ? 'page previous' : 'page active')">
    <h3>Success</h3>
    <p>You did it!</p>
  </section>
  <section class="page next"
    [class]="slidingStepperPage < 4 ? 'page next' :
           (slidingStepperPage > 4 ? 'page previous' : 'page active')">
    <h3>Something went wrong. </h3>
    <button on="tap:AMP.setState({ slidingStepperPage: 0 })"
      type="button">Try again</button>
  </section>
</form>
Need further explanation?

If the explanations on this page don't cover all of your questions feel free to reach out to other AMP users to discuss your exact use case.

Go to Stack Overflow
An unexplained feature?

The AMP project strongly encourages your participation and contributions! We hope you'll become an ongoing participant in our open source community but we also welcome one-off contributions for the issues you're particularly passionate about.

Edit sample on GitHub