Friday, April 26, 2024

New patterns


I’m excited to share many new web.dev patterns! These additions are from the show GUI Challenges where I share my strategies on how to build various components and common interface needs, then collect user submissions for the same tasks and help us all grow our perspectives on how to solve them.

Turns out GUI Challenges fit nicely into patterns:

h1 split-by="word" word-animation="hover">
  hover the words
&LT/h1>

const span = (text, index) => {
  const node = document.createElement('span')

  node.textContent = text
  node.style.setProperty('--index', index)
  
  return node
}

export const byWord = text =>
  text.split(' ').map(span)

const {matches:motionOK} = window.matchMedia(
  '(prefers-reduced-motion: no-preference)'
)

if (motionOK) {
  const splitTargets = document.querySelectorAll('[split-by]')

  splitTargets.forEach(node => {
    let nodes = byWord(node.innerText)

    if (nodes)
      node.firstChild.replaceWith(...nodes)
  })
}

@media (prefers-reduced-motion:no-preference) {
  [word-animation] {
    display: inline-flex;
    flex-wrap: wrap;
    gap: 1ch
  }
}

@media (prefers-reduced-motion:no-preference) and (hover) {
  [word-animation=hover] {
    overflow: hidden;
    overflow: clip
  }

  [word-animation=hover]>span {
    transition: transform .3s ease;
    cursor: pointer
  }

  [word-animation=hover]>span:not(:hover) {
    transform: translateY(50%)
  }
}

Now they can be embedded into posts (like above), aggregated for easy browsing and inspiration, and also have added new categories for other contributors to add their patterns to. Take a look around, take some code: it’s all there for you.

Overview #

Three new pattern categories:

  1. Components
  2. Animations
  3. Theming

Plus, five new patterns added to the existing Layout patterns.

Components #

Supporting graphic which has colorful prototyped components in a grid layout.

View the component patterns landing page or check each out individually:

  1. Breadcrumbs
  2. Buttons
  3. Carousel
  4. Dialog
  5. Game Menu
  6. Loading Bar
  7. Media Scroller
  8. Multi-Select
  9. Settings
  10. Sidenav
  11. Split Buttons
  12. Stories
  13. SVG Favicon
  14. Switch
  15. Tabs
  16. Toast

Here’s a preview of the split button pattern:

div class="gui-split-button">
  button>View Cart&LT/button>
  span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    svg aria-hidden="true" viewBox="0 0 20 20">
      path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    &LT/svg>
    ul class="gui-popup">
      li>button>
        svg aria-hidden="true" viewBox="0 0 24 24">
          path d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
        &LT/svg>
        Checkout
      &LT/button>&LT/li>
      li>button>
        svg aria-hidden="true" viewBox="0 0 24 24">
          path d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
        &LT/svg>
        Quick Pay
      &LT/button>&LT/li>
      li>button>
        svg aria-hidden="true" viewBox="0 0 24 24">
          path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
        &LT/svg>
        Save for later
      &LT/button>&LT/li>
    &LT/ul>
  &LT/span>
&LT/div>

div class="gui-split-button">
  button>Send&LT/button>
  span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    svg aria-hidden="true" viewBox="0 0 20 20">
      path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    &LT/svg>
    ul class="gui-popup">
      li>button>
        svg aria-hidden="true" viewBox="0 0 24 24">
          path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
        &LT/svg>
        Schedule for later
      &LT/button>&LT/li>
      li>button>
        svg aria-hidden="true" viewBox="0 0 24 24">
          path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
        &LT/svg>
        Delete
      &LT/button>&LT/li>
      li>button>
        svg aria-hidden="true" viewBox="0 0 24 24">
          path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
        &LT/svg>
        Save draft
      &LT/button>&LT/li>
    &LT/ul>
  &LT/span>
&LT/div>

