Skip to content
On this page Hover and pointer

Motion

Micro-interactions

Named micro-interactions with exact values: magnetic button, press spring, morphing toggle, like burst, hold to confirm, copy to check, add to cart fly.

A micro-interaction is the small reply an element gives to a touch: it leans, presses, flips, confirms. Each demo below plays itself while in view with a faint fake pointer (hidden from assistive tech), and the moment you touch it, it is yours: the real pointer or keyboard drives the same effect. Under reduced motion every demo shows its final state. Read Motion for the contract.

Hover and pointer

Magnetic button

A pill button that leans toward the pointer when it comes within 72px, pulled by a Spring (stiffness 0.12, damping 0.55) at 35% of the offset, and springs back when the pointer leaves. Disabled on coarse pointers. Also called a magnet button, botão magnético. Ask: “Use #340, a magnetic button, on the hero call to action.”

Code
svelte
<script lang="ts">
  import { MotionStage } from '@codeatlasdev/ui';
  import { ArrowRight } from '@lucide/svelte';
  import { Spring } from 'svelte/motion';

  const PULL = 0.35;
  const RANGE = 72;
  // Pull is a fraction of the pointer offset from the button centre, so it eases out at the edge.
  const pull = new Spring({ x: 0, y: 0 }, { stiffness: 0.12, damping: 0.55 });
  let zone = $state<HTMLElement>();
  let button = $state<HTMLElement>();
  let coarse = $state(false);
  // True once a real pointer is over the zone: the pull then belongs to it, not to the autoplay.
  let userHover = false;
  let ghost = $state({ x: -80, y: 70, visible: false });

  $effect(() => {
    const mq = matchMedia('(pointer: coarse)');
    const sync = () => (coarse = mq.matches);
    sync();
    mq.addEventListener('change', sync);
    return () => mq.removeEventListener('change', sync);
  });

  function aim(clientX: number, clientY: number) {
    if (!button || coarse) return;
    const r = button.getBoundingClientRect();
    const dx = clientX - (r.left + r.width / 2);
    const dy = clientY - (r.top + r.height / 2);
    const near = Math.hypot(dx, dy) < RANGE + r.width / 2;
    pull.target = near ? { x: dx * PULL, y: dy * PULL } : { x: 0, y: 0 };
  }
  const release = () => (pull.target = { x: 0, y: 0 });

  // Autoplay: a fake cursor drifts in, the button leans toward it, then the cursor leaves and it springs back.
  function demoCycle(run: () => boolean) {
    const steps: [number, number, number][] = [
      [-70, 60, 0],
      [34, -20, 500],
      [48, 14, 1100],
      [-90, 70, 1800],
    ];
    const timers = steps.map(([x, y, at]) =>
      setTimeout(() => {
        if (!run()) return;
        ghost = { x, y, visible: true };
        if (x < -80) release();
        else pull.target = { x: x * PULL, y: y * PULL };
      }, at)
    );
    // Pause or out of view mid-lean: drop the pull, unless a real pointer already owns it.
    return () => {
      timers.forEach(clearTimeout);
      if (!userHover) release();
    };
  }
</script>

