<![CDATA[The Craft of UI]]>https://craftofui.substack.comhttps://substackcdn.com/image/fetch/$s_!uCWy!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F0f2025fe-eabd-45bf-a0e0-0545aa8a88cc_404x404.pngThe Craft of UIhttps://craftofui.substack.comSubstackSat, 21 Feb 2026 20:53:50 GMT<![CDATA[Time Travel with JavaScript]]>https://craftofui.substack.com/p/time-travel-with-javascripthttps://craftofui.substack.com/p/time-travel-with-javascriptFri, 11 Apr 2025 23:01:03 GMT
Jhey's laptop showing the digital split flap display

In today's issue, I want to show you how to time travel — with JavaScript.

OK, it might not be "Doc Brown" levels of time travel.

But it's a powerful animation trick that feels like magic when the opportunity arises to use it.

We're gonna build something like this (a Vestaboard?). I've wanted to make one of these for some time. It was finally inspired by a colleague sharing this font site.

But before that, some context. I went down the rabbit hole to understand this technique back in 2021. The goal then was to create an infinite scrolling carousel without duplicating elements.

You can apply this technique to various use cases. All whilst avoiding DOM tricks like duplicating and moving elements around.

That's often one technique you will see when people create an infinite marquee/carousel effect (I should make more YouTube content). They duplicate the first and last element or the entire list. Then they position it so that it looks seamless as the translation happens.

But often you can avoid that altogether with today's technique.

CREATING A VESTABOARD

We’re going to create a split-flap display for this issue. A style popularized recently under the name “Vestaboard”. The trick to building something like this is to break it down into smaller tasks. You can start by creating a single flap, providing a foundation for the mechanics to follow.

A single styled split-flap showing the character "A"
How you might style up a single split flap

How about the markup? You can use one container element and give it four child elements. Two children represent the current value, and the others represent the next value.

<div class="flip">
  <!-- unfold top -->
  <div>B</div>
  <!-- unfold bottom -->
  <div>B</div>
  <!-- fold top -->
  <div>A</div>
  <!-- fold bottom -->
  <div>A</div>
</div>

Using clip-path, you can clip the children into the right shape. A small trick here is to use pseudo-elements of the container for the axle ends.

.flip div:nth-of-type(odd) {
  clip-path: polygon(
    0 0,
    100% 0,
    100% 40%,
    calc(90% + 0.025em) 40%,
    calc(90% + 0.025em) 48%,
    calc(10% - 0.025em) 48%,
    calc(10% - 0.025em) 40%,
    0 40%
  );
}

.flip div:nth-of-type(even) {
  clip-path: polygon(
    0 60%,
    calc(10% - 0.025em) 60%,
    calc(10% - 0.025em) 52%,
    calc(90% + 0.025em) 52%,
    calc(90% + 0.025em) 60%,
    100% 60%,
    100% 100%,
    0 100%
  );
}

Once you have all the pieces, you can determine how they will move. One way to think about it is that the current value will fold, and the new value will unfold. And that means you only need to move two of the four elements. You will rotate the bottom of the new value and the top of the current value on the x-axis.

@layer flip {
  /* map 0-180 against a brightness range */
  /* based on a --flipped value of 0-1 */
  .flip div:nth-of-type(1),
  .flip div:nth-of-type(2) {
    /* 0-90 will be 0-1 */
    filter: brightness(calc(1 / 90 * clamp(0, var(--flipped), 90)));
  }
  .flip div:nth-of-type(3),
  .flip div:nth-of-type(4) {
    /* 90-180 will be 1-0 */
    filter: brightness(calc(1 - (1 / 90 * clamp(0, var(--flipped) - 90, 180))));
  }
  .flip div:nth-of-type(2) {
    rotate: x calc(-180deg + (var(--flipped) * -1deg));
    z-index: 2;
  }
  .flip div:nth-of-type(3) {
    backface-visibility: hidden;
    rotate: x calc(var(--flipped) * -1deg);
    z-index: 3;
  }
}

Throw in some perspective and dynamic brightness, you can get something like this:

Mechanics

It's time to add JavaScript to the mix. You have a few options for JavaScript animation. We will use GSAP for today's technique because of its API options. It also makes creating the breakdowns easier 😅. There are alternatives out there, but I didn’t have as much luck with them. And of course, you could also use WAAPI with a different approach (included later). The nice thing about GSAP is that you can get a lot from a small amount of code.

Onto the animation. Let's start by updating our flap from "A" to "B" to "C".

const [foldTop, foldBottom, unfoldTop, unfoldBottom] = Array.from(
  flip.querySelectorAll('.flip > div')
)
await gsap
  .timeline()
  .fromTo(
    unfoldBottom,
    { rotateX: 180 },
    { rotateX: 0 },
    0
  )
  .fromTo(
    foldTop,
    { rotateX: 0 },
    { rotateX: -180 },
    0
  )
// confirm the letters
gsap.set([unfoldTop, unfold], { innerText: 'B' })
gsap.set([foldBottom, fold], { innerText: 'C' })
// repeat the steps above to go from "B" to "C"

Here we simultaneously animate rotation on the x-axis for two parts. This would be a very verbose way of animating from letter to letter. This is one way of approaching things with WAAPI in a while loop.

With GSAP though, you have some powerful API features to tidy this up. Using onRepeat makes this much cleaner.

gsap
  .timeline({
    repeat: 1,
    delay: 0.4,
    onRepeat: () => {
      gsap.set([unfoldTop, unfold], { innerText: 'C' })
      gsap.set([foldBottom, fold], { innerText: 'B' })
    },
  })
  .fromTo(
    unfold,
    { rotateX: 180 },
    { rotateX: 0, duration: 1, ease: 'none' },
    0
  )
  .fromTo(
    fold,
    { rotateX: 0 },
    { rotateX: -180, duration: 1, ease: 'none' },
    0
  )

That's a starting point, but how do we scale it? We need it to loop whatever characters we pass to it. All this takes is a minor tweak to our timeline logic.

const chars = Array.from(" SHIP_IT ")
gsap
  .timeline({
    paused: true,
    repeat: chars.length - 2,
    onRepeat: () => {
      const index = Math.floor(timeline.totalTime() /
        timeline.duration())
      const next = chars[index]
      const current = chars[(index + 1) % chars.length]
      gsap.set([unfoldTop, unfold], { innerText: current })
      gsap.set([foldBottom, fold], { innerText: next })
    },
  })

And now we can loop over whatever characters we want. Padding the character list with a space provides a way to return to a blank flap. Using Array.from() lets us use emojis in the character string.

Now you have a generated timeline you can control. This is the "magic" (meta) part. The GSAP API allows you to set the time for a given timeline. But, you can also animate that time value.

const timeline = gsap.timeline... /* character timeline from above */
gsap.to(timeline, {
  duration: 1,
  totalTime: desiredTime, /* desired character index */
  ease: 'power1.out',
})

To break down what's happening, consider this video. It shows both scrubbing and animating the timeline.

You may have noticed in the character timeline that the ease is set to “none” and the timeline duration is 1. A linear animation gives us better control of the effect when we animate the time.

The reality is that we aren't ever going to animate the timeline in reverse. It will only go forward. So, how do you animate beyond the duration of the timeline? Create a meta timeline and give it infinite iterations. We can use the modulo (%) operator to work out the correct character to show.

