Swish UI
All componentsButtons and controls Free

Arrow nudge links

Link rows whose arrows act out where they go: forward loops through, external tilts out, download drops in a tray.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, type MouseEvent, type ReactElement } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

type Kind = "forward" | "external" | "download" | "back";
const LINKS: { kind: Kind; title: string; text: string; label: string }[] = [
  { kind: "forward", title: "Read the changelog", text: "Everything we shipped this week", label: "Read the changelog" },
  { kind: "external", title: "API reference", text: "Opens docs.example.com in a new tab", label: "API reference, opens in a new tab" },
  { kind: "download", title: "Download the CLI", text: "macOS, Linux and Windows, 12 MB", label: "Download the CLI" },
  { kind: "back", title: "All guides", text: "Back to the guide index", label: "Back to all guides" },
];

const ICONS: Record<Kind, ReactElement> = {
  forward: <g className={styles.move}><path d="M4 12h15M13 6l6 6-6 6" /></g>,
  external: <g className={styles.move}><path d="M5 12h13M12 6l6 6-6 6" /></g>,
  download: <><g className={styles.move}><path d="M12 3v11M7 9.5l5 5 5-5" /></g><path className={styles.tray} d="M4 16v3.5h16V16" /></>,
  back: <g className={styles.move}><path className={styles.shaft} d="M5 12h14" /><path d="M11 6l-6 6 6 6" /></g>,
};

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const hovering = useRef(false);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();

  // Replays a row's arrow once, for taps on touch screens and the idle tour.
  const play = (row: HTMLElement) => {
    delete row.dataset.go;
    void row.offsetWidth;
    row.dataset.go = "";
  };

  // Idle tour: while nobody is pointing at the list, play each arrow in turn so a static screen shows them.
  useEffect(() => {
    const el = root.current;
    if (!el || reduced || !inView) return;
    const rows = [...el.querySelectorAll<HTMLElement>("a")];
    let i = 0;
    const timer = setInterval(() => {
      if (hovering.current || el.contains(document.activeElement)) return;
      play(rows[i++ % rows.length]);
    }, 1400);
    return () => clearInterval(timer);
  }, [inView, reduced]);

  const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
    e.preventDefault();
    play(e.currentTarget);
  };

  return (
    <div
      ref={root}
      className={styles.root}
      onPointerEnter={(e) => { if (e.pointerType === "mouse") hovering.current = true; }}
      onPointerLeave={() => { hovering.current = false; }}
    >
      <nav className={styles.list} aria-label="Resources">
        {LINKS.map((l) => (
          <a
            key={l.kind}
            href="#"
            className={styles.row}
            data-kind={l.kind}
            aria-label={l.label}
            onClick={onClick}
            onAnimationEnd={(e) => { if (e.target !== e.currentTarget && (e.target as Element).classList.contains(styles.move)) delete e.currentTarget.dataset.go; }}
          >
            <span className={styles.text}>
              <span className={styles.title}>{l.title}</span>
              <span className={styles.sub}>{l.text}</span>
            </span>
            <span className={styles.icon} aria-hidden="true">
              <svg viewBox="0 0 24 24">{ICONS[l.kind]}</svg>
            </span>
          </a>
        ))}
      </nav>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 14px;
  box-sizing: border-box; background: var(--surface); font-family: system-ui, sans-serif;
}
.list { width: min(640px, 100%); display: grid; gap: 8px; }

