Squiggle

Loose sketches and half-thoughts, saved before they slip away.
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState } from 'react';

const WAVE = `M0 5 ${Array.from({ length: 12 }, (_, i) => `Q${i * 10 + 5} ${i % 2 ? 10 : 0} ${(i + 1) * 10} 5`).join(' ')}`;
const TABS = [
  { label: 'Ideas', body: 'Loose sketches and half-thoughts, saved before they slip away.' },
  { label: 'Drafts', body: 'The ideas that grew a title and a first paragraph.' },
  { label: 'Done', body: 'Finished pieces, ready to share or to file away.' },
];

const css = `
  @keyframes tabs-squiggle-wave-a { 0% { opacity: 1; transform: scaleY(0.1); } 30% { transform: scaleY(1); } 70% { transform: scaleY(0.7); } 100% { opacity: 1; transform: scaleY(0.1); } }
  @keyframes tabs-squiggle-wave-b { 0% { opacity: 1; transform: scaleY(0.1); } 30% { transform: scaleY(1); } 70% { transform: scaleY(0.7); } 100% { opacity: 1; transform: scaleY(0.1); } }
  @keyframes tabs-squiggle-rest-a { 0%, 100% { opacity: 0; } }
  @keyframes tabs-squiggle-rest-b { 0%, 100% { opacity: 0; } }
  .tabs-squiggle { width: 260px; }
  .tabs-squiggle__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); padding-bottom: 10px; }
  .tabs-squiggle__tab { padding: 10px 0 6px; border: 0; border-radius: 6px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; transition: color 0.2s; }
  .tabs-squiggle__tab:hover { color: light-dark(#42454c, #e5e6e9); }
  .tabs-squiggle__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
  .tabs-squiggle__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; }
  .tabs-squiggle__line { position: absolute; bottom: 0; left: calc(var(--tabs-squiggle-i) / 3 * 100% + 16px); width: calc(100% / 3 - 32px); height: 10px; transition: left 0.5s cubic-bezier(0.45, 0, 0.2, 1); }
  .tabs-squiggle__line::before { content: ''; position: absolute; left: 0; right: 0; top: 4px; height: 2.5px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); }
  .tabs-squiggle__line svg { display: block; width: 100%; height: 100%; overflow: visible; opacity: 0; }
  .tabs-squiggle[data-flip='a'] .tabs-squiggle__line::before { animation: tabs-squiggle-rest-a 0.7s; }
  .tabs-squiggle[data-flip='b'] .tabs-squiggle__line::before { animation: tabs-squiggle-rest-b 0.7s; }
  .tabs-squiggle[data-flip='a'] .tabs-squiggle__line svg { animation: tabs-squiggle-wave-a 0.7s ease-in-out; }
  .tabs-squiggle[data-flip='b'] .tabs-squiggle__line svg { animation: tabs-squiggle-wave-b 0.7s ease-in-out; }
  .tabs-squiggle__line path { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.5; stroke-linecap: round; vector-effect: non-scaling-stroke; }
  .tabs-squiggle__panel { padding: 12px 4px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
  @media (prefers-reduced-motion: reduce) {
    .tabs-squiggle__line { transition: none; }
    .tabs-squiggle__line svg, .tabs-squiggle__line::before { animation: none !important; }
  }
`;

export function SquiggleTabs() {
  const id = useId();
  const [sel, setSel] = useState(0);
  const [flip, setFlip] = useState(0);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const select = (i: number) => {
    if (i === sel) return;
    setSel(i);
    setFlip((f) => f + 1);
  };
  const onKeyDown = (e: React.KeyboardEvent) => {
    const n = TABS.length;
    const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
    if (next === undefined) return;
    e.preventDefault();
    select(next);
    tabs.current[next]?.focus();
  };
  return (
    <>
      <style>
        {css}
      </style>
      <div className="tabs-squiggle" data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined} style={{ '--tabs-squiggle-i': sel } as React.CSSProperties}>
        <div className="tabs-squiggle__list" role="tablist" aria-label="Notes" onKeyDown={onKeyDown}>
          {TABS.map((t, i) => (
            <button
              key={t.label}
              ref={(el) => {
                tabs.current[i] = el;
              }}
              className="tabs-squiggle__tab"
              type="button"
              role="tab"
              id={`${id}-tab-${i}`}
              aria-selected={sel === i}
              aria-controls={`${id}-panel-${i}`}
              tabIndex={sel === i ? 0 : -1}
              onClick={() => select(i)}
            >
              {t.label}
            </button>
          ))}
          <span className="tabs-squiggle__line" aria-hidden="true">
            <svg viewBox="0 0 120 10" preserveAspectRatio="none">
              <path d={WAVE} />
            </svg>
          </span>
        </div>
        {TABS.map((t, i) => (
          <div key={t.label} className="tabs-squiggle__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
            {t.body}
          </div>
        ))}
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/tab-squiggle
Source on GitHub

About this component

Notes tabs with a playful underline: it wiggles into a wave while it travels to the new tab and lies flat again once it arrives.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, morph
Techniques
css variables, keyframes, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Under prefers-reduced-motion the underline moves without the wave.

More tabs

See all
Personal•••• 4242Debit
Stack
Call the printer
Send invoice
Water plants
Sticky
Sunrise run5 km along the river, fog still on the water.
Stories
Four streams on air, one of them from your team.
Torch