const duration = timeline.totalDuration()
const scrubber = gsap.to(timeline, {
  totalTime: duration,
  repeat: -1,
  paused: true,
  duration: duration,
  ease: 'none',
})

Imagine we are animating through the characters of the alphabet. We are currently on "J" but want to get to "A". Instead of animating the time backward, we work out the time required to go forward past "Z" and back around to "A". When we select a character, we calculate the time to scrub forward by and animate that. With an eye on staggering a line of flips, we can pad out the time shift with extra loops.

const chars = Array.from(` abcdefghijklmnopqrstuvwxyz `)
const flipTo = (desired) => {
  const currentIndex = chars.indexOf(chars[timeline.totalTime()])
  const desiredIndex = chars.indexOf(desired)
  // if the current index is greater, loop around
  const shift =
    currentIndex > desiredIndex
      ? chars.length - 1 - currentIndex + desiredIndex
      : desiredIndex - currentIndex
  // this is how you throw an extra loop in for the stagger
  const pad =
    currentIndex === desiredIndex ? 0 : config.pad * (chars.length - 1)
  /* animate the time position of the scrubber */
  gsap.to(scrubber, {
    totalTime: `+=${shift + pad}`,
    ease: 'power1.out',
    duration: (shift + pad) * gsap.utils.random(0.2, 0.6),
  })
}

Now we have a working slot on the board!

That's the trick to creating this split-flap display. You scrub the playhead of an animation that loops through the characters you want to use. And to make a board, you combine a bunch of slots. It's up to you how you go about this. The demos linked below use JavaScript classes to tidy things up. But you could create web components and expose parts for styling. Would you like me to make a web component and put it on GitHub? Let me know! I didn't want to get bogged down in API shape and other things here. But here's an example of using it.

const newSlot = new FlipSlot({
  pad: 1,
  characters: "abcdefghijklmnopqrstuvwyz",
  color: "red",
})
newSlot.flip("J")

And here's a video playing with it.

Now I did mention WAAPI earlier. If you wanted to create a dependency-free version, you could. Instead of scrubbing an animation, you can repeat Element.animate() inside a loop. The idea is that you repeat until you get the character you want. It isn't much code at all to get this working. The caveat is that you must write easing functions and extras to handle what GSAP does for you. For what it's worth, I love the control GSAP gives you to ease the time. I'd need more time to play with the WAAPI output.

/* repeat this inside a loop */
/* index tracks the current unclipped index */
/* run is the number of loops handled */
this.currentValue = chars[this.index % chars.length]
this.nextValue = chars[(this.index + 1) % chars.length]
unfoldTop.innerText = unfoldBottom.innerText = this.nextValue
foldTop.innerText = foldBottom.innerText = this.currentValue
await Promise.all([
  unfoldTop.animate(
    {
      filter: ['brightness(0)', 'brightness(1)'],
    },
    {
      duration,
      delay: run === 0 ? delay : 0,
     }
  ).finished,
  unfoldBottom.animate(
    {
      rotate: ['x 180deg', 'x 0deg'],
    },
    {
      duration,
      delay: run === 0 ? delay : 0,
     }
  ).finished,
  foldTop.animate(
    {
      rotate: ['x 0deg', 'x -180deg'],
    },
    {
      duration,
      delay: run === 0 ? delay : 0,
    }
  ).finished,
  foldBottom.animate(
    {
      filter: ['brightness(1)', 'brightness(0)'],
    },
    {
      duration,
      delay: run === 0 ? delay : 0,
    }
  ).finished,
])
this.index++
run++

But you get a similar result! (I still want to spend some time with this as I have a hunch we can do it a little better)


That’s a wrap for now—a fun little challenge to tinker with!

Also, a quick note: thanks for your patience. It’s taken me longer than I’d like to get this issue out. Between work, life, and everything in between, finding the time has been tricky. That said, I’ve also been working towards something exciting—a course for Craft of UI! It’ll be a home for all the demos, walkthroughs, and deeper dives. If that sounds like your kind of thing, feel free to join the waitlist at course.craftofui.com.

Demo link (GSAP): codepen.io/jh3y
Demo link (WAAPI): codepen.io/jh3y
On X: x.com/jh3yy
On BlueSky: bsky.app/jhey.dev

Stay awesome! ┬┴┬┴┤•ᴥ•ʔ├┬┴┬┴


THIS WEEK’S SPONSOR (THERE ISN’T ONE)

We don’t have one. But, if you want to buy a book. Buy this one from my wife, “Design for Developers: Master the Basics

Learn essential design skills to elevate your code! Design for Developers simplifies design fundamentals for devs. Create beautiful, user-friendly interfaces with confidence.

Amazon Link - Manning Link


The Craft of UI is a reader-supported publication. To receive new posts and support my work, consider becoming a subscriber.

]]>
<![CDATA[You can scroll things]]>https://craftofui.substack.com/p/you-can-scroll-thingshttps://craftofui.substack.com/p/you-can-scroll-thingsFri, 10 Jan 2025 23:24:56 GMT

Happy New Year! Hope you've had a great holiday season. Many people shared 2024 highlight reels recently so it felt fitting to produce my own.

From that video, someone asked about a particular word scroller demo first seen on the Forge and Form site (scroll down to the bottom). So for today's issue, let's break it down as we ease ourselves into 2025.

So, how are you going to make it?

Markup

Like with most demos, we need to start with some HTML. It's good to think about how the content will get communicated to a screen reader user. We'll go with a section using a heading and a list. You could also play with hiding the feature from screen reader users. And then providing alternative text.

<section class="content fluid">
  <h2>you can&nbsp;</h2>
  <ul style="--count: 22">
    <li style="--i: 0">design.</li>
    <li style="--i: 1">prototype.</li>
    <li style="--i: 2">solve.</li>
    <li style="--i: 3">build.</li>
    <li style="--i: 4">develop.</li>
    <li style="--i: 5">debug.</li>
    <li style="--i: 6">learn.</li>
    <li style="--i: 7">cook.</li>
    <li style="--i: 8">ship.</li>
    <li style="--i: 9">prompt.</li>
    <li style="--i: 10">collaborate.</li>
    <!-- as many items as you like -->    
  </ul>
</section>

One trick we're using here is using an inline style to track each list item index (—-i). This is so we can color the items "lazily". It's the easiest way until we have sibling-count/index.

Basic Styles

Before animating things, we need some foundations. When building out these interactions, think about the edge cases first. For this demo, what happens if the user views on a smaller device? In the original, they switch up the layout to vertical producing a different effect. Using a responsive font in our version means we keep the effect the same whatever the screen size. Let me know if you want me to do an issue covering responsive typography implementations.

The main trick for our animation is combining position: sticky and scroll-snap. You use sticky positioning on the section header and scroll the list past it.

section:first-of-type h2 {
  position: sticky;
  top: calc(50% - 0.5lh); /* tap into responsive line height unit */
}
html {
  scroll-snap-type: y proximity;

  li {
    scroll-snap-align: center;
  }
}

Using scroll-snap snaps each item in place and will snap the animation so you don't land on half states. You've likely seen the mandatory value used more often for scroll-snap. But here we use the proximity value. You only want to snap to an item if there's one nearby. Otherwise, your users should be able to scroll as normal. Using mandatory would be too aggressive here depending on the rest of the page content.