div class="gui-split-button">
  button>Squash&LT/button>
  span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    svg aria-hidden="true" viewBox="0 0 20 20">
      path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    &LT/svg>
    ul class="gui-popup">
      li>button>
        Create a merge commit
      &LT/button>&LT/li>
      li>button>
        Rebase
      &LT/button>&LT/li>
    &LT/ul>
  &LT/span>
&LT/div>

import $ from 'blingblingjs'
import {rovingIndex} from 'roving-ux'

const splitButtons = $('.gui-split-button')
const popupButtons = $('.gui-popup-button')

// popup activating roving index for it's buttons
popupButtons.forEach(element => 
  rovingIndex({
    element,
    target: 'button',
  }))

// support escape key
popupButtons.on('keyup', e => {
  if (e.code === 'Escape')
    e.target.blur()
})

popupButtons.on('focusin', e => {
  e.currentTarget.setAttribute('aria-expanded', true)
})

popupButtons.on('focusout', e => {
  e.currentTarget.setAttribute('aria-expanded', false)
})

// respond to any button interaction
splitButtons.on('click', event => {
  if (event.target.nodeName !== 'BUTTON') return
  console.info(event.target.innerText)
})

.gui-split-button {
  --theme:        hsl(220 75% 50%);
  --theme-hover:  hsl(220 75% 45%);
  --theme-active: hsl(220 75% 40%);
  --theme-text:   hsl(220 75% 25%);
  --theme-border: hsl(220 50% 75%);
  --ontheme:      hsl(220 90% 98%);
  --popupbg:      hsl(220 0% 100%);

  --border: 1px solid var(--theme-border);
  --radius: 6px;
  --in-speed: 500ms;
  --out-speed: 100ms;

  display: inline-flex;
  border-radius: var(--radius);
  background: var(--theme);
  color: var(--ontheme);
  fill: var(--ontheme);

  touch-action: manipulation;
  user-select: none;
  -webkit-tap-highlight-color: transparent;

  @media (--dark) {
    --theme:        hsl(220 50% 60%);
    --theme-hover:  hsl(220 50% 65%);
    --theme-active: hsl(220 75% 70%);
    --theme-text:   hsl(220 10% 85%);
    --theme-border: hsl(220 20% 70%);
    --ontheme:      hsl(220 90% 5%);
    --popupbg:      hsl(220 10% 30%);
  }

  & button {
    cursor: pointer;
    appearance: none;
    background: none;
    border: none;

    display: inline-flex;
    align-items: center;
    gap: 1ch;
    white-space: nowrap;

    font-family: inherit;
    font-size: inherit;
    font-weight: 500;

    padding-block: 1.25ch;
    padding-inline: 2.5ch;

    color: var(--ontheme);
    outline-color: var(--theme);
    outline-offset: -5px;

    &:is(:hover, :focus-visible) {
      background: var(--theme-hover);
      color: var(--ontheme);

      & > svg {
        stroke: currentColor;
        fill: none;
      }
    }

    &:active {
      background: var(--theme-active);
    }
  }

  & > button {
    border-radius: var(--radius) 0 0 var(--radius);

    @supports (border-start-start-radius: 1px) {
      border-end-start-radius: var(--radius);
      border-start-start-radius: var(--radius);
    }
  }

  @media (--light) {
    & > button,
    & button:is(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--theme-active);
    }
    & > .gui-popup-button > svg,
    & button:is(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--theme-active));
    }
  }
  
  & svg {
    inline-size: 2ch;
    box-sizing: content-box;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-width: 2px;
  }
}