.row {
  display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 10px 10px 10px 14px; box-sizing: border-box;
  border-radius: 12px; border: 1px solid var(--line); background: var(--white); color: var(--ink); text-decoration: none;
  transition: border-color .25s, background-color .25s, transform .15s var(--out);
}
.row:hover, .row:focus-visible { border-color: var(--accent-soft); }
.row:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.row:active { transform: scale(.985); }
/* The back link reads right to left: arrow first. */
.row[data-kind="back"] { flex-direction: row-reverse; padding-left: 10px; padding-right: 14px; }
.text { flex: 1; min-width: 0; display: grid; gap: 2px; }
.title { font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.icon {
  flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; overflow: hidden;
  background: var(--surface); color: var(--ink); transition: background-color .25s, color .25s;
}
.row:hover .icon, .row:focus-visible .icon, .row[data-go] .icon { background: var(--accent-pale); color: var(--accent-text); }
.icon svg { width: 18px; height: 18px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.move, .shaft, .tray { transform-box: fill-box; transform-origin: center; }

/* Forward: out to the right, back in from the left. */
.row[data-kind="forward"]:is(:hover, :focus-visible, [data-go]) .move { animation: through .5s var(--out); }
@keyframes through {
  40% { translate: 18px 0; opacity: 0; animation-timing-function: steps(1); }
  41% { translate: -18px 0; opacity: 0; }
}
/* External: turns to point up and right, leaves along that line and comes back from the opposite corner. */
.row[data-kind="external"] .move { transition: rotate .3s var(--out); }
.row[data-kind="external"]:is(:hover, :focus-visible, [data-go]) .move { rotate: -45deg; animation: diagonal .55s .12s var(--out) backwards; }
@keyframes diagonal {
  40% { translate: 16px -16px; opacity: 0; animation-timing-function: steps(1); }
  41% { translate: -16px 16px; opacity: 0; }
}
/* Download: drops through the tray line, the tray dips, then the arrow falls back in from the top. */
.row[data-kind="download"]:is(:hover, :focus-visible, [data-go]) .move { animation: drop .6s var(--out); }
.row[data-kind="download"]:is(:hover, :focus-visible, [data-go]) .tray { animation: dip .6s var(--out); }
@keyframes drop {
  40% { translate: 0 18px; opacity: 0; animation-timing-function: steps(1); }
  41% { translate: 0 -18px; opacity: 0; }
}
@keyframes dip { 30% { translate: 0 2px; } }
/* Back: slides left while the shaft stretches behind it. */
.row[data-kind="back"] .move { transition: translate .35s var(--out); }
.row[data-kind="back"] .shaft { transform-origin: left; transition: scale .35s var(--out); }
.row[data-kind="back"]:is(:hover, :focus-visible) .move { translate: -3px 0; }
.row[data-kind="back"]:is(:hover, :focus-visible) .shaft { scale: 1.25 1; }
.row[data-kind="back"][data-go] .move { animation: back .6s var(--out); }
@keyframes back { 45% { translate: -4px 0; } }

/* Wide stages: a two by two grid of cards. */
@container (min-width: 620px) {
  .list { width: min(760px, 100%); grid-template-columns: 1fr 1fr; gap: 12px; }
  .row { min-height: 84px; padding: 14px 14px 14px 20px; border-radius: 14px; }
  .row[data-kind="back"] { padding-left: 14px; padding-right: 20px; }
  .title { font-size: 15px; }
  .sub { font-size: 13px; }
}
/* Short tiles: titles only. */
@container (max-height: 300px) {
  .sub { display: none; }
  .list { gap: 6px; }
  .row { min-height: 44px; padding-block: 5px; }
}

@media (prefers-reduced-motion: reduce) {
  .row .move, .row .tray, .row .shaft { animation: none !important; transition: none; }
}

Use it for

  • Resource lists, footers and docs indexes with mixed link types
  • Card links where the destination type matters (new tab, file, back)
  • Call to action rows that need a quiet hover

Avoid

  • Dense navigation menus, where moving icons distract
  • Buttons that run an action in place: arrows promise navigation

How it works

Each row is a link with a data-kind, and its arrow group runs a short CSS keyframe on hover, focus-visible or a data-go attribute: the arrow translates out, jumps to the opposite side on a steps(1) keyframe while invisible, and eases back in. The external arrow also transitions rotate to minus 45 degrees, the download tray dips as the arrow passes, and the back arrow slides left while its shaft scales out behind it. Taps and an idle tour replay a row by re-adding data-go, which animationend removes.

Accessibility

Rows are real links with focus-visible outlines, and keyboard focus plays the same motion as hover. The external link's accessible name says it opens a new tab. Reduced motion keeps the colour change and the static 45 degree tilt but drops every arrow animation and the idle tour.

Found on