Remember grabbing the index of each list item? We'll use that to color each one. Use the oklch color space because it tends to "make it pop".

ul {
  --step: calc(
    (var(--end, 359) - var(--start, 0)) / (var(--count) - 1)
  );
}
li:not(:last-of-type) {
  color: oklch(
    65% 0.3 calc(var(--start) + (var(--step) * var(--i)))
  );
}

The neat part here? We can set a range and have CSS work out the color steps. All done via CSS custom properties.

The Animation

Before you even begin to animate this, you have something that gives some visual interest. We discussed a few issues ago about the progressive enhancement of scroll animation. It's the same for today's issue. We'll do a CSS scroll animation and then walk through how to do it with JavaScript too.

CSS

The CSS animation here is straightforward using a view timeline on each list item.

li {
  animation-name: brighten;
  animation-fill-mode: both;
  animation-timing-function: linear;
  animation-range: cover calc(50% - 1lh) calc(50% + 1lh);
  animation-timeline: view();
}

The @keyframes reduce the opacity of each item at the start and end. They raise the opacity and brighten each list item as it hits the middle of the viewport.

@keyframes brighten {
  0%, 100% { opacity: 0.2; }
  50% {
    opacity: 1;
    filter: brightness(1.2);
  }
}

The first and last list items have a different behavior. The first only reduces opacity and the last only raises opacity. The last one also doesn't brighten. To cater to this, restructure our keyframes and use scoped custom properties. This prevents you from creating 3 different keyframe sets.

li {
  &:first-of-type { --start-opacity: 1; }
  &:last-of-type {
    --brightness: 1;
    --end-opacity: 1;
  }
}
@keyframes brighten {
  0% { opacity: var(--start-opacity, 0.2); }
  50% {
    opacity: 1;
    filter: brightness(var(--brightness, 1.2));
  }
  100% { opacity: var(--end-opacity, 0.2); }
}

Neat.

One bonus here is that you can debug your CSS scroll animations in Chrome's DevTools. Scroll animations have a mouse icon in the Animations panel.

JavaScript

The JavaScript version is a little more involved. You can use GreenSock's ScrollTrigger and tether the scroll to a timeline animation.

import gsap from "gsap"
import ScrollTrigger from "gsap/ScrollTrigger"

if (
  !CSS.supports('(animation-timeline: scroll()) and (animation-range: 0% 100%)')
) {
  gsap.registerPlugin(ScrollTrigger)

  const items = gsap.utils.toArray('ul li')
  gsap.set(items, { opacity: (i) => (i !== 0 ? 0.2 : 1) })

  const dimmer = gsap
    .timeline()
    .to(items.slice(1), {
      opacity: 1,
      stagger: 0.5,
    })
    .to(
      items.slice(0, items.length - 1),
      {
        opacity: 0.2,
        stagger: 0.5,
      },
      0
    )

  ScrollTrigger.create({
    trigger: items[0],
    endTrigger: items[items.length - 1],
    start: 'center center',
    end: 'center center',
    animation: dimmer,
    scrub: 0.2,
  })
}

Shout out to Carl for sharing his take on this solution. It's good to see how others approach this as you can achieve it in many ways. An alternative would be to loop over the items and create a timeline for each using each item as the trigger.

Bonus

In the spirit of progressive enhancement let's add an extra touch. Something that gives it that little level of detail that takes it further.

The scrollbar-color CSS property is not completely available yet. We're awaiting Safari support. But, that doesn't mean we couldn't add something you only see in Chrome and Firefox (for now). Syncing the scrollbar color to the current item color crossed my mind early on. It's a neat challenge to solve.

html {
  scrollbar-color: oklch(65% 0.3 var(--hue)) #0000;
}

You can animate a CSS custom property on the document. And then use the value inside scrollbar-color. The neat thing is that you can tap into the hue range properties so the animation is dynamic.

@property --hue {
  initial-value: 0;
  syntax: '<number>';
  inherits: false;
}

html {
  timeline-scope: --list;
  scrollbar-color: oklch(65% 0.3 var(--hue)) #0000;
  animation-name: change;
  animation-fill-mode: both;
  animation-timing-function: linear;
  animation-range: entry 50% exit 50%;
  animation-timeline: --list;

  ul { view-timeline: --list; }
}

@keyframes change { to { --hue: var(--end); }}

You'll get a neat little progressive enhancement from this. You can also use GreenSock to make it work with JavaScript too.

In the demo, you'll notice that the scrollbar starts desaturated. For that, use the same technique but animate the "chroma" value for the scrollbar-color.


That's it! It's a neat interaction effect that doesn't need much code. And it’s a nice short way to ease ourselves into 2025. Should we break down some more demos from the "Highlight" reel in the coming weeks? Do you prefer short-form issues? Or longer ones?

Demo link: codepen.io/jh3y
On X: x.com/jh3yy
On BlueSky: bsky.app/jhey.dev

Stay awesome! ┬┴┬┴┤•ᴥ•ʔ├┬┴┬┴


THIS WEEK’S SPONSOR (AGAIN)

Is my wife, with her book “Design for Developers: Master the Basics

Learn essential design skills to elevate your code! Design for Developers simplifies design fundamentals for devs. Create beautiful, user-friendly interfaces with confidence.

Amazon Link - Manning Link


Before you go, thank you for all the feedback on the issues. My plan for 2025 is to keep going with an eye on making a site/course if that’s still of interest. We will likely start with a course titled “Foundations” around CSS animation.

As for the newsletter. One issue a month for free subscribers and many for paid. The price is also lowered to the lowest rate possible. It works out much cheaper to do an annual subscription. The lowest monthly Substack allows is $5.

There have also been requests for a Discord server. I’m not 100% sure where to start with this.

Let me know what you think ʕ·ᴥ· ʔ

Leave a comment


The Craft of UI is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.

]]>
<![CDATA[Muddling your words]]>https://craftofui.substack.com/p/muddling-your-wordshttps://craftofui.substack.com/p/muddling-your-wordsThu, 12 Dec 2024 22:15:12 GMT

Let's revisit a classic effect in today's issue, text scrambling.

Text scrambling effects were doing the rounds a few weeks back. It reminded me of this scramble demo and a personal site put together in '20. (The deployed Storybook for that site still lives on and works.)

Figured they were worth revisiting and came away with some different approaches. The people over at GreenSock shared one of the results in their newsletter. So instead of another scroll animation, let's look at text scrambling this issue.

So how are you going to make this?

Markup

First, you need some markup. Now with any text effect like this, you need to be mindful of how it's presented to your users. Real-time text scrambling could sound like a nightmare to screen reader users. So before you do anything, make sure your content is accessible.

<a href="#" aria-label="Scramble Me">Scramble Me</a>

Note that how you do this also differs based on the elements you're working with. For interactive elements, you can use aria-label. For non-interactive elements, use the "sr-only" trick. That’s where you hide the element but make it accessible to assistive tech.

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

JavaScript

A JavaScript solution to this is pretty straightforward.

Given a string of text:

  • Scramble characters to random characters from a given set using some interval

  • Unscramble characters in a staggered effect revealing original characters