.gui-popup-button {
  inline-size: 4ch;
  cursor: pointer;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-inline-start: var(--border);
  border-radius: 0 var(--radius) var(--radius) 0;

  @supports (border-start-start-radius: 1px) {
    border-inline-start: var(--border);
    border-start-end-radius: var(--radius);
    border-end-end-radius: var(--radius);
  }

  &:is(:hover,:focus-within) {
    background: var(--theme-hover);
  }

  /* fixes iOS trying to be helpful */
  &:focus {
    outline: none;
  }

  &:active {
    background: var(--theme-active);
  }
  
  &:focus-within {
    & > svg {
      transition-duration: var(--in-speed);
      transform: rotateZ(.5turn);
    }
    & > .gui-popup {
      transition-duration: var(--in-speed);
      opacity: 1;
      transform: translateY(0);
      pointer-events: auto;
    }
  }

  @media (--motionOK) {
    & > svg {
      transition: transform var(--out-speed) ease;
    }
    & > .gui-popup {
      transform: translateY(5px);

      transition: 
        opacity var(--out-speed) ease,
        transform var(--out-speed) ease;
    }
  }
}

.gui-popup {
  --shadow: 220 70% 15%;
  --shadow-strength: 1%;

  opacity: 0;
  pointer-events: none;

  position: absolute;
  inset-block-end: 80%;
  inset-inline-start: -1.5ch;
  
  list-style-type: none;
  background: var(--popupbg);
  color: var(--theme-text);
  padding-inline: 0;
  padding-block: .5ch;
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  font-size: .9em;
  transition: opacity var(--out-speed) ease;

  box-shadow:
    0 -2px 5px 0 hsl(var(--shadow) / calc(var(--shadow-strength) + 5%)),
    0 1px 1px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 10%)),
    0 2px 2px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 12%)),
    0 5px 5px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 13%)),
    0 9px 9px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 14%)),
    0 16px 16px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 20%))
  ;

  /* fixes iOS trying to be helpful */
  &:focus {outline: none}

  @media (--dark) {
    --shadow-strength: 5%;
    --shadow: 220 3% 2%;

    & button:not(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--ontheme);
    }

    & button:not(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--ontheme));
    }
  }

  @media (width ) {
    inset-inline-start: -200%;
  }

  & svg {
    fill: var(--popupbg);
    stroke: var(--theme);

    @media (prefers-color-scheme: dark) {
      stroke: var(--theme-border);
    }
  }

  & button {
    color: var(--theme-text);
    width: 100%;
  }
}

Animations #

Supporting graphic which has a ball in motion down a curve.

View the animation patterns landing page or check each out individually:

  1. Animated Letters
  2. Animated Words
  3. Interactive Letters
  4. Interactive Words

Here’s a preview of the animated letters pattern:

h1 split-by="letter" letter-animation="breath">
  hover-me letters
&LT/h1>

const span = (text, index) => {
  const node = document.createElement('span')

  node.textContent = text
  node.style.setProperty('--index', index)
  
  return node
}

const byLetter = text =>
  [...text].map(span)

const {matches:motionOK} = window.matchMedia(
  '(prefers-reduced-motion: no-preference)'
)

if (motionOK) {
  const splitTargets = document.querySelectorAll('[split-by]')

  splitTargets.forEach(node => {
    let nodes = byLetter(node.innerText)

    if (nodes)
      node.firstChild.replaceWith(...nodes)
  })
}

@keyframes breath {
  from {
    animation-timing-function: ease-out;
  }

  to {
    transform: scale(1.25) translateY(-5px) perspective(1px);
    text-shadow: 0 0 40px var(--glow-color);
    animation-timing-function: ease-in-out;
  }
}

@media (prefers-reduced-motion:no-preference) {
  [letter-animation] > span {
    display: inline-block;
    white-space: break-spaces;
  }

  [letter-animation=breath] {
    --glow-color: white;
  }

  [letter-animation=breath]>span {
    animation: breath 1.2s ease calc(var(--index) * 100 * 1ms) infinite alternate;
  }
}

@media (prefers-reduced-motion:no-preference) and (prefers-color-scheme: light) {
  [letter-animation=breath] {
    --glow-color: black;
  }
}

Theming #

Supporting graphic with two layers of a dashboard, one is pink and the other is blue.

View the theming patterns landing page or check each out individually:

  1. Color Schemes
  2. Theme Switch

