Skip to content

Alex Carpenter

Staff UI Engineer at Shopify

Grand Rapids, MI.

Notes on engineering, developer experience, design systems, and accessibility.

Notes

  • Smooth Dot Indicators with Embla Carousel and CSS color-mix()

    The interview gallery on ibrewmyown.coffee uses image carousels with pagination dots. Standard pagination dots using Embla Carousel toggle between active and inactive, but I wanted them to respond to scroll progress: as you drag, the current indicator fades out while the next one fades in.

    Implementation

    Embla Carousel exposes scroll progress, making this possible:

    const onScroll = (): void => {
      const snapList = emblaApi.scrollSnapList();
      const progress = emblaApi.scrollProgress();
    
      if (snapList.length < 2) return;
    
      let lower = snapList.length - 2;
      for (let i = 0; i < snapList.length - 1; i++) {
        if (progress >= snapList[i] && progress <= snapList[i + 1]) {
          lower = i;
          break;
        }
      }
    
      const upper = lower + 1;
      const range = snapList[upper] - snapList[lower];
      const t = range === 0 ? 0 : (progress - snapList[lower]) / range;
    
      dotNodes.forEach((_, i) => {
        if (i === lower) setDotProgress(i, 1 - t);
        else if (i === upper) setDotProgress(i, t);
        else setDotProgress(i, 0);
      });
    };

    As t interpolates from 0 to 1, a CSS custom property --dot-progress is set for each dot. This single value drives two animations: width expansion and color interpolation.

    Width and Color

    The dot width expands as you approach it:

    width: calc(var(--spacing) * 2 + var(--spacing) * 3 * var(--dot-progress, 0));

    And the color blends between two states using color-mix():

    background-color: color-mix(
      in srgb,
      var(--color-neutral-600) calc(var(--dot-progress, 0) * 100%),
      var(--color-neutral-300)
    );

    This approach preserves contrast, keeping the active dot darkest and the inactive lightest. The browser handles both interpolations without additional JS overhead.

    Hat tip to Derek Briggs for the color fade idea: “What if the current active indicator faded out the primary color during the drag and the next active faded in so that the most active item had the most contrast always?”

  • <TextMorph />

    torph.lochie.me

    Dependency-free animated text component.

  • @nano_kit/store

    nano_kit.js.org

    @nano_kit/store is a lightweight state management library inspired by Nano Stores and built around a push-pull based reactivity system.

  • react-prehydrate

    github.com

    Eliminate flash-of-incorrect-state for user preferences in React Server Component apps.

  • eslint-plugin-react-render-types

    github.com

    ESLint plugin that brings Flow’s Render Types to TypeScript via JSDoc. Enforce component composition constraints like @renders {MenuItem} at lint time.

  • TIL you can slow down animations in Chrome DevTools.

    Press cmd+shift+p (or ctrl+shift+p on Windows/Linux), then search for “animations”. An Animations panel will appear at the bottom of DevTools where you can adjust the playback speed to 100%, 25%, or 10%. All animations on the page will now run at the selected speed.

  • CSS only scroll fade example that I implemented on ibrewmyown.coffee:

    @supports (animation-timeline: scroll()) {
      @property --fade-left {
        syntax: "<length>";
        inherits: false;
        initial-value: 0px;
      }
      @property --fade-right {
        syntax: "<length>";
        inherits: false;
        initial-value: 0px;
      }
    
      .scroll-fade {
        --fade-distance: 40px;
        mask-image: linear-gradient(
          to right,
          transparent 0,
          #000 var(--fade-left),
          #000 calc(100% - var(--fade-right)),
          transparent 100%
        );
        mask-size: 100% 100%;
        mask-repeat: no-repeat;
        animation:
          fade-in-left 1 linear both,
          fade-out-right 1 linear both;
        animation-timeline: scroll(x self), scroll(x self);
        animation-range:
          0% 12%,
          88% 100%;
      }
    
      @keyframes fade-in-left {
        from {
          --fade-left: 0px;
        }
        to {
          --fade-left: var(--fade-distance);
        }
      }
    
      @keyframes fade-out-right {
        from {
          --fade-right: var(--fade-distance);
        }
        to {
          --fade-right: 0px;
        }
      }
    }
  • Preserve modal aspect ratio across viewport sizes

    This is one of those that feels like it should be easier than it ends up being, but here is where I landed at to ensure a modal image preserves its 3/2 aspect ratio no mater the viewport width/height.

    .modal {
      aspect-ratio: 3 / 2;
      height: min(calc(100vh - 2rem), calc((100vw - 2rem) * 2 / 3));
      margin: auto;
      position: relative;
    }

    See it in action on I Brew My Own Coffe.

  • It’s always felt weird using opacity for disabled buttons for situations where its not placed on a solid background.

    In this alternative approach I am using color-mix to create a similar disabled effect without the opacity.

    <button
      class="bg-[color-mix(in_srgb,var(--color-blue-500)_50%,var(--color-background))] text-[color-mix(in_srgb,var(--color-white)_50%,var(--color-background))]"
      disabled
    >
      Hello world
    </button>
  • I’ve been fortunate to work with some amazing people over the years and a couple of those folks are open for new opportunities. Scoop them up.

  • My shadcn/ui registry

    ui.alexcarpenter.me

    I’ve been slowly working on my own shadcn/ui registry while learning more about the whole component distrubution setup at work. So far I have the following components:

    • <InfoList />
      A list component with support for items, headers, and icons slots.
    • <InlineText />
      An inline text component with an icon slot that wraps perfectly with the text.
    • <PricingTable />
      A pricing table component with support for monthly/yearly pricing.
  • Another use case for absolute positioning elements within inline text to support hanging punctuation.

    Supports varying sizes of text, without the need for custom negative text indent values.

    <p class="relative inline-block">
      <span class="absolute right-full"></span>Lorem ipsum dolor sit amet
      consectetur adipisicing elit. Iste officia quasi fugiat, dolores ab nam
      repellendus voluptate”
    </p>
  • Ensure the trailing icon never orphans itself on to a new line. Paired with the icon alignment technique I shared last week to vertically center it.

    <p class="relative inline-block pr-[1.25em]">
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Quia, ducimus
      <span class="absolute ml-[.25em] inline-flex h-[1lh] items-center">
        <Icon name="arrow-up-right" class="size-[1em]" />
      </span>
    </p>

    Picked this tip of from John Phamous some time ago.

  • How to properly align icons within list items

    My current favorite approach to building bullet proof icon alignment within list items. Ensures icons are always vertically aligned to the first line of text, and ensures the icons do not shrink when text wraps to two lines.

    <ul>
      <li class="flex gap-2">
        <span class="flex h-[1lh] items-center">
          <Icon class="size-[1em] flex-none" name="badge-check" />
        </span>
        List item 2 that is longer than the others and wraps to two lines
      </li>
    </ul>

    With this approach, you can apply a font size to the list item, and the icon will scale accordingly.

  • We shipped a shadcn/ui registry

    clerk.com

    npx shadcn@latest add https://clerk.com/r/nextjs-quickstart.json

    This single command will install:

    • App layout with ClerkProvider and theme integration
    • Sign-in and sign-up pages with catch-all routes
    • Clerk middleware for route protection
    • Header component with authentication buttons
    • Theme provider for dark/light mode support
  • For when <mark /> elements wrap to multiple lines, use box-decoration-break: clone; to render each fragment with their own specified border, padding, and margin.

    mark {
      box-decoration-break: clone;
      padding-inline: 0.25rem;
    }
  • Using background-repeat: round; to get that repeated dot background to fit perfectly across differing viewport widths.

    div {
      background-image: radial-gradient(red 1px, transparent 1.3px);
      background-size: 24px 24px;
      background-position: 0 0;
      background-repeat: round;
    }
  • Had a quick chat with Hamed about the recent work we have done to improve the customization of Clerk UI components. Watch it on YouTube.

  • Your component library ships bundled CSS via CSS-in-JS. You want folks to opt in to being able to toggle between light/dark mode but you don’t know how they are handling toggling between light/dark.

    Use CSS var toggle hack?

    /* Component library styles */
    :root {
      --dark-mode: ;
    }
    
    button {
      padding: 1rem;
      background-color: var(--dark-mode, white) black;
      color: var(--dark-mode, black) white;
    }
    
    /* User styles turn on dark mode */
    .dark {
      --dark-mode: initial;
    }
    
    @media (prefers-color-scheme: dark) {
      :root {
        --dark-mode: initial;
      }
    }

    This gives the user the ability to enable dark mode based on how they have their app configured. @⁠media query, class, data-attr, etc.

    They can even be very targeted on which components this is enabled for.

  • Write like you talk

    paulgraham.com

    Here’s a simple trick for getting more people to read what you write: write in spoken language.

  • Agentic Engineering

    zed.dev

    Software development is changing and we find ourselves at a convergence. Between the extremes of technological zealotry (“all code will be AI-generated”) and dismissive skepticism (“AI-generated code is garbage”) lies a more practical and nuanced approach—one that is ours to discover together.

  • Clerk CSS variables are now available!

    clerk.com

    Theme your Clerk components directly from your CSS files where your design tokens live – no more CSS-in-JS required.

    :root {
      --clerk-color-primary: #6d47ff;
    }

    We learned from our own experience: the variables appearance option had limited adoption because it was hard to integrate with existing design systems. Even we had to use workarounds with elements prop + Tailwind classes in our dashboard.

    Now you can theme components where your tokens are already defined. Plus we’ve improved variable naming and added new ones like colorRing, colorMuted, and colorShadow for more flexible theming.

  • Working on some updates to make it easier to theme Clerk components from your existing CSS variables.

    Generating a color palette using relative color syntax and color-mix.

    :root {
      --brand-color: oklch(49.1% 0.27 292.581);
    }
    
    @media (prefers-color-scheme: dark) {
      :root {
        --brand-color: oklch(54.1% 0.281 293.009);
      }
    }
    <ClerkProvider
      appearance={{
        variables: {
          colorPrimary: "var(--brand-color)",
        },
      }}
    />
  • Handle all potential cases in a switch statement

    x.com/housecor

    type Cat = { kind: 'cat' }
    type Dog = { kind: 'dog' }
    type Pet = Cat | Dog
    
    function example(pet: Pet) {
      switch (pet.kind) {
        case: 'cat':
          return ...
        case: 'dog'
          return ...
        default:
          pet satisfies never
      }
    }
  • Tiny polyfill for CSS scroll driven animations

    x.com/devongovett

    let animationRange = [0, 62];
    
    if (!CSS.supports("(animation-timeline: scroll())")) {
      let [start, end] = animationRange;
      let animations = header.getAnimations();
      let onScroll = () => {
        // Calculate animation time based on percentage of animationRange * duration.
        let time =
          Math.max(0, Math.min(end, window.scrollY - start) / (end - start)) * 1000;
        for (let animation of animations) {
          animation.currentTime = time;
        }
      };
    
      window.addEventListener("scroll", onScroll, { passive: true });
    }
  • The Prettify Helper

    www.totaltypescript.com

    The Prettify helper is a utility type that takes an object type and makes the hover overlay more readable.

    type Prettify<T> = {
      [K in keyof T]: T[K];
    } & {};
  • Automatic foreground color contrast based on the provided background color.

    button {
      --background: black;
      --foreground: color(
        from var(--background) xyz round(up, min(1, max(0, 0.18 - y)))
          round(up, min(1, max(0, 0.18 - y))) round(up, min(1, max(0, 0.18 - y)))
      );
    
      background-color: var(--background);
      color: var(--foreground);
    }

    via blog.damato.design

  • Evil Martions Harmonizer

    harmonizer.evilmartians.com

    Harmonizer is a tool for generating accessible, consistent color palettes for user interfaces. Using the OKLCH color model and APCA contrast formula, Harmonizer helps you create color palettes with consistent chroma and contrast across all levels and hues.

  • never just

    www.neverjust.net

    it’s never just that simple

  • Not uncommon to see folks add form submit handlers on the submit buttons click event vs on the forms submit handler. The problem is this prevents users from being able to fill out the form and submit it solely from the keyboard.

    <form>
      <button onClick={handleSubmit} />
    </form>

    Instead, apply the handleSubmit function to the form onSubmit handler. This ensures the form can be submitted when the user hits the return key.

    <form onSubmit={handleSubmit}>
      <button type="submit" />
    </form>

    If for whatever reason your button needs to live outside of the form element, likely in a dialog situation, you can ensure the same functionality by passing the forms id to the button via the form attribute as shown below.

    <form id="contactForm" onSubmit={handleSubmit}>
    </form>
    <button form="contactForm" type="submit" />
  • Quick little improvement for wrapping highlighted text. Make use of box-decoration-break: clone; to ensure elements fragments break across lines.

    mark {
      box-decoration-break: clone;
    }

    View demo on Twitter.

  • Quick little improvement for elements that render dark even in light mode and have overflow, force the color-scheme to dark to blend the native form controls a bit better.

    pre {
      color-scheme: dark;
    }

    View demo on Twitter.

  • Stop vertically aligning your checkboxes with center. Instead use baseline to keep it aligned with the first line of the label text.

    label {
      display: flex;
      align-items: center;
      align-items: baseline;
    }
  • This was a fun layout challenge for some dashboard UI we’ve been working on at Clerk. The structure of the layout contains your typical sidebar and content elements that live side by side.

    Simplified markup example of what we are working with:

    <div class="header"></div>
    <div class="nav"></div>
    
    <div class="layout">
      <div class="sidebar"></div>
      <div class="content">
        <div class="data"></div>
      </div>
    </div>

    The data element is a large table which i’ve hard coded to cause an overflow that needs to be able to scroll vertically and horizontally but we wanted to make sure that its scroll bars were always within the viewport.

    To accomplish this we can make use of contain: size; to ensure the content overflows within its container and not cause the page to need to scroll.

    body {
      min-height: 100%;
      display: flex;
      flex-direction: column;
      flex: 1;
    }
    
    .header {
      width: 100%;
      height: 56px;
    }
    
    .nav {
      width: 100%;
      height: 56px;
      position: sticky;
    }
    
    .layout {
      flex: 1;
      display: grid;
      grid-template-columns: 360px 1fr;
    }
    
    .sidebar {...}
    
    .content {
      overflow: auto;
      contain: size;
    }
    
    /* This is simply to cause an overflow to demonstrate the table size */
    .data {
      width: 200vw;
      height: 200vh;
    }

    View the demo CodePen here.

  • Create a list of links from an array using Intl.ListFormat with formatToParts

    const tags = ["HTML", "CSS", "JavaScript"];
    
    {
      new Intl.ListFormat("en-US").formatToParts(tags).map(({ type, value }) => {
        if (type === "element") {
          return <a href={`/${slugify(value)}`}>{value}</a>;
        }
        return value;
      });
    }

    which returns the following markup:

    <a href="/html">HTML</a>, <a href="/css">CSS</a>, and
    <a href="/javascript">JavaScript</a>
  • React Aria exposing state through the className is super handy. Here we’re using the placement returned to define a Tailwind CSS variable which we can then use in Motion to define its animation direction. View sandbox example here.

    <TooltipTrigger isOpen={open} onOpenChange={setOpen}>
      <Button>Trigger</Button>
      <AnimatePresence>
        {open ? (
          <MotionTooltip
            className={({ placement }) =>
              cx({
                "[--y:4px]": placement === "top",
                "[--y:-4px]": placement === "bottom",
              })
            }
            offset={6}
            initial={{ opacity: 0, y: "var(--y)" }}
            animate={{ opacity: 1, y: 0 }}
          >
            Content
          </MotionTooltip>
        ) : null}
      </AnimatePresence>
    </TooltipTrigger>