Here’s a quick rudimentary take on a function that could do this.

const scramble = (el) => {
  if (!el.matches('[aria-label]'))
    return console.error('set aria-label')
  const stagger = 25
  let duration
  let startTime
  return function scrambleText() {
    if (!startTime) {
      anchor.dataset.scrambling = true
      duration = el.innerText.length * 2
      startTime = Date.now()
    }
    const timeElapsed = Date.now() - startTime
    const actionTime = duration - el.innerText.length * stagger
    const index = Math.max(
      0, Math.floor((timeElapsed - actionTime) / stagger)
    )
    el.innerText = `${el
      .getAttribute('aria-label')
      .slice(0, index)}${randomString(el.innerText.length - index)}`
    if (Date.now() - startTime <= duration) {
      requestAnimationFrame(scrambleText)
    } else {
      startTime = undefined
      anchor.dataset.scrambling = false
    }
  }
}

You could adjust this to your liking, but let's break down what's happening there.

  • It's a function that returns a function given some element. You invoke the returned function to kick off the scramble. You could store this or adjust it as you like, put it in a hook, a class, etc.

    scramble(element)()
  • The stagger is a hardcoded stagger for the reveals in milliseconds. The duration and startTime variables become references for the animation loop.

  • If the element doesn't have an aria-label set, return an error or a warning.

      if (!el.matches('[aria-label]'))
        return console.error('set aria-label')
  • The scrambleText function is what you return. This will run in each requested animation frame with requestAnimationFrame. You could store this for reuse.

  • It starts by setting a startTime if there isn't one. It also calculates a duration to use and sets data-scrambling on the element. The data attribute is a convenient way to check if the scramble is running.

  • The scrambling part of the function first calculates the time elapsed. Then it calculates what we're calling actionTime. This is how far into the window of unscrambling we are. The index tells you how many characters should no longer scramble.

    const timeElapsed = Date.now() - startTime
    const actionTime = duration - el.innerText.length * stagger
    const index = Math.max(
      0, Math.floor((timeElapsed - actionTime) / stagger)
    )
  • Then update the text content of the element. This is a slice of the aria-label value using the index and a string of random characters to fill the text. randomString returns a random character string of a given length.

    const defaultChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    const randomString = (length, chars = defaultChars) => {
      return [...Array(length)]
        .map(() => chars[Math.floor(Math.random() * chars.length)])
        .join('')
    }
    
    // Setting the content
    el.innerText = `${el
      .getAttribute('aria-label')
      .slice(0, index)}${randomString(el.innerText.length - index)}`
    
  • The last piece is to determine whether we need another frame or if we can tear things down

    if (Date.now() - startTime <= duration) {
      requestAnimationFrame(scrambleText)
    } else {
      startTime = undefined
      anchor.dataset.scrambling = false
    }

That's a few lines of code to get the effect. You could take things further with easing functions for the stagger, etc.

To use this with our element, check for user preferences and attach to pointerenter and focus.

const scrambleText = scramble(element)
const handleScramble = () => {
  if (element.dataset.scrambling !== 'true') scrambleText()
}
if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
  anchor.addEventListener('pointerenter', handleScramble)
  anchor.addEventListener('focus', handleScramble)
}

And this gives you something like:

An alternative method could be to use GSAP and their ScrambleText plugin.

const defaultChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
gsap.to(element, {
  duration: element.innerText.length * 0.05,
  ease: 'sine.inOut',
  scrambleText: {
    text: element.innerText,
    speed: 4,
    chars: defaultChars,
  }
)

Mindful that you're pulling a dependency in here but it's easier to play with easing changing the one line.

CSS

Now for some CSS solutions. These have some caveats.

  • Requires more markup preparation

  • Scrambling gets triggered by :hover/:focus-visible. Quick hovering on and off cuts the effect off before it can finish. This may be fine based on timings, etc.

  • Randomness. The JavaScript solutions yield random scrambles on every scramble.

The performance gain of using a CSS solution is almost negligible. CSS solutions do gain from fewer blocks of client-side script though.

Using Pseudo-elements

Our first technique relies on animating the content of an ::after pseudo-element. Combine this with the trick of leveraging custom property scope in your @keyframes.

For your markup, the trick is to split the text into spans for each character. Then for each character:

  • Set a data attribute for the original character: [data-char=””]

  • Set a CSS custom property for the character index: —-character-index: 0;

  • Set CSS custom properties for the random characters to scramble through

It's up to you how you generate this markup. Today's demo leverages Splitting.js for quickness. It's a micro-library for splitting text up into elements. You can pass it some text and get back generated markup. The demo uses that and then loops over the characters to add the custom properties.

element.innerHTML = Splitting.html({
  content: config.text,
  whitespace: true,
})
const characters = element.querySelectorAll('[data-char]')
for (const character of characters) {
  character.style.setProperty(
    '--char-1',
    `"${defaultChars[Math.floor(Math.random() * defaultChars.length)]}"`
  )
  // other characters here...
}

Yielding something like this:

<a href="#" aria-label="Scramble Me">
  <span class="words chars splitting"
    style="--word-total: 2; --char-total: 11;">
    <span class="word" data-word="Scramble" style="--word-index: 0;">       
      <span class="char" data-char="S"
        style="
          --char-index: 0;
          --char-1: 'E'
          --char-2: 'o';
          --char-3: '2';">
        S
      </span>
      <span class="char" data-char="c"
        style="
          --char-index: 1;
          --char-1: 'l';
          --char-2: 'g';
          --char-3: 'p';">
        c
      </span>
      <!-- other characters -->
    </span>
  </span>
</a>

Side note:: When we get CSS sibling-count() and sibling-index() you won’t need the inline custom properties for indexes and totals.

Now for the styles. For these types of effects, a monospace font is usually best so you avoid layout shifts. Our pseudo-element is the visual text so you can hide the main element content using color. Then style the pseudo-element to overlay as if it were the character's content.

[data-char] {
  position: relative;
  color: #0000;
}
[data-char]::after {
  color: canvasText;
  content: attr(data-char);
  position: absolute;
  left: 0;
  display: inline-block;
}

To scramble the text, use @keyframes that flip the pseudo-element content. Each character gets a different result based on its inline custom properties. To create the stagger, use the character's index with a stagger duration.

@media (prefers-reduced-motion: no-preference) {
  a:is(:hover, :focus-visible) [data-char]::after {
    animation: scramble 0.24s calc(var(--char-index, 0) * 0.05s);
  }
}
@keyframes scramble {
  0%, 20% { content: '_'; }
  40% { content: var(--char-1); }
  60% { content: var(--char-2); }
  80% { content: var(--char-3); }
}

And that gives you something like this.

Using Tracks

This is the “performant” one (Remember, it’s negligible). The trick? Translate tracks of characters in the clipped window of the character element. This clip does a better job of putting it into words.

Adjust the markup so each character has an equal-length random set of characters. The only rule is that each set must start and end in the original character. For example, if you're scrambling "S". A string like "S0123ABCBS" will work.

<span class="char" data-char="S" style="--char-index: 0;">
  <span>SDCCQZ3JWNXS</span>
</span>

You're using a monospace font so set the character width and height. Then set the overflow to hidden.

[data-char] {
  width: 1ch;
  height: 1lh;
  overflow: hidden;
}

For the track, set word-break and white-space on the element to make it a vertical column. This makes it easier to debug and see what's happening. You could have things horizontal and translate on the x-axis and lose these lines.

[data-char] span {
  display: inline-block;
  white-space: break-spaces;
  word-break: break-word;
}

The last part is the transition. Yep, no @keyframes for this one. Translate the track along the y-axis with a steps timing. Calculate a nice delay using sin(). The --char-total and --steps properties are character total and characters per track.

[data-char] span {
  --duration: 0.24;
  --delay: calc(
    (
      sin((var(--char-index) / var(--char-total)) * 90deg) *
      (var(--duration) * 1)
    )
  );
}
a:is(:hover, :focus-visible) [data-char] span {
  transition: translate calc(var(--duration) * 1s)
    calc(0.1s + (var(--delay) * 1s)) steps(calc(var(--steps) + 1));
  translate: 0 calc(100% - 1lh);
}

Translate the track by calc(100% - 1lh) to produce the scramble effect. Note how you also only apply the transition when hovering or focussing. This is so it doesn't scramble back when you move away from the element. There’s also an intentional 0.1s added to the delay. This means we only trigger the scramble when :hover is intentional and not when passing over.


Extras

DevTools Addition

We mentioned performance above. And it's a negligible difference in how each solution performs. I profiled each solution for around 10 seconds triggering the scramble on repeat.

Screenshots from the results of running the Performance Profiler in Chromium DevTools

An interesting part though is a new addition to Chromium's DevTools profiling. It will now tell you if an animation doesn’t get composited.

Screenshot from Chromium DevTools showing compositing failed on an animation

Without going deep on this, you want to stick to composited animations where you can. These are those that only use compositor properties: the transform properties and opacity. They perform better. If you need to use others, test that things perform well and as expected.

Scroll Animation.

Would it be an issue without referencing scroll-driven animation? 💀

You could take that last solution and bind it to scroll. That's pretty cool. That gives you a performant composited animation running off the main thread.

More scroll animation in the next issue?


That's been text scrambling effects. Like many things in web development, there's more than one way. But that means more tricks and techniques to add to your craft.

Demo link: codepen.io/jh3y
On X: x.com/jh3yy
On BlueSky: bsky.app/jhey.dev

Stay awesome! ┬┴┬┴┤•ᴥ•ʔ├┬┴┬┴


THIS WEEK’S SPONSOR

Is my wife, with her book “Design for Developers: Master the Basics

Learn essential design skills to elevate your code! Design for Developers simplifies design fundamentals for devs. Create beautiful, user-friendly interfaces with confidence.

Amazon Link - Manning Link


Last thing before you go. I'm trying to work out what direction to take the newsletter in. It's a plan to create a site that accompanies it at some point and a course (do we still want that?).

But, I'd like to put in place some sort of reason for paid contributions.

One idea is reducing the cost to say $2 a month. That gives paid subscribers all the posts. Free subscribers will then get at most one post a month (I'm writing 2 a month with plans to up this with more short posts, etc.).

Let me know what you think ʕ·ᴥ· ʔ

Leave a comment


The Craft of UI is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.

]]>
<![CDATA[Scrolling a page out of the Playbook]]>https://craftofui.substack.com/p/scrolling-a-page-out-of-the-playbookhttps://craftofui.substack.com/p/scrolling-a-page-out-of-the-playbookThu, 28 Nov 2024 08:01:36 GMT