One pattern is for building a client side theme switch so users can indicate their preference without it being directly tied to their system preference. The other is for creating a theming design system with CSS custom properties.

Here’s a preview of the color schemes pattern:

header>
  h3>Scheme&LT/h3>
  form id="theme-switcher">
    div>
      input checked type="radio" id="auto" name="theme" value="auto">
      label for="auto">Auto&LT/label>
    &LT/div>
    div>
      input type="radio" id="light" name="theme" value="light">
      label for="light">Light&LT/label>
    &LT/div>
    div>
      input type="radio" id="dark" name="theme" value="dark">
      label for="dark">Dark&LT/label>
    &LT/div>
    div>
      input type="radio" id="dim" name="theme" value="dim">
      label for="dim">Dim&LT/label>
    &LT/div>
  &LT/form>
&LT/header>

main>
  section>
    div class="surface-samples">
      div class="surface1 rad-shadow">1&LT/div>
      div class="surface2 rad-shadow">2&LT/div>
      div class="surface3 rad-shadow">3&LT/div>
      div class="surface4 rad-shadow">4&LT/div>
    &LT/div>
  &LT/section>

  section>
    div class="text-samples">
      h1 class="text1">
        span class="swatch brand rad-shadow">&LT/span>
        Brand
      &LT/h1>
      h1 class="text1">
        span class="swatch text1 rad-shadow">&LT/span>
        Text Color 1
      &LT/h1>
      h1 class="text2">
        span class="swatch text2 rad-shadow">&LT/span>
        Text Color 2
      &LT/h1>
      br>
      p class="text1">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.&LT/p>
      p class="text2">Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.&LT/p>
    &LT/div>
  &LT/section>
&LT/main>

const switcher = document.querySelector('#theme-switcher')
const doc = document.firstElementChild

switcher.addEventListener('input', e =>
  setTheme(e.target.value))

const setTheme = theme =>
  doc.setAttribute('color-scheme', theme)

* {
  /* brand foundation */
  --brand-hue: 200;
  --brand-saturation: 100%;
  --brand-lightness: 50%;

  /* light */
  --brand-light: hsl(var(--brand-hue) var(--brand-saturation) var(--brand-lightness));
  --text1-light: hsl(var(--brand-hue) var(--brand-saturation) 10%);
  --text2-light: hsl(var(--brand-hue) 30% 30%);
  --surface1-light: hsl(var(--brand-hue) 25% 90%);
  --surface2-light: hsl(var(--brand-hue) 20% 99%);
  --surface3-light: hsl(var(--brand-hue) 20% 92%);
  --surface4-light: hsl(var(--brand-hue) 20% 85%);
  --surface-shadow-light: var(--brand-hue) 10% 20%;
  --shadow-strength-light: .02;

  /* dark */
  --brand-dark: hsl(
    var(--brand-hue)
    calc(var(--brand-saturation) / 2)
    calc(var(--brand-lightness) / 1.5)
  );
  --text1-dark: hsl(var(--brand-hue) 15% 85%);
  --text2-dark: hsl(var(--brand-hue) 5% 65%);
  --surface1-dark: hsl(var(--brand-hue) 10% 10%);
  --surface2-dark: hsl(var(--brand-hue) 10% 15%);
  --surface3-dark: hsl(var(--brand-hue) 5%  20%);
  --surface4-dark: hsl(var(--brand-hue) 5% 25%);
  --surface-shadow-dark: var(--brand-hue) 50% 3%;
  --shadow-strength-dark: .8;

  /* dim */
  --brand-dim: hsl(
    var(--brand-hue)
    calc(var(--brand-saturation) / 1.25)
    calc(var(--brand-lightness) / 1.25)
  );
  --text1-dim: hsl(var(--brand-hue) 15% 75%);
  --text2-dim: hsl(var(--brand-hue) 10% 61%);
  --surface1-dim: hsl(var(--brand-hue) 10% 20%);
  --surface2-dim: hsl(var(--brand-hue) 10% 25%);
  --surface3-dim: hsl(var(--brand-hue) 5%  30%);
  --surface4-dim: hsl(var(--brand-hue) 5% 35%);
  --surface-shadow-dim: var(--brand-hue) 30% 13%;
  --shadow-strength-dim: .2;
}