<MotionStage label="Magnetic button: the button leans toward the pointer and springs back" loop={2800}>
  {#snippet children(m)}
    {#key m.cycle}
      <div
        class="mag"
        bind:this={zone}
        role="presentation"
        onpointerenter={() => {
          userHover = true;
          m.takeover();
        }}
        onpointermove={(e) => aim(e.clientX, e.clientY)}
        onpointerleave={() => {
          userHover = false;
          release();
        }}
        {@attach (node) => {
          if (!m.playing || m.reduced) return;
          return demoCycle(() => m.playing);
        }}
      >
        <button
          type="button"
          class="mag__btn"
          bind:this={button}
          style="--mx:{m.reduced ? 0 : pull.current.x}px;--my:{m.reduced ? 0 : pull.current.y}px"
        >
          Start free trial <ArrowRight size={16} aria-hidden="true" />
        </button>
        {#if m.playing && ghost.visible}
          <span class="mag__ghost" aria-hidden="true" style="--gx:{ghost.x}px;--gy:{ghost.y}px"></span>
        {/if}
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .mag {
    position: relative;
    display: grid;
    place-items: center;
    inline-size: 100%;
    max-inline-size: 320px;
    min-block-size: 9rem;
  }
  .mag__btn {
    display: inline-flex;
    align-items: center;
    gap: var(--ca-space-2);
    min-block-size: var(--ca-touch-target);
    padding-inline: var(--ca-space-6);
    border: none;
    border-radius: var(--ca-radius-pill);
    background: var(--ca-brand-fill);
    color: var(--ca-brand-contrast);
    font: inherit;
    font-weight: var(--ca-font-weight-semibold);
    cursor: pointer;
    box-shadow: var(--ca-shadow-md);
    transform: translate3d(var(--mx, 0), var(--my, 0), 0);
  }
  .mag__btn:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .mag__ghost {
    position: absolute;
    inset-inline-start: 50%;
    inset-block-start: 50%;
    inline-size: var(--ca-space-4);
    block-size: var(--ca-space-4);
    margin: calc(var(--ca-space-2) * -1);
    border-radius: var(--ca-radius-pill);
    background: var(--ca-text-primary);
    opacity: 0.55;
    pointer-events: none;
    transform: translate3d(var(--gx), var(--gy), 0);
    transition: transform 520ms cubic-bezier(0.2, 0, 0, 1);
  }
  @media (pointer: coarse) {
    .mag__btn {
      transform: none;
    }
    .mag__ghost {
      display: none;
    }
  }
</style>

Tilt card

A card that rotates up to 10° toward the pointer in 3D inside a 700px perspective and settles flat on leave. Use it for feature or pricing cards on desktop. Also called 3D tilt, card com inclinação. Ask: “Use #347, a tilt card, for the plan tiles.”

Code
svelte
<script lang="ts">
  import { MotionStage } from '@codeatlasdev/ui';
  import { Spring } from 'svelte/motion';

  const MAX = 10;
  // Easing: spring. Duration: spring (no fixed time, it settles by physics).
  const tilt = new Spring({ x: 0, y: 0 }, { stiffness: 0.1, damping: 0.6 });
  let card = $state<HTMLElement>();
  let coarse = $state(false);
  // True once a real pointer is over the card: the tilt then belongs to it, not to the autoplay.
  let userHover = false;

  $effect(() => {
    const mq = matchMedia('(pointer: coarse)');
    const sync = () => (coarse = mq.matches);
    sync();
    mq.addEventListener('change', sync);
    return () => mq.removeEventListener('change', sync);
  });

  // Pointer position inside the card (-1..1) becomes rotateX/rotateY, at most 10deg.
  function aim(clientX: number, clientY: number) {
    if (!card || coarse) return;
    const r = card.getBoundingClientRect();
    const nx = ((clientX - r.left) / r.width) * 2 - 1;
    const ny = ((clientY - r.top) / r.height) * 2 - 1;
    tilt.target = { x: -ny * MAX, y: nx * MAX };
  }
  const rest = () => (tilt.target = { x: 0, y: 0 });

  // Autoplay sweeps a fake pointer across the card corners, then lets it settle flat.
  const autoplay = (node: HTMLElement) => {
    const r = () => card?.getBoundingClientRect();
    const at = (fx: number, fy: number) => () => {
      const b = r();
      if (b) aim(b.left + b.width * fx, b.top + b.height * fy);
    };
    const timers = [
      setTimeout(at(0.1, 0.15), 300),
      setTimeout(at(0.9, 0.2), 900),
      setTimeout(at(0.8, 0.9), 1500),
      setTimeout(rest, 2100),
    ];
    return () => {
      timers.forEach(clearTimeout);
      if (!userHover) rest();
    };
  };
</script>

<MotionStage label="Tilt card: the card leans toward the pointer in 3D" loop={2800}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="tilt" {@attach (node) => (m.playing && !m.reduced ? autoplay(node) : undefined)}>
        <div
          class="tilt__card"
          bind:this={card}
          role="presentation"
          onpointerenter={() => {
            userHover = true;
            m.takeover();
          }}
          onpointermove={(e) => aim(e.clientX, e.clientY)}
          onpointerleave={() => {
            userHover = false;
            rest();
          }}
          style="--rx:{m.reduced ? 0 : tilt.current.x}deg;--ry:{m.reduced ? 0 : tilt.current.y}deg"
        >
          <p class="tilt__eyebrow">Monthly revenue</p>
          <p class="tilt__value">$48,290</p>
          <p class="tilt__delta">+12.4% vs last month</p>
        </div>
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .tilt {
    perspective: 700px;
    inline-size: 100%;
    max-inline-size: 280px;
  }
  .tilt__card {
    padding: var(--ca-space-6);
    border: var(--ca-border-width-thin) solid var(--ca-outline-default);
    border-radius: var(--ca-radius-lg);
    background: var(--ca-surface-raised);
    box-shadow: var(--ca-shadow-lg);
    transform: rotateX(var(--rx, 0)) rotateY(var(--ry, 0));
  }
  .tilt__card p {
    margin: 0;
  }
  .tilt__eyebrow {
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-caption);
  }
  .tilt__value {
    margin-block: var(--ca-space-1);
    font-size: var(--ca-font-size-h3);
    font-weight: var(--ca-font-weight-bold);
    font-variant-numeric: tabular-nums;
  }
  .tilt__delta {
    color: var(--ca-semantic-success);
    font-size: var(--ca-font-size-body2);
    font-weight: var(--ca-font-weight-medium);
  }
  @media (pointer: coarse) {
    .tilt__card {
      transform: none;
    }
  }
</style>

Press and toggle

Press spring

The button scales to 0.94 on press and overshoots past 1 on release: a Spring with stiffness 0.35 and damping 0.42. Space and Enter press it too. Use it on any tappable surface. Also called press scale, efeito de pressionar. Ask: “Use #341, a press spring, on the pay button.”

Code
svelte
<script lang="ts">
  import { MotionStage } from '@codeatlasdev/ui';
  import { Spring } from 'svelte/motion';

  // Press: scale to 0.94 fast (stiff), release overshoots past 1 then settles (low damping).
  const scale = new Spring(1, { stiffness: 0.35, damping: 0.42 });
  let pressed = $state(false);
  let count = $state(0);

  function down() {
    pressed = true;
    scale.target = 0.94;
  }
  function up(user = true) {
    if (!pressed) return;
    pressed = false;
    scale.target = 1;
    if (user) count += 1;
  }

  // Autoplay presses the button for 160ms every cycle.
  function autopress(node: HTMLElement) {
    let autoPressed = false;
    const t1 = setTimeout(() => {
      autoPressed = true;
      down();
    }, 500);
    const t2 = setTimeout(() => up(false), 660);
    return () => {
      clearTimeout(t1);
      clearTimeout(t2);
      // Paused or left between the two timers: nobody would release the press the autoplay made.
      // After a takeover a press the user made themselves stays theirs.
      if (autoPressed) up(false);
    };
  }
</script>

<MotionStage label="Press spring: a button scales down on press and overshoots on release" loop={1800}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="press" {@attach (node) => (m.playing && !m.reduced ? autopress(node) : undefined)}>
        <button
          type="button"
          class="press__btn"
          class:press__btn--down={pressed}
          style="--s:{m.reduced ? 1 : scale.current}"
          onpointerdown={down}
          onpointerup={() => up()}
          onpointerleave={() => up()}
          onpointercancel={() => up()}
          onkeydown={(e) => (e.key === ' ' || e.key === 'Enter') && !e.repeat && down()}
          onkeyup={(e) => (e.key === ' ' || e.key === 'Enter') && up()}
        >
          Pay $48.00
        </button>
        <p class="press__count" aria-live="polite">Pressed {count} {count === 1 ? 'time' : 'times'}</p>
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .press {
    display: grid;
    justify-items: center;
    gap: var(--ca-space-3);
  }
  .press__btn {
    min-block-size: var(--ca-touch-target);
    padding-inline: var(--ca-space-8);
    border: none;
    border-radius: var(--ca-radius-lg);
    background: var(--ca-brand-fill);
    color: var(--ca-brand-contrast);
    font: inherit;
    font-weight: var(--ca-font-weight-semibold);
    cursor: pointer;
    box-shadow: var(--ca-shadow-md);
    transform: scale(var(--s, 1));
    touch-action: manipulation;
  }
  .press__btn--down {
    box-shadow: var(--ca-shadow-xs);
  }
  .press__btn:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .press__count {
    margin: 0;
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-body2);
  }
</style>

Morphing toggle

A sun that becomes a moon: a mask circle slides over the disc for 420ms (cubic-bezier(0.2, 0, 0, 1)) while the rays rotate and shrink away. A real role=switch. Use it for a theme or mute toggle. Also called icon morph, toggle com morph. Ask: “Use #342, a morphing toggle, for dark mode.”

Code
svelte
<script lang="ts">
  import { MotionStage } from '@codeatlasdev/ui';

  const uid = $props.id();
  let dark = $state(false);

  // Autoplay flips the switch once per cycle.
  const flip = (node: HTMLElement) => {
    const t = setTimeout(() => (dark = !dark), 700);
    return () => clearTimeout(t);
  };
</script>

<MotionStage label="Morphing toggle: a sun morphs into a moon" loop={2400}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="tg" {@attach (node) => (m.playing && !m.reduced ? flip(node) : undefined)}>
        <button
          type="button"
          role="switch"
          aria-checked={dark}
          aria-label="Dark mode"
          class="tg__btn"
          class:tg__btn--dark={dark}
          class:tg__btn--still={m.reduced}
          onclick={() => (dark = !dark)}
        >
          <svg viewBox="0 0 24 24" class="tg__icon" aria-hidden="true">
            <!-- The sun's disc: a mask circle slides in and bites it into a crescent. -->
            <mask id="{uid}-bite">
              <rect width="24" height="24" fill="white" />
              <circle class="tg__bite" cx="17" cy="7" r="7" fill="black" />
            </mask>
            <circle class="tg__disc" cx="12" cy="12" r="6" mask={`url(#${uid}-bite)`} />
            <g class="tg__rays">
              {#each [0, 45, 90, 135, 180, 225, 270, 315] as a}
                <line x1="12" y1="2" x2="12" y2="4.5" transform="rotate({a} 12 12)" />
              {/each}
            </g>
          </svg>
        </button>
        <p class="tg__label">{dark ? 'Dark' : 'Light'} appearance</p>
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .tg {
    display: grid;
    justify-items: center;
    gap: var(--ca-space-3);
  }
  .tg__btn {
    display: grid;
    place-items: center;
    inline-size: var(--ca-touch-target);
    block-size: var(--ca-touch-target);
    padding: 0;
    border: var(--ca-border-width-thin) solid var(--ca-outline-default);
    border-radius: var(--ca-radius-pill);
    background: var(--ca-surface-raised);
    color: var(--ca-text-primary);
    cursor: pointer;
    transition: background 280ms cubic-bezier(0.2, 0, 0, 1);
  }
  .tg__btn--dark {
    background: var(--ca-brand-fill);
    color: var(--ca-brand-contrast);
  }
  .tg__btn:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .tg__icon {
    inline-size: 26px;
    block-size: 26px;
    fill: currentColor;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    transition: transform 420ms cubic-bezier(0.2, 0, 0, 1);
  }
  .tg__disc {
    stroke: none;
  }
  .tg__rays {
    transform-origin: 12px 12px;
    transition:
      transform 420ms cubic-bezier(0.2, 0, 0, 1),
      opacity 280ms cubic-bezier(0.2, 0, 0, 1);
  }
  .tg__bite {
    transform: translate(10px, -10px);
    transition: transform 420ms cubic-bezier(0.2, 0, 0, 1);
  }
  .tg__btn--dark .tg__icon {
    transform: rotate(-35deg);
  }
  .tg__btn--dark .tg__bite {
    transform: translate(0, 0);
  }
  .tg__btn--dark .tg__rays {
    transform: rotate(45deg) scale(0.4);
    opacity: 0;
  }
  .tg__btn--still,
  .tg__btn--still .tg__icon,
  .tg__btn--still .tg__bite,
  .tg__btn--still .tg__rays {
    transition: none;
  }
  .tg__label {
    margin: 0;
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-body2);
  }
</style>

Spring switch

The thumb stretches to 1.3 while pressed and lands with a 420ms overshoot (cubic-bezier(0.175, 0.885, 0.32, 1.275)). Use it on settings switches. Also called elastic toggle, switch elástico. Ask: “Use #348, a spring switch, for the notification setting.”

Code
svelte
<script lang="ts">
  import { MotionStage } from '@codeatlasdev/ui';

  const uid = $props.id();
  let on = $state(false);
  let pressed = $state(false);

  const flip = (node: HTMLElement) => {
    on = false;
    const t0 = setTimeout(() => (pressed = true), 500);
    const t1 = setTimeout(() => {
      pressed = false;
      on = true;
    }, 640);
    const t2 = setTimeout(() => (on = false), 1700);
    return () => {
      [t0, t1, t2].forEach(clearTimeout);
      pressed = false;
    };
  };
</script>

<MotionStage label="Spring switch: the thumb stretches on press and overshoots into place" loop={2800}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="sw" {@attach (node) => (m.playing && !m.reduced ? flip(node) : undefined)}>
        <button
          type="button"
          role="switch"
          aria-checked={on}
          aria-labelledby="{uid}-label"
          class="sw__track"
          class:sw__track--on={on}
          class:sw__track--press={pressed}
          class:sw__track--still={m.reduced}
          onclick={() => (on = !on)}
          onpointerdown={() => (pressed = true)}
          onpointerup={() => (pressed = false)}
          onpointerleave={() => (pressed = false)}
        >
          <span class="sw__thumb"></span>
        </button>
        <span class="sw__label" id="{uid}-label">Push notifications</span>
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .sw {
    display: flex;
    align-items: center;
    gap: var(--ca-space-3);
  }
  /* The button is the 44px hit area; the visible 52x32 track is its ::before. */
  .sw__track {
    position: relative;
    flex: none;
    inline-size: 52px;
    block-size: var(--ca-touch-target);
    padding: 0;
    border: none;
    background: none;
    cursor: pointer;
  }
  .sw__track::before {
    content: '';
    position: absolute;
    inset-block: 6px;
    inset-inline: 0;
    border-radius: var(--ca-radius-pill);
    background: var(--ca-outline-default);
    transition: background 240ms cubic-bezier(0.2, 0, 0, 1);
  }
  .sw__track--on::before {
    background: var(--ca-brand-fill);
  }
  .sw__track:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .sw__thumb {
    position: absolute;
    inset-block-start: 10px;
    inset-inline-start: 4px;
    inline-size: 24px;
    block-size: 24px;
    border-radius: var(--ca-radius-pill);
    background: var(--ca-surface-raised);
    box-shadow: var(--ca-shadow-sm);
    transform-origin: left center;
    transition: transform 420ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
  }
  .sw__track--on .sw__thumb {
    transform: translateX(20px);
  }
  .sw__track--press .sw__thumb {
    transform: scaleX(1.3);
  }
  .sw__track--on.sw__track--press .sw__thumb {
    transform: translateX(14px) scaleX(1.3);
  }
  .sw__track--still::before,
  .sw__track--still .sw__thumb {
    transition: none;
  }
  .sw__label {
    font-size: var(--ca-font-size-body2);
  }
</style>

Feedback

Like burst

The heart pops to 1.45 over 460ms and throws eight particles 26px outward in 560ms; aria-pressed carries the state and the count ticks. Use it for likes and reactions. Also called heart burst, explosão de curtida. Ask: “Use #343, a like burst, on the post footer.”

Code
svelte
<script lang="ts">
  import { MotionStage } from '@codeatlasdev/ui';
  import { Heart } from '@lucide/svelte';

  const PARTICLES = 8;
  let liked = $state(false);
  let count = $state(128);
  // Bumped on every like so the particle ring remounts and plays again.
  let burst = $state(0);

  function toggle() {
    liked = !liked;
    count += liked ? 1 : -1;
    if (liked) burst += 1;
  }

  // Autoplay likes, then unlikes, once per cycle.
  const autoplay = (node: HTMLElement) => {
    liked = false;
    count = 128;
    const t1 = setTimeout(toggle, 600);
    const t2 = setTimeout(() => {
      if (liked) toggle();
    }, 1700);
    return () => {
      clearTimeout(t1);
      clearTimeout(t2);
    };
  };
</script>

<MotionStage label="Like burst: a heart pops and throws eight particles" loop={2600}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="like" {@attach (node) => (m.playing && !m.reduced ? autoplay(node) : undefined)}>
        <button
          type="button"
          class="like__btn"
          class:like__btn--on={liked}
          aria-pressed={liked}
          onclick={toggle}
        >
          <span class="like__heart" class:like__heart--pop={liked && !m.reduced}>
            <Heart size={22} aria-hidden="true" fill={liked ? 'currentColor' : 'none'} />
          </span>
          {#if liked && !m.reduced}
            {#key burst}
              <span class="like__ring" aria-hidden="true">
                {#each Array.from({ length: PARTICLES }, (_, i) => i) as i}
                  <i style="--a:{i * (360 / PARTICLES)}deg"></i>
                {/each}
              </span>
            {/key}
          {/if}
          <span class="like__label">Like</span>
          <span class="like__count">{count}</span>
        </button>
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .like__btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: var(--ca-space-2);
    min-block-size: var(--ca-touch-target);
    padding-inline: var(--ca-space-5);
    border: var(--ca-border-width-thin) solid var(--ca-outline-default);
    border-radius: var(--ca-radius-pill);
    background: var(--ca-surface-raised);
    color: var(--ca-text-primary);
    font: inherit;
    font-weight: var(--ca-font-weight-medium);
    cursor: pointer;
    transition: color 200ms cubic-bezier(0.2, 0, 0, 1);
  }
  .like__btn--on {
    color: var(--ca-semantic-error);
  }
  .like__btn:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .like__heart {
    display: grid;
  }
  .like__heart--pop {
    animation: like-pop 460ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
  }
  .like__count {
    min-inline-size: 3ch;
    font-variant-numeric: tabular-nums;
  }
  .like__ring {
    position: absolute;
    inset-inline-start: calc(var(--ca-space-5) + 11px);
    inset-block-start: 50%;
    pointer-events: none;
  }
  .like__ring i {
    position: absolute;
    inline-size: 5px;
    block-size: 5px;
    margin: -2.5px;
    border-radius: var(--ca-radius-pill);
    background: var(--ca-semantic-error);
    animation: like-particle 560ms cubic-bezier(0, 0, 0.2, 1) both;
  }
  @keyframes like-pop {
    0% { transform: scale(1); }
    40% { transform: scale(1.45); }
    100% { transform: scale(1); }
  }
  @keyframes like-particle {
    from { opacity: 1; transform: rotate(var(--a)) translateY(0) scale(1); }
    to { opacity: 0; transform: rotate(var(--a)) translateY(-26px) scale(0.3); }
  }
</style>

Hold to confirm

A progress ring fills over 1200ms while you hold; releasing early cancels and drains it. Hold Space or Enter for the same on a keyboard. Use it for deleting or paying without a dialog. Also called press and hold, segurar para confirmar. Ask: “Use #344, hold to confirm, for deleting a project.”

Code
svelte
<script lang="ts">
  import { MotionStage, announce } from '@codeatlasdev/ui';
  import { Trash2, Check } from '@lucide/svelte';

  const uid = $props.id();
  // 1200ms hold, linear: progress is time / HOLD, so the ring fills at a constant rate.
  const HOLD = 1200;
  const R = 22;
  const C = 2 * Math.PI * R;

  let progress = $state(0);
  let done = $state(false);
  let holding = false;
  let raf = 0;
  let start = 0;

  function frame(now: number) {
    progress = Math.min(1, (now - start) / HOLD);
    if (progress >= 1) {
      holding = false;
      done = true;
      announce('Project deleted');
      return;
    }
    raf = requestAnimationFrame(frame);
  }
  function begin() {
    if (holding || done) return;
    holding = true;
    start = performance.now();
    raf = requestAnimationFrame(frame);
  }
  // Releasing early cancels: the ring drains back to 0.
  function cancel() {
    if (!holding) return;
    holding = false;
    cancelAnimationFrame(raf);
    progress = 0;
  }
  function reset() {
    cancel();
    done = false;
    progress = 0;
  }

  // Autoplay holds for the full duration, shows the result, then resets on the next cycle.
  const autoplay = (node: HTMLElement) => {
    reset();
    const t1 = setTimeout(begin, 400);
    return () => {
      clearTimeout(t1);
      reset();
    };
  };
</script>

<MotionStage label="Hold to confirm: press and hold to delete, release early to cancel" loop={3400}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="hold" {@attach (node) => (m.playing && !m.reduced ? autoplay(node) : undefined)}>
        <button
          type="button"
          class="hold__btn"
          class:hold__btn--done={done}
          aria-describedby="{uid}-hint"
          onpointerdown={begin}
          onpointerup={cancel}
          onpointerleave={cancel}
          onpointercancel={cancel}
          onkeydown={(e) => {
            if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
              e.preventDefault();
              begin();
            }
          }}
          onkeyup={(e) => (e.key === ' ' || e.key === 'Enter') && cancel()}
          onblur={cancel}
        >
          <svg class="hold__ring" viewBox="0 0 52 52" aria-hidden="true">
            <circle class="hold__track" cx="26" cy="26" r={R} />
            <circle
              class="hold__bar"
              cx="26"
              cy="26"
              r={R}
              stroke-dasharray={C}
              stroke-dashoffset={C * (1 - (m.reduced && !done ? 0 : progress))}
            />
          </svg>
          {#if done}<Check size={20} aria-hidden="true" />{:else}<Trash2 size={20} aria-hidden="true" />{/if}
          <span class="ca-sr-only">{done ? 'Deleted' : 'Delete project'}</span>
        </button>
        <p class="hold__text" id="{uid}-hint">
          {done ? 'Project deleted' : 'Hold 1.2 seconds to delete “Q3 roadmap”'}
        </p>
        {#if done}
          <button type="button" class="hold__undo" onclick={reset}>Undo</button>
        {/if}
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .hold {
    display: grid;
    justify-items: center;
    gap: var(--ca-space-3);
    max-inline-size: 280px;
    text-align: center;
  }
  .hold__btn {
    position: relative;
    display: grid;
    place-items: center;
    inline-size: 64px;
    block-size: 64px;
    padding: 0;
    border: none;
    border-radius: var(--ca-radius-pill);
    background: var(--ca-surface-raised);
    box-shadow: var(--ca-shadow-md);
    color: var(--ca-semantic-error);
    cursor: pointer;
    touch-action: none;
    user-select: none;
  }
  .hold__btn--done {
    color: var(--ca-semantic-success);
  }
  .hold__btn:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .hold__ring {
    position: absolute;
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    transform: rotate(-90deg);
    fill: none;
    stroke-width: 3;
  }
  .hold__track {
    stroke: var(--ca-outline-subtle);
  }
  .hold__bar {
    stroke: currentColor;
    stroke-linecap: round;
  }
  .hold__text {
    margin: 0;
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-body2);
  }
  .hold__undo {
    min-block-size: var(--ca-touch-target);
    padding-inline: var(--ca-space-4);
    border: none;
    background: none;
    color: var(--ca-brand-text);
    font: inherit;
    font-weight: var(--ca-font-weight-medium);
    cursor: pointer;
  }
</style>

Copy to check

Two sheets scale away in 280ms while a check mark draws itself in 320ms after a 120ms delay, then it reverts after 1.6 seconds. Use it beside any command or token. Also called copy confirmation, copiar e confirmar. Ask: “Use #345, a copy to check, on the install command.”

Code
svelte
<script lang="ts">
  import { MotionStage, announce } from '@codeatlasdev/ui';

  const CODE = 'npm i @codeatlasdev/ui';
  let copied = $state(false);
  let timer: ReturnType<typeof setTimeout> | undefined;

  function copy(real: boolean) {
    if (real) navigator.clipboard?.writeText(CODE).catch(() => {});
    copied = true;
    announce('Copied to clipboard');
    clearTimeout(timer);
    timer = setTimeout(() => (copied = false), 1600);
  }

  // Autoplay "clicks" copy without touching the real clipboard.
  const autoplay = (node: HTMLElement) => {
    copied = false;
    const t = setTimeout(() => copy(false), 500);
    return () => {
      clearTimeout(t);
      clearTimeout(timer);
      copied = false;
    };
  };
</script>

<MotionStage label="Copy to check: the copy icon morphs into a check mark" loop={2800}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="cc" {@attach (node) => (m.playing && !m.reduced ? autoplay(node) : undefined)}>
        <code class="cc__code">{CODE}</code>
        <button
          type="button"
          class="cc__btn"
          class:cc__btn--done={copied}
          class:cc__btn--still={m.reduced}
          onclick={() => copy(true)}
        >
          <svg viewBox="0 0 24 24" class="cc__icon" aria-hidden="true">
            <!-- Copy: two sheets. Check: one stroke that draws itself. They cross-fade and the sheets slide into the tick. -->
            <g class="cc__sheets">
              <rect x="9" y="9" width="11" height="11" rx="2" />
              <path d="M5 15V6a2 2 0 0 1 2-2h9" />
            </g>
            <path class="cc__tick" d="M5 12.5l4.5 4.5L19 7.5" pathLength="1" />
          </svg>
          <span class="ca-sr-only">{copied ? 'Copied' : 'Copy command'}</span>
        </button>
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .cc {
    display: flex;
    align-items: center;
    gap: var(--ca-space-2);
    padding: var(--ca-space-2) var(--ca-space-2) var(--ca-space-2) var(--ca-space-4);
    border: var(--ca-border-width-thin) solid var(--ca-outline-default);
    border-radius: var(--ca-radius-lg);
    background: var(--ca-surface-raised);
    max-inline-size: 100%;
  }
  .cc__code {
    font-family: var(--ca-font-mono);
    font-size: var(--ca-font-size-body2);
    overflow-wrap: anywhere;
  }
  .cc__btn {
    display: grid;
    place-items: center;
    flex: none;
    inline-size: var(--ca-touch-target);
    block-size: var(--ca-touch-target);
    padding: 0;
    border: none;
    border-radius: var(--ca-radius-md);
    background: none;
    color: var(--ca-text-secondary);
    cursor: pointer;
    transition: color 200ms cubic-bezier(0.2, 0, 0, 1);
  }
  .cc__btn--done {
    color: var(--ca-semantic-success);
  }
  .cc__btn:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .cc__icon {
    inline-size: 20px;
    block-size: 20px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
  }
  .cc__sheets {
    transform-origin: 12px 12px;
    transition:
      transform 280ms cubic-bezier(0.2, 0, 0, 1),
      opacity 200ms cubic-bezier(0.2, 0, 0, 1);
  }
  .cc__tick {
    stroke-dasharray: 1;
    stroke-dashoffset: 1;
    transition: stroke-dashoffset 320ms cubic-bezier(0.2, 0, 0, 1) 120ms;
  }
  .cc__btn--done .cc__sheets {
    transform: scale(0.6);
    opacity: 0;
  }
  .cc__btn--done .cc__tick {
    stroke-dashoffset: 0;
  }
  .cc__btn--still .cc__sheets,
  .cc__btn--still .cc__tick {
    transition: none;
  }
</style>

Add to cart fly

A dot flies from the button to the bag in 520ms, then the badge bumps to 1.5 and back in 360ms. Use it when an action lands somewhere else on screen. Also called fly to cart, adicionar ao carrinho. Ask: “Use #346, an add to cart fly, on the product card.”

Code
svelte
<script lang="ts">
  import { MotionStage, announce } from '@codeatlasdev/ui';
  import { ShoppingBag } from '@lucide/svelte';

  let count = $state(2);
  // Bumped on every add: remounts the flying dot and replays the badge bump.
  let flight = $state(0);
  let flying = $state(false);
  let timer: ReturnType<typeof setTimeout> | undefined;

  function add() {
    flight += 1;
    flying = true;
    clearTimeout(timer);
    timer = setTimeout(() => {
      count += 1;
      flying = false;
      announce(`Cart: ${count} items`);
    }, 520);
  }

  const autoplay = (node: HTMLElement) => {
    clearTimeout(timer);
    count = 2;
    flying = false;
    const t = setTimeout(add, 500);
    return () => {
      clearTimeout(t);
      clearTimeout(timer);
    };
  };
</script>

<MotionStage label="Add to cart: a dot flies to the cart and its count bumps" loop={2400}>
  {#snippet children(m)}
    {#key m.cycle}
      <div class="cart" {@attach (node) => (m.playing && !m.reduced ? autoplay(node) : undefined)}>
        <div class="cart__item">
          <span class="cart__thumb" aria-hidden="true"></span>
          <div class="cart__info">
            <p class="cart__name">Linen overshirt</p>
            <p class="cart__price">$89.00</p>
          </div>
          <button type="button" class="cart__add" onclick={add}>Add to cart</button>
          {#if flying && !m.reduced}
            {#key flight}<span class="cart__dot" aria-hidden="true"></span>{/key}
          {/if}
        </div>
        <div class="cart__bag" role="img" aria-label="Cart, {count} items">
          <ShoppingBag size={22} aria-hidden="true" />
          {#key count}
            <span class="cart__badge" class:cart__badge--bump={!m.reduced && count > 2} aria-hidden="true">{count}</span>
          {/key}
        </div>
      </div>
    {/key}
  {/snippet}
</MotionStage>

<style>
  .cart {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--ca-space-4);
    inline-size: 100%;
    max-inline-size: 360px;
  }
  .cart__item {
    position: relative;
    display: flex;
    flex: 1;
    align-items: center;
    gap: var(--ca-space-3);
    padding: var(--ca-space-3);
    border: var(--ca-border-width-thin) solid var(--ca-outline-default);
    border-radius: var(--ca-radius-lg);
    background: var(--ca-surface-raised);
    min-inline-size: 0;
  }
  .cart__thumb {
    flex: none;
    inline-size: 44px;
    block-size: 44px;
    border-radius: var(--ca-radius-md);
    background: var(--ca-brand-light);
  }
  .cart__info {
    flex: 1;
    min-inline-size: 0;
  }
  .cart__name,
  .cart__price {
    margin: 0;
  }
  .cart__name {
    font-weight: var(--ca-font-weight-semibold);
    font-size: var(--ca-font-size-body2);
  }
  .cart__price {
    color: var(--ca-text-secondary);
    font-size: var(--ca-font-size-caption);
  }
  .cart__add {
    min-block-size: var(--ca-touch-target);
    padding-inline: var(--ca-space-3);
    border: none;
    border-radius: var(--ca-radius-control);
    background: var(--ca-brand-fill);
    color: var(--ca-brand-contrast);
    font: inherit;
    font-size: var(--ca-font-size-body2);
    font-weight: var(--ca-font-weight-medium);
    cursor: pointer;
    white-space: nowrap;
  }
  .cart__add:focus-visible {
    outline: var(--ca-border-width-focus) solid var(--ca-focus-ring);
    outline-offset: var(--ca-focus-ring-offset);
  }
  .cart__bag {
    position: relative;
    display: grid;
    place-items: center;
    flex: none;
    inline-size: var(--ca-touch-target);
    block-size: var(--ca-touch-target);
    color: var(--ca-text-primary);
  }
  .cart__badge {
    position: absolute;
    inset-block-start: 2px;
    inset-inline-end: 0;
    display: grid;
    place-items: center;
    min-inline-size: 18px;
    block-size: 18px;
    padding-inline: var(--ca-space-1);
    border-radius: var(--ca-radius-pill);
    background: var(--ca-brand-fill);
    color: var(--ca-brand-contrast);
    font-size: var(--ca-font-size-nano);
    font-weight: var(--ca-font-weight-bold);
    font-variant-numeric: tabular-nums;
  }
  .cart__badge--bump {
    animation: cart-bump 360ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
  }
  /* The dot leaves the Add button (right of the item) and lands on the bag. */
  .cart__dot {
    position: absolute;
    inset-inline-end: var(--ca-space-6);
    inset-block-start: 50%;
    inline-size: 12px;
    block-size: 12px;
    margin-block-start: -6px;
    border-radius: var(--ca-radius-pill);
    background: var(--ca-brand-fill);
    pointer-events: none;
    animation: cart-fly 520ms cubic-bezier(0.4, 0, 0.2, 1) both;
  }
  @keyframes cart-fly {
    0% { opacity: 1; transform: translate(0, 0) scale(1); }
    60% { opacity: 1; transform: translate(40px, -26px) scale(0.9); }
    100% { opacity: 0; transform: translate(72px, -4px) scale(0.4); }
  }
  @keyframes cart-bump {
    0% { transform: scale(1); }
    45% { transform: scale(1.5); }
    100% { transform: scale(1); }
  }
</style>