In the past two issues, we've referenced CSS scroll-driven animations. But we've yet to build a "typical" scroll-driven animation. For this issue, let's put one together.

This past week, eBay dropped some docs for their Evo design system over at playbook.ebay.com. Having a browse, some things jumped out aside from the bold style. There's some use of clamped fonts in the header and a physics box further down the page. What also caught the eye were some scroll-driven and scroll-triggered pieces. How could you make this?

Support options

CSS scroll-driven animations are currently only available in Chromium. Which poses the question "What about my non-Chromium users?". You can approach this in different ways.

  1. Progressive enhancement with CSS: Display your images as a grid. If scroll animation has support, do the animation.

  2. Progressive enhancement with JavaScript: Display your images as a grid. Use JavaScript to create the scroll animation. My preference is for using GreenSock's ScrollTrigger for this.

  3. Cover all bases: If you can do option 1, do it. Otherwise, try option 2.

The typical approach will be option 2 based on the current support. It depends on how critical that scroll animation is to your design. You can do a lot with option 1. Especially when it’s those “little details”.

In today’s demo, any animation is a “bonus”. If a user scrolled our page and didn't see it, they wouldn't know unless they were looking for it. That said, let's do option 3!

The Grid

First, you want to think about what elements you need to get this working. It’s a grid of images.

When you look at the animation you're trying to make, it's as if layers of the grid move at different rates. The best path here is to split the grid into layers and have each layer use the same grid layout. Then you can animate each layer with different timing.

<div class="grid">
  <!-- 6 images for outer edges -->
  <div class="layer">
    <img src="outer.avif" alt="" />
    ...
  </div>
  <!-- 6 images for images one column in -->
  <div class="layer">
    <img src="inner.avif" alt="" />
    ...
  </div>
  <!-- 2 images top and bottom of center column -->
  <div class="layer">
    <img src="center-top.avif" alt="" />
    <img src="center-bottom.avif" alt="" />
  </div>
  <!-- the center image that shrinks -->
  <div class="scaler">
    <img src="center.avif" alt="" />
  <div>
</div>

How about using CSS subgrid for this? You could use a subgrid to split the grid up in different ways. This provides an opportunity to create varied effects. In our example, sharing the grid layout might make working with that center image easier.

Start with a grid layout (You can adjust the columns for smaller screen sizes in a media query).

.grid {
  align-content: center;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-template-rows: repeat(3, 1fr);
  gap: 8%;
  width: 1600px;
  max-width: calc(100% - 4rem);
  margin: 0 auto;
}

Now for each layer, use subgrid. Using a subgrid lets each layer use the grid tracks from the parent. This snippet shows how the first layer sets out its images using :nth-of-type(odd/even) with grid-column.

.grid .layer {
  display: grid;
  /* cover it */
  grid-column: 1 / -1;
  grid-row: 1 / -1;
  /* use subgrid */
  grid-template-columns: subgrid;
  grid-template-rows: subgrid;
}
.grid .layer:nth-of-type(1) img:nth-of-type(odd) {
  grid-column: 1;
}
.grid .layer:nth-of-type(1) img:nth-of-type(even) {
  grid-column: -1;
}

The scaling image goes in the center. It’s a direct child so you can place it with grid-area.

.grid .scaler {
  grid-area: 2 / 3;
} 

Now you have a grid of images ready to animate!

Using the CSS Grid features in Chrome’s Developer Tools

NOTE:: You may have to make a few adjustments to get things working in all browsers with subgrid. For example, a percentage gap didn’t work for this demo in Safari. Switching to a clamped container query unit did work.

Laying Foundations

Scroll animations come in different shapes and sizes. There are a few common tricks. For this demo, you can use the "sticky spacer" trick. The gist is:

  1. Stick an element with what you want to animate using position: sticky

  2. Make the parent container larger so it creates space

  3. Animate based on the container position