:root {
  color-scheme: light;

  /* set defaults */
  --brand: var(--brand-light);
  --text1: var(--text1-light);
  --text2: var(--text2-light);
  --surface1: var(--surface1-light);
  --surface2: var(--surface2-light);
  --surface3: var(--surface3-light);
  --surface4: var(--surface4-light);
  --surface-shadow: var(--surface-shadow-light);
  --shadow-strength: var(--shadow-strength-light);
}

@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;

    --brand: var(--brand-dark);
    --text1: var(--text1-dark);
    --text2: var(--text2-dark);
    --surface1: var(--surface1-dark);
    --surface2: var(--surface2-dark);
    --surface3: var(--surface3-dark);
    --surface4: var(--surface4-dark);
    --surface-shadow: var(--surface-shadow-dark);
    --shadow-strength: var(--shadow-strength-dark);
  }
}

[color-scheme="light"] {
  color-scheme: light;

  --brand: var(--brand-light);
  --text1: var(--text1-light);
  --text2: var(--text2-light);
  --surface1: var(--surface1-light);
  --surface2: var(--surface2-light);
  --surface3: var(--surface3-light);
  --surface4: var(--surface4-light);
  --surface-shadow: var(--surface-shadow-light);
  --shadow-strength: var(--shadow-strength-light);
}

[color-scheme="dark"] {
  color-scheme: dark;

  --brand: var(--brand-dark);
  --text1: var(--text1-dark);
  --text2: var(--text2-dark);
  --surface1: var(--surface1-dark);
  --surface2: var(--surface2-dark);
  --surface3: var(--surface3-dark);
  --surface4: var(--surface4-dark);
  --surface-shadow: var(--surface-shadow-dark);
  --shadow-strength: var(--shadow-strength-dark);
}

[color-scheme="dim"] {
  color-scheme: dark;

  --brand: var(--brand-dim);
  --text1: var(--text1-dim);
  --text2: var(--text2-dim);
  --surface1: var(--surface1-dim);
  --surface2: var(--surface2-dim);
  --surface3: var(--surface3-dim);
  --surface4: var(--surface4-dim);
  --surface-shadow: var(--surface-shadow-dim);
  --shadow-strength: var(--shadow-strength-dim);
}

/* READY TO USE! */
.brand {
  color: var(--brand);
  background-color: var(--brand);
}

.surface1 {
  background-color: var(--surface1);
  color: var(--text2);
}

.surface2 {
  background-color: var(--surface2);
  color: var(--text2);
}

.surface3 {
  background-color: var(--surface3);
  color: var(--text1);
}

.surface4 {
  background-color: var(--surface4);
  color: var(--text1);
}

.text1 {
  color: var(--text1);
}

p.text1 {
  font-weight: 200;
}

.text2 {
  color: var(--text2);
}

New layout patterns on centering #

View the layout patterns landing page or check each out individually:

  1. Autobot
  2. Content Center
  3. Fluffy Center
  4. Gentle Flex
  5. Pop n’ Plop

Each demo features a grab handle to resize the container and a button to add a child to the layout. These, as explained in the article, are to help you feel the strengths and weaknesses of various centering techniques the web offers. Plus, they have fun names.

Here is the article determined “winner” of the centering exploration, the Gentle Flex:

article class="gentle-flex">
  h1>Gentle Flex&LT/h1>
&LT/article>

.gentle-flex {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1ch;
}

Wrap up #

I hope these new patterns help teach you new techniques, inspire you, provide insights into accessibility and overall keep you hyped on building UI. Stay tuned for more patterns as the Chrome Team continues adding to these collections.

Last updated: Improve article
RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments