Swish UI
All componentsFeedback and forms Free

Scanner bar

A bright segment sweeps under a search field with a fading afterglow, then fills the bar and shows the results.

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

Demo.tsx

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

const SEARCH_MS = 2600;
const AGAIN_MS = 3800; // the demo searches again on its own while nobody is typing
const TRAIL = 4; // the head plus three fading afterglow segments

const results = (q: string) => {
  const word = q.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 18) || "invoice";
  return [
    { name: `${word}-2026-10.pdf`, where: "Finance" },
    { name: `${word}-template.docx`, where: "Shared" },
    { name: `q3-${word}-export.csv`, where: "Reports" },
  ];
};

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const id = useId();
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [query, setQuery] = useState("invoice");
  const [shown, setShown] = useState("invoice");
  const [searching, setSearching] = useState(true);
  const [run, setRun] = useState(0);
  const [focused, setFocused] = useState(false);

  const search = (e?: FormEvent) => {
    e?.preventDefault();
    setShown(query);
    setRun((r) => r + 1);
    setSearching(true);
  };

  // Reduced motion opens on the finished search.
  useEffect(() => {
    if (reduced) setSearching(false);
  }, [reduced]);

  useEffect(() => {
    if (!inView) return;
    if (searching) {
      const t = setTimeout(() => setSearching(false), reduced ? 700 : SEARCH_MS);
      return () => clearTimeout(t);
    }
    if (reduced || focused) return;
    const t = setTimeout(search, AGAIN_MS);
    return () => clearTimeout(t);
  }, [searching, inView, reduced, focused, run]);

  return (
    <div ref={root} className={styles.root}>
      <section className={styles.panel} data-state={searching ? "searching" : "done"} aria-label="File search">
        <form className={styles.field} onSubmit={search} role="search">
          <svg className={styles.glass} viewBox="0 0 20 20" aria-hidden="true"><circle cx="9" cy="9" r="5.5" /><path d="m13.2 13.2 3.3 3.3" /></svg>
          <label className={styles.sr} htmlFor={id}>Search files</label>
          <input
            id={id}
            className={styles.input}
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            autoComplete="off"
            spellCheck={false}
          />
          <kbd className={styles.kbd} aria-hidden="true">Enter</kbd>
        </form>
        <div className={styles.track} aria-hidden="true">
          {searching ? (
            <span key={run} className={styles.scan}>
              {Array.from({ length: TRAIL }, (_, i) => (
                <span key={i} className={styles.rail} style={{ animationDelay: `${i * 70}ms`, opacity: [1, 0.5, 0.26, 0.12][i] }}>
                  <span className={styles.seg} data-head={i === 0 || undefined} />
                </span>
              ))}
            </span>
          ) : (
            <span key={run} className={styles.fill} />
          )}
        </div>
        <div className={styles.status}>
          <p role="status" className={styles.count}>
            {searching ? (
              <span key="s" className={styles.label}>Searching 2,340 files<span className={styles.ellipsis}>...</span></span>
            ) : (
              <span key="d" className={styles.label}>
                <svg viewBox="0 0 16 16" aria-hidden="true"><path d="m3.5 8.5 3 3 6-7" /></svg>
                12 results for &ldquo;{shown.trim() || "invoice"}&rdquo;
              </span>
            )}
          </p>
          <button type="button" className={styles.again} onClick={() => search()} disabled={searching}>
            Search again
          </button>
        </div>
        <ul className={styles.list} aria-label="Top results">
          {!searching && results(shown).map((r, i) => (
            <li key={`${run}-${i}`} className={styles.item} style={{ animationDelay: `${120 + i * 70}ms` }}>
              <span className={styles.file} aria-hidden="true" />
              <span className={styles.name}>{r.name}</span>
              <span className={styles.where}>{r.where}</span>
            </li>
          ))}
          {searching && Array.from({ length: 3 }, (_, i) => <li key={i} className={styles.ghost} aria-hidden="true" />)}
        </ul>
      </section>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
  box-sizing: border-box; background: var(--surface);
}
.panel {
  width: min(460px, 100%); display: grid; gap: 0; border-radius: 16px; border: 1px solid var(--line); background: var(--white);
  overflow: hidden; box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--ink) 45%, transparent);
}
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.field { display: flex; align-items: center; gap: 10px; padding: 0 14px; height: 50px; }
.field:focus-within { background: var(--surface); }
.glass { flex: none; width: 18px; height: 18px; fill: none; stroke: var(--muted); stroke-width: 1.8; stroke-linecap: round; }
.input {
  flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; background: none; color: var(--ink);
  font: inherit; font-size: 15px; font-weight: 550; outline: none;
}
.kbd {
  flex: none; padding: 2px 7px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface);
  color: var(--muted); font: 600 11px system-ui, sans-serif;
}