This demo presented at Figma Config (hence the Figma logo) shows how the trick works.

If you want to see a clip of me talking through that demo, it’s here 👇 (do not feel obliged)

For our demo. Wrap the grid in a content block inside a section.

<section>
  <div class="content">
    <div class="grid">
     <!-- our grid content -->
    </div>
  </div>
</section>

Give the section some extra height and make our content block sticky.

section:first-of-type {
  min-height: 240vh;
}
.content {
  position: sticky;
  top: 0;
}

Now you can start animating things! One last thing before we move things. Be mindful of your users. Remember to check for two things in your CSS. Check for scroll animation support and that your users have no motion preferences.

@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: scroll()) and (animation-range: 0 100%){
  /* animation code */
}
}

Animating the Center

The animation for the center image is straightforward. Animate its height and width from 100vh and 100vw. An open-ended animation means the image will animate to its size within the grid.

You’re animating based on the parent, so set a view-timeline on that element.

main section:first-of-type {
  view-timeline: --driver;
}

Then use that in your animation.

.scaler img {
  animation-name: scale-x, scale-y;
  animation-fill-mode: both;
  animation-timing-function: var(--power-2), var(--power-1);
  animation-timeline: --driver;
  animation-range: entry 100% exit -20%;
}
@keyframes scale-x {
  0%, 10% { width: calc(100vw - 4rem); }
}
@keyframes scale-y {
  0%, 10% { height: calc(100vh - 4rem); }
}

To break that down:

  • Set 2 animations, scale-x and scale-y

  • Set animation-fill-mode to both

  • Use our view-timeline as the animation-timeline

  • Set the animation-range to start once the parent has entered and finish 20% before it starts to exit

  • Use a different timing function for each so it doesn't feel too linear (more on this in a moment)

And this gives you something like this!

Animating the Layers

The last piece is the layers. You use the same view-timeline but drive two different keyframes.

.grid .layer {
  animation-name: fade, reveal;
  animation-fill-mode: both;
  animation-timeline: --driver;
  animation-timing-function: var(--sine), var(--power-1);
  animation-range: entry 100% exit 0%;
}
@keyframes fade { 0%, 55% { opacity: 0; }}
@keyframes reveal { 0%, 30% { scale: 0; }}

There are a few ways to get the stagger. You could opt to adjust the animation-range for each layer. An alternative could be for all layers to use the same range but have different animation-timing.

Using animation-range could look something like this:

.grid .layer {
  &:nth-of-type(1) {
    animation-range: entry 100% exit 0%;
  }
  &:nth-of-type(2) {
    animation-range: entry 100% exit -10%;
  }
  &:nth-of-type(3) {
    animation-range: entry 100% exit -20%;
  }
}

Using animation-timing-function could look something like this:

.grid .layer {
  &:nth-of-type(1) {
    animation-timing-function: var(--sine), var(--power-1);
  }
  &:nth-of-type(2) {
    animation-timing-function: var(--sine), var(--power-3);
  }
  &:nth-of-type(3) {
    animation-timing-function: var(--sine), var(--power-4);
  }
}

The extra part about using animation-timing-function is defining the different easing curves. CSS now has support for custom timing curves via linear(). I built a rudimentary demo to help me use GreenSock eases in CSS. Type in the easing string and get back the CSS equal. For this demo, "--power-1", "--power-2", etc. look wild. But they're written once, then not touched again.

.layer {
--power-1-out: linear(
    0 0%,
    0.0027 3.64%,
    0.0106 7.29%,
    0.0425 14.58%,
    0.0957 21.87%,
    0.1701 29.16%,
    0.2477 35.19%,
    0.3401 41.23%,
    0.5982 55.18%,
    0.7044 61.56%,
    0.7987 68.28%,
    0.875 75%,
    0.9297 81.25%,
    0.9687 87.5%,
    0.9922 93.75%,
    1 100%
  );
}

Here are the animated layers showcasing both staggers.

And here are the grid layers shown in DevTools


That's it for your CSS implementation. There was mention of a JavaScript solution above. Without digging deep, this could be the GreenSock ScrollTrigger timeline for the center image. (You can wrap it in a support check like we did in the previous issue)

gsap
  .timeline({
    scrollTrigger: {
      trigger: 'main section:first-of-type',
      start: 'top -10%',
      end: 'bottom 80%',
      scrub: true,
    },
  })
  .from(
    '.scaler img',
    {
      height: window.innerHeight - 32,
      ease: 'power1.inOut',
    }
  )
  .from(
    '.scaler img',
    {
      width: window.innerWidth - 32,
      ease: 'power2.inOut',
    },
    0
  )

You create a GreenSock timeline and use the ScrollTrigger to drive it. That will work fine and you can adjust it as you wish. There is the potential that you may write more this way. An example is how you handled timing functions in the CSS for the scaling image.


Last things last, before we go. Remember option 1? How it's good to enhance as a bonus? Those little details? ʕ⊙ᴥ⊙ʔ

Until next time!


Demo link: codepen.io/jh3y
On X: x.com/jh3yy
On BlueSky: bsky.app/jhey.dev


RECENT DEMOS


The Craft of UI is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber. Would love to hear your ideas on what a paid subscription could offer. Access to private repos? Office hours?

]]>
<![CDATA[Sliders: Range Inputs in Disguise]]>https://craftofui.substack.com/p/sliders-range-inputs-in-disguisehttps://craftofui.substack.com/p/sliders-range-inputs-in-disguiseThu, 14 Nov 2024 22:10:53 GMT
CSS iOS-style range sliders

This issue sees us revisit some slider demos built earlier this year. Some of these were then presented at Figma Config 2024. A major theme of that talk was leaning on web platform primitives to do the work for us.

These recently ended up doing the rounds again so let's dig into how it works.

My brain works in a way that wherever the scrolling happens, there's a constant question of "How would I make that?" and "Could I do it with this?".

So how would you go about implementing this design from Malay?

A slider control for the web that gives you a way to choose a coffee/milk ratio

This is what I put together for reference.

If you said, "Start with an HTML input of type 'range'", that's a great start.

Peter Parker meme where he is looking at something with this glasses down and then puts them on to see what it really is. On the right is a picture of our final demo being looked at without glasses. Below it being looked at with the glasses on is a basic HTML input type range.

Where do you go next? The difference in approaches from looking around the web surprises me. But more often than not, there are divs involved and sometimes an input hidden somewhere. That input is usually hidden and only made available to a screen reader. Then it's the job of the divs and some well-placed interaction logic to let you interact with it. And those interactions will likely keep that input in sync at some point.

You could say the "historical" approach might be to throw some JavaScript at it.


When you think about the issue, it's applying styles to a primitive input. The underlying mechanism works fine (Albeit with some quirks). We only want to create a new style for it. So instead flip your thinking to "What does my CSS need to make this happen?". In this case, it only cares about the value of the input. Give your CSS that and let it handle the rest.

Let’s assume we start by wrapping an input and its label.

<div class="slider">
  <label class="sr-only" for="slider">Some label</label>
  <input
    type="range"
    id="slider"
    min="0"
    max="100"
    step="1"
    value="25"
    tabindex="0"
  />
</div>

Note the explicit tabindex for Safari.

Getting the value

The easiest way to pass a value to CSS? Use a custom property with a little JavaScript.

const slider = document.querySelector('.slider')
const input = slider.querySelector('input')
const update = () => {
  slider.style.setProperty('--slider-complete', input.value)
}
input.addEventListener('input', update)

But you might not need that. You could wrap it in one of these:

if (
  !CSS.supports(
    '(animation-timeline: scroll()) and (animation-range: 0 100%)'
  )
) {
  // syncing code
}

That's because you can use the CSS scroll-driven animations API to get the value of your input. What?! Yeah. Attach a view timeline to the range thumb and then animate a custom property using that timeline.

@property --slider-complete {
  initial-value: 0;
  inherits: true;
  syntax: '<integer>';
}
@supports (animation-timeline: scroll()) and (animation-range: 0 100%) {
  .slider {
    timeline-scope: --thumb;
    animation: progress reverse both linear;
    animation-timeline: --thumb;
    animation-range: contain;
  }
  @keyframes progress {
    to {
      --slider-complete: 100;
    }
  }
  .slider input {
    overflow: hidden;
  }
  .slider ::-webkit-slider-thumb {
    view-timeline: --thumb inline;
  }
}

The gist is this:

  1. Set overflow: hidden on the input

  2. Create an inline axis view-timeline on ::-webkit-slider-thumb

  3. Hoist its visibility with timeline-scope

  4. Animate a defined custom property from 0 to 100 using the view-timeline

  5. Style whatever you want within the parent

  6. Profit (makes sense, right?)

In this example, we can take the value and use it within a counter to show the updating value (Demo link)

.slider::after {
  counter-set: progress var(--slider-complete);
  content: 'Debug: ' counter(progress);
}

You could even do something like have a dynamic accent-color:

.slider input {
  accent-color: hsl(var(--slider-complete) 70% 65%);
}

Some of you might have noticed that the above JavaScript version isn't gonna hold out. Our scroll animation tracks the progress of the thumb from 0 to 100. But our JavaScript passes through the input value. What if we have a different min? max? step? We need to account for it.

A rudimentary but perhaps overkill starting point:

if (
  !CSS.supports(
    '(animation-timeline: view()) and (animation-range: 0 100%)'
  )
) {
  class Slider {
    constructor(element) {
      const input = element.querySelector('[type=range]')
      const sync = () => {
        const val = (input.value - input.min) / (input.max - input.min)
        element.style.setProperty('--slider-complete',
          Math.round(val * 100))
      }
      console.info('polyfilling scroll animation for input:', element)
      input.addEventListener('input', sync)
      // on iOS, you'll also want to cater for starting an interaction
      input.addEventListener('pointerdown', sync)
      sync()
    }
  }
  const sliders = document.querySelectorAll('.slider')
  for (const slider of sliders) new Slider(slider)
}

Note that if you're going to use CSS to show the current value, test it against the input value too. Especially if you're adjusting the available range.

Styling things

Now you’ve got the mechanism in place, you can focus on the fun stuff, styling it up. You’re free to do whatever you can imagine now you have the value.

Here’s some HTML, you could use:

<label for="slider">Volume</label>
<div class="slider">
  <div class="slider__track">
    <input type="range" id="slider" min="0" max="100" step="1" />
    <div class="slider__fill"></div>
    <div class="slider__indicator"></div>
  </div>
</div>

This will come down to your design though. For example, you might want to do something fun with the label. You could introduce new elements that are aria-hidden for this. Then hide the real label making it still available to screen readers.

Here’s a basic styled “Slider” (Demo link):

You want to fill in the track and move the handle as the value changes. To move the handle you need the width of the slider. You could be explicit here and pass down a custom property for that. You could also use container query units.

.slider__indicator {
  translate: calc((var(--slider-complete) * 1cqi) - 50%) -50%;
}
.slider__fill::after {
  translate: calc(var(--slider-complete) * 1cqi) 0%;
}

Same for the fill. The fill is a pseudoelement that translates across.

Remember earlier when we mentioned solutions hiding the input? They tend to backfill the interaction on other elements. For our technique though, we can style the underlying input to increase the touch target of the input.

.slider {
  /* use these values on the track and elsewhere */
  --height: 2rem;
  --width: 400px;
}
.slider input {
  height: 100%;
  width: 100%;
}
.slider ::-webkit-slider-thumb {
  width: var(--height);
  height: var(--height);
}

That way we keep all the interactivity from the underlying input. And without having to rebuild the basics ourselves.

One last nice touch. Use grab hands where you can.

.slider [type='range']:hover {
  cursor: grab;
}
.slider [type='range']:active {
  cursor: grabbing;
}

Back to the Brief

Now you know the mechanics. Our Coffee & Milk slider doesn't seem so bad, right? It's the same underlying technique with two visual labels that update.

For them, we can use CSS counters and some Math.

.slider__label {
  counter-set: low var(--value) high calc(100 - var(--value));
}
.slider__label::before {
  color: hsl(24 74% 54%);
  content: 'COFFEE ' counter(low) '%';
  left: 0.5rem;
}

You might make these aria-hidden and then hide a real label that might say something like “Coffee to Milk Ratio (%)”.

The track is interesting because you can build it as one long piece with different colored ends. Leave a gap in the middle for the handle. Then translate it within a clipped container. Alternatively, you could transition the width of the two pieces and the position of the handle.

There is one ugly part. The way we change the height of the track. That's hard-coded into a keyframe using an extra custom property.

.slider {
  animation: sync reverse, shift;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: --thumb;
  animation-range: contain;
}
.slider__label {
  translate: 0 calc(var(--shift) * 50%);
}
.slider__track {
  height: calc(50% + (var(--shift) * 50%));
}
@keyframes shift {
  0%,
  31%,
  61%,
  100% {
    --shift: 0;
  }
  32%,
  60% {
    --shift: 1;
  }
}

You could likely do something with the value using clamp or some of the newer CSS Math features available now.

Taking it further

That's how you take a range input and turn it into a Coffee slider. Where else could you take it though?

How about these iOS-style vertical sliders?

These are a little trickier and involve vertical orientation. You might think to use a transform and rotate the inputs. But that only gets you so far as you'll encounter issues with touch on a device. Instead, tell the browser these are vertical using writing-mode and direction. That will also mean reverting your view-timeline to block axis and no longer reversing the animation.

.slider {
  timeline-scope: --thumb;
  animation: sync both linear;
  animation-timeline: --thumb;
  animation-range: contain;
}
.slider [type='range']::-webkit-slider-thumb {
  view-timeline-name: --thumb;
}
.slider [type='range'] {
  writing-mode: vertical-lr;
  direction: rtl;
  -webkit-appearance: slider-vertical;
}

The icons for each slider morph based on clamping our --slider-complete value. Then use that to transform the SVG. Remember to use transform-box: fill-box on your SVG.

The cool part here is the little overstretch bounce.

You can achieve that with a little JavaScript. Track the pointer position when dragging the slider and calculate the overshoot. Depending on the direction you go, adjust the transform-origin. And the last piece is to update a --stretch value that we use to scale the track.

.slider__track {
  scale: calc(
    1 - (clamp(0, var(--stretch), 1) * (var(--stretch-ratio) * 0.5))
  )
  calc(1 + (clamp(0, var(--stretch), 1) * var(--stretch-ratio)));
}