/* The scanner: four stacked rails run the same sweep, each a little later and fainter, so a glow trails the head. */
.track { position: relative; height: 3px; background: var(--line); }
.scan { position: absolute; inset: 0; animation: fade-in .3s var(--out); }
.rail { position: absolute; inset: 0; animation: sweep 1.5s var(--out) infinite backwards; }
.seg { position: absolute; left: 0; top: 0; bottom: 0; width: 26%; border-radius: 2px; background: linear-gradient(90deg, transparent, var(--accent) 35%, var(--accent) 65%, transparent); }
.seg[data-head] { background: linear-gradient(90deg, transparent, var(--accent) 30%, var(--accent-text) 50%, var(--accent) 70%, transparent); box-shadow: 0 0 10px 1px var(--accent); }
@keyframes sweep {
  0%, 100% { translate: -6% 0; }
  50% { translate: 80% 0; }
}
@keyframes fade-in { from { opacity: 0; } }
.fill { position: absolute; inset: 0; background: var(--accent-text); transform-origin: left; animation: fill .55s var(--out) both; }
@keyframes fill { from { scale: .3 1; opacity: .6; } }

.status { display: flex; align-items: center; gap: 10px; padding: 12px 14px 8px; }
.count { flex: 1; min-width: 0; margin: 0; font-size: 13px; font-weight: 650; }
.label { display: inline-flex; align-items: center; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; animation: label-in .35s var(--out); }
.panel[data-state="searching"] .label { color: var(--muted); }
.label svg { flex: none; margin-right: 6px; width: 15px; height: 15px; fill: none; stroke: var(--accent-text); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 16; animation: draw .4s var(--out) .15s both; }
@keyframes label-in { from { opacity: 0; translate: 0 5px; } }
@keyframes draw { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } }
.ellipsis { display: inline-block; width: 1.2em; overflow: hidden; vertical-align: bottom; animation: dots 1.2s steps(4) infinite; }
@keyframes dots { from { width: 0; } to { width: 1.2em; } }

.again {
  flex: none; min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--white);
  color: var(--ink); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer;
  transition: transform .18s var(--out), opacity .3s, background-color .2s;
}
.again:hover:not(:disabled) { background: var(--surface); }
.again:active:not(:disabled) { transform: scale(.95); }
.again:disabled { opacity: .45; cursor: default; }
.again:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }

.list { list-style: none; margin: 0; padding: 0 8px 10px; display: grid; gap: 2px; }
.item, .ghost { display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px; border-radius: 8px; box-sizing: border-box; }
.item { animation: label-in .4s var(--out) both; font-size: 13px; }
.item:first-child { background: var(--accent-pale); }
.file { flex: none; width: 14px; height: 17px; border-radius: 3px; border: 1.5px solid var(--muted); box-sizing: border-box; }
.item:first-child .file { border-color: var(--accent-text); }
.name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 550; }
.where { flex: none; color: var(--muted); font-size: 12px; }
.ghost::before { content: ""; width: 14px; height: 17px; border-radius: 3px; background: var(--surface); }
.ghost::after { content: ""; flex: 1; max-width: 55%; height: 8px; border-radius: 4px; background: var(--surface); }
.ghost:nth-child(2)::after { max-width: 40%; }
.ghost:nth-child(3)::after { max-width: 48%; }

@container (max-height: 270px) {
  .root { padding: 10px; }
  .field { height: 42px; }
  .status { padding-block: 8px 4px; }
  .item, .ghost { height: 30px; }
  .item:nth-child(3), .ghost:nth-child(3) { display: none; }
}
@container (max-width: 340px) {
  .kbd, .where { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .scan, .rail, .fill, .label, .label svg, .item, .ellipsis { animation: none; }
  .rail { translate: 37% 0; }
  .rail:not(:first-child) { display: none; }
}

Use it for

  • Search, indexing and sync where the duration is unknown
  • Command palettes and launchers
  • Any indeterminate wait under a field or a header

Avoid

  • Work with a known length, where a determinate bar tells more
  • Waits under half a second, where the sweep only flickers

How it works

Four stacked rails run the same back and forth keyframe, each 70ms later and fainter than the one before, so the trailing copies read as an afterglow behind the head whichever way it moves. Each half of the sweep uses the shared ease out curve, so the light rushes off and settles at the ends. When the search finishes the rails unmount and a fill scales the bar to full width while the result count and rows rise in.

Accessibility

The input is labelled and submits with Enter, Search again is a real button and the count is a status region so the result is announced. Auto search pauses off screen and while the field is focused. Reduced motion opens on the results and shows a still segment while searching.

Found on