And then there's this example.

But this could be a whole article in itself. The point is that it's all powered by that one trick of grabbing the --slider-complete value. This one leans into 3D transforms, some trigonometry, and the fact you have Math.round in CSS now!


Demo Link: codepen.io/jh3y


RECENT DEMOS

The Craft of UI is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber. Would love to hear your ideas on what a paid subscription could offer. Access to private repos? Office hours?


]]>
<![CDATA[Building a Drawer: The Versatility of Popover]]>https://craftofui.substack.com/p/building-a-drawerhttps://craftofui.substack.com/p/building-a-drawerWed, 30 Oct 2024 23:48:50 GMT
Using today’s demo on an Android device

Recently, I got a request for a CSS-only drawer with “drag” support on mobile. This is tricky, but newer APIs like Popover make clever solutions possible. For example, morphing desktop navigation into a mobile drawer. So far can we get with some of these cutting-edge APIs?

The trick for the morph: Override user agent styles on desktop and lean into them on mobile styling the Popover as a drawer.

The Craft of UI is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.

Popover API

If you haven't played with the Popover API, this is the "Hello World":

<div popover id="pop">I'm the Popover</div>
<button popovertarget="pop" popovertargetaction="toggle">Open Popover</button>

It promotes an element to the top layer (no z-index needed) with free light dismiss and a styleable ::backdrop.

No JavaScript required.

How far can CSS take you?

You can get far using a Popover and CSS.

Click to open the drawer. Click off or use the esc key to dismiss. Some CSS and you can transition the entry/exit. Only Firefox is left to go for @starting-style support.

.drawer {
  /* This acts like a holding animation */
  transition-property: display;
  transition-behavior: allow-discrete;
  transition-duration: var(--duration);
}

.drawer__content {
  transition-property: translate;
  transition-duration: var(--duration);
  transition-timing-function: var(--ease);
  translate: 0 100%;
}

.drawer:popover-open .drawer__content {
  translate: 0 0;
  @starting-style {
    translate: 0 100%;
  }
}

You could handle missing support with some WAAPI or at the very least using Element.getAnimations and toggling some classNames. Do not use a setTimeout and hope for the best.

Gestures

How much can we get out of the browser for free? For a draggable drawer, CSS scroll-snap can do a lot.

Use a viewport-sized Popover, and put the drawer in a scroll container that uses scroll-snap. Use snap anchors (with pointer-events: none) to make the drawer snap into place.

This trick lets touch devices "drag" for free, while the desktop needs a few lines of JavaScript to update the scroll position on drag.

.drawer__scroller {
  height: 100%;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  overflow-y: auto;
  scroll-snap-type: y mandatory;

  /* Acts as a buffer */
  &::after {
    content: '';
    width: 100%;
    height: 100svh;
    order: -1;
    flex: 1 0 100svh;
  }
}
/* The Anchors */
.drawer__anchors {
  pointer-events: none;
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.drawer__anchor {
  height: 50px;
  width: 100%;
  scroll-snap-align: end;
  &:first-of-type {
    translate: 0 -100%;
  }
}

Now you can slide the drawer up and down, but it doesn't close when you slide it down. We need some JavaScript.

A new event, scrollsnapchange is perfect for us. When the snap changes, close the Popover if we've scrolled the drawer out.

scroller.addEventListener('scrollsnapchange', () => {
  if (scroller.scrollTop === 0) {
    drawer.dataset.snapped = true
    drawer.hidePopover()
  }
})
// handle removing the snapped tag and setting up IntersectionObserver
drawer.addEventListener('toggle', (event) => {
  if (event.newState === 'closed') {
    drawer.dataset.snapped = false
  }
  if (event.newState === 'open' && !('onsrollsnapchange' in window)) {
    if (!observer) observer = new IntersectionObserver(callback, options)
    observer.observe(topAnchor)
  }
}

Note:: We use dataset to manage when transitions should or should not happen in our CSS.


The Popover API gives you access to a toggle event so you can see the state of a Popover (You also get a beforetoggle event).

If there's no onscrollsnapchange support, use an IntersectionObserver to do the job.

Scroll-Driven Details

Now the mechanics are in place, we can have some fun. What about that action sheet effect where the body behind scales down?

We're using scroll so you could use CSS scroll-driven animation to style the backdrop as we move the drawer.

By driving the value of some CSS custom properties, you can animate the backdrop styling. For browsers where scroll-driven animations aren't supported, we can use a trick where we kick off a requestAnimationFrame on scroll.

@supports (animation-timeline: scroll()) {
  :root {
    timeline-scope: --drawer;
  }
  :root:has(.drawer:popover-open) {
    --closed: 1;
    animation: open both linear reverse;
    animation-timeline: --drawer;
    animation-range: entry;
    main { --opened: 1; }
  }
  .drawer__slide {
    view-timeline: --drawer;
  }
  @keyframes open {
    0% {
      --closed: 0;
    }
  }
}
main {
  --diff: calc(var(--opened) * var(--closed));
  scale: calc(
    1 - ((var(--opened) * 0.04) - (var(--diff) * 0.04))
  );
}

Note:: This worked great on the body in Chromium but broke in Safari. Looks like transitioning from the top layer with allow-discrete doesn't work as expected. For this reason, we're using main as the backdrop and scaling that down instead.


Visual Viewport and Software Keyboards

Using values like 100svh or 100dvh for layout is great but they don't adjust for on-screen keyboards. This means our drawer could get covered when we try to get user input.

As of Chrome 108, you can use the interactive-widget=resize-contents key to resize both the Visual and Layout Viewports. But it doesn't work on iOS Safari. To get around this, use the visualViewport API to listen for the offset and resize the drawer via CSS. The idea is that you never want your drawer to get cut off because the keyboard pushed it up above the top of your screen:

const handleResize = () => {
  document.documentElement.style.setProperty(
    '--viewport-offset',
    window.visualViewport.offsetTop
  )
}
window.visualViewport?.addEventListener('resize', handleResize)
.drawer__content {
  max-height: calc(95% - (var(--viewport-offset, 0) * 1px));
}

Great!

Recommend this article about “Dealing with the Visual Viewport”. It is up to you how you handle the Visual Viewport based on your design. This post on web.dev does a good job of laying out different behaviors.

The fun stuff!

When looking for designs to play with, I loved the idea of this sticky reaction bar that fired emojis. Some top padding on the drawer and display: flex gives us the effect. The content flexes(flex: 1) with overflow: auto. The reaction bar sticks to the bottom on scroll with position: sticky. Add a little full-screen canvas and you've got some emoji bursts for fun. When you let the browser handle as much of the not-so-fun stuff as possible, you can focus on building the fun things!


Demo Link: codepen.io/jh3y


Takeaways

  • Using the Popover API for disclosures

  • @starting-style for entry/exit animations

  • scroll-snap tricks with scrollsnapchange

  • CSS scroll-driven backdrops with @property

  • VisualViewport resize listening and interactive-widget=resize-contents

  • Grab a device! Check things out in the iOS Simulator or use USB debugging on Android via Chrome

  • How to cater for browser support issues with APIs like IntersectionObserver and requestAnimationFrame

The Craft of UI is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.

]]>