Freight

  • motion
  • ui kit
No dependencies
Code format
React + Tailwind
import { useId, useRef, useState, type CSSProperties } from 'react';

const MAX = 3;
const wagonWidth = (t: string) => Math.max(38, Math.min(64, 14 + t.length * 5.8));

const css = `
  .field-freight { --steel: light-dark(#50535b, #7d8087); --body: light-dark(#6d7179, #393c41); --plank: color-mix(in oklab, var(--body) 80%, light-dark(#fbfcfd, #121417)); --paint: light-dark(#42454c, #f1f2f3); --label: light-dark(#fbfcfd, #f1f2f3); display: grid; gap: 10px; width: 262px; }
  .field-freight__yard { position: relative; height: 92px; overflow: hidden; }
  .field-freight__rails { position: absolute; left: 0; right: 0; bottom: 6px; height: 7px; background: linear-gradient(var(--steel), var(--steel)) 0 0 / 100% 2px no-repeat, repeating-linear-gradient(90deg, color-mix(in oklab, var(--steel) 55%, transparent) 0 5px, transparent 5px 12px) 0 2px / 100% 5px no-repeat; }
  .field-freight__train { position: absolute; left: 4px; bottom: 13px; display: flex; align-items: flex-end; }
  .field-freight__train[data-jolt='0'] { animation: field-freight-jolt-a 0.45s ease-out 0.62s; }
  .field-freight__train[data-jolt='1'] { animation: field-freight-jolt-b 0.45s ease-out 0.62s; }
  @keyframes field-freight-jolt-a { 25% { transform: translateX(-3px); } 60% { transform: translateX(1px); } }
  @keyframes field-freight-jolt-b { 25% { transform: translateX(-3px); } 60% { transform: translateX(1px); } }
  /* The engine, facing left, with its chimney and three drivers. */
  .field-freight__loco { position: relative; width: 60px; height: 46px; flex: none; }
  .field-freight__loco i { position: absolute; display: block; background: var(--paint); }
  .field-freight__wheel { position: absolute; bottom: -4px; width: 11px; height: 11px; border-radius: 50%; background: radial-gradient(circle, var(--steel) 0 2px, light-dark(#fbfcfd, #121417) 2.5px 3.5px, var(--steel) 4px); box-shadow: 0 0 0 1px light-dark(#fbfcfd, #121417); }
  .field-freight__puff { position: absolute; left: 9px; top: -6px; width: 10px; height: 10px; border-radius: 50%; background: color-mix(in oklab, light-dark(#50535b, #c6c8cd) 55%, transparent); opacity: 0; animation: field-freight-puff 1.2s ease-out forwards; }
  @keyframes field-freight-puff { 0% { opacity: 0.9; transform: scale(0.4); } 100% { opacity: 0; transform: translate(var(--dx), -34px) scale(2.2); } }
  .field-freight__link { width: 6px; height: 3px; margin-bottom: 7px; flex: none; background: var(--steel); }
  /* A wagon: plank sides, a stencilled tag, rolled in from the right to couple on. */
  .field-freight__wagon { position: relative; flex: none; height: 34px; border-radius: 2px; background: repeating-linear-gradient(90deg, var(--body) 0 6px, var(--plank) 6px 7px); box-shadow: inset 0 2px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 20%, transparent), inset 0 -3px 0 var(--plank); animation: field-freight-roll 0.75s cubic-bezier(0.15, 0.6, 0.3, 1) both; }
  @keyframes field-freight-roll { from { transform: translateX(240px); } }
  .field-freight__wagon .field-freight__wheel { width: 9px; height: 9px; bottom: -5px; }
  .field-freight__tag { position: absolute; left: 0; right: 0; top: 11px; color: var(--label); font: 400 9px/1 Stencil, 'Stencil Std', Impact, 'Arial Black', sans-serif; letter-spacing: 0.06em; text-align: center; text-transform: uppercase; white-space: nowrap; }
  .field-freight__off { position: absolute; right: -4px; top: -6px; display: grid; place-items: center; width: 14px; height: 14px; padding: 0; border: 0; border-radius: 50%; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font: 600 10px/1 system-ui, sans-serif; opacity: 0; cursor: pointer; transition: opacity 0.2s; }
  .field-freight__wagon:hover .field-freight__off, .field-freight__off:focus-visible { opacity: 1; }
  .field-freight__off:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); }
  .field-freight__gone { position: absolute; bottom: 13px; pointer-events: none; animation: field-freight-away 0.9s cubic-bezier(0.5, 0, 0.9, 0.6) forwards; }
  @keyframes field-freight-away { to { transform: translateX(260px); } }
  .field-freight__slip { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 6px 0 12px; border-radius: 8px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
  .field-freight__slip:has(.field-freight__input:focus-visible) { box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087), 0 0 0 2px light-dark(#5f626a, #a7abb2); }
  .field-freight__slip[data-full='0'] { animation: field-freight-full-a 0.3s; }
  .field-freight__slip[data-full='1'] { animation: field-freight-full-b 0.3s; }
  @keyframes field-freight-full-a { 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } }
  @keyframes field-freight-full-b { 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } }
  .field-freight__input { flex: 1; min-width: 0; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: light-dark(#373a40, #f1f2f3); font: 500 14px system-ui, sans-serif; outline: none; }
  .field-freight__input::placeholder { color: light-dark(#6d7179, #7d8087); }
  .field-freight__key { padding: 3px 6px; border-radius: 5px; background: light-dark(#ebedf0, #232529); color: light-dark(#5f626a, #a7abb2); font: 600 10px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }
  @media (prefers-reduced-motion: reduce) {
    .field-freight * { animation: none !important; }
    .field-freight__gone { display: none; }
  }
`;

function Wheels({ at }: { at: number[] }) {
  return (
    <>
      {at.map((l) => (
        <span key={l} className="field-freight__wheel" style={{ left: l }} />
      ))}
    </>
  );
}

export function Preview() {
  const id = useId();
  const [tags, setTags] = useState(['motion', 'ui kit']);
  const [draft, setDraft] = useState('');
  const [adds, setAdds] = useState(0);
  const [full, setFull] = useState(0);
  const [gone, setGone] = useState<{ tag: string; x: number; n: number } | null>(null);
  const n = useRef(0);
  const add = () => {
    const t = draft.trim().slice(0, 9);
    if (!t) return;
    if (tags.length >= MAX || tags.includes(t)) return setFull((f) => f + 1);
    setTags([...tags, t]);
    setDraft('');
    setAdds((a) => a + 1);
  };
  const remove = (i: number) => {
    // The uncoupled wagon rolls away from where it stood.
    const x = 4 + 60 + tags.slice(0, i).reduce((s, t) => s + 6 + wagonWidth(t), 0) + 6;
    setGone({ tag: tags[i], x, n: ++n.current });
    setTags(tags.filter((_, k) => k !== i));
  };
  return (
    <>
      <style href="kit-inputs-freight" precedence="kit">
        {css}
      </style>
      <div className="field-freight">
        <div className="field-freight__yard">
          <span className="field-freight__rails" aria-hidden="true" />
          <div className="field-freight__train" data-jolt={adds ? adds % 2 : undefined}>
            <span className="field-freight__loco" aria-hidden="true">
              <i style={{ left: 6, top: 4, width: 7, height: 12, borderRadius: '2px 2px 0 0' }} />
              <i style={{ left: 2, top: 14, width: 40, height: 20, borderRadius: '9px 2px 2px 9px' }} />
              <i style={{ left: 38, top: 2, width: 20, height: 32, borderRadius: 2 }} />
              <i style={{ left: 42, top: 7, width: 10, height: 8, background: 'light-dark(#fbfcfd, #121417)', borderRadius: 1 }} />
              <i style={{ left: 0, top: 33, width: 58, height: 5, borderRadius: 1 }} />
              <Wheels at={[6, 22, 42]} />
              <span key={adds} className="field-freight__puff" style={{ '--dx': '-14px' } as CSSProperties} />
            </span>
            <ul style={{ display: 'flex', alignItems: 'flex-end', margin: 0, padding: 0, listStyle: 'none' }} aria-label="Tags">
              {tags.map((t, i) => (
                <li key={t} style={{ display: 'flex', alignItems: 'flex-end' }}>
                  <span className="field-freight__link" aria-hidden="true" />
                  <span className="field-freight__wagon" style={{ width: wagonWidth(t) }}>
                    <span className="field-freight__tag">{t}</span>
                    <Wheels at={[5, wagonWidth(t) - 14]} />
                    <button type="button" className="field-freight__off" aria-label={`Remove ${t}`} onClick={() => remove(i)}>
                      ×
                    </button>
                  </span>
                </li>
              ))}
            </ul>
          </div>
          {gone && (
            <span key={gone.n} className="field-freight__gone" style={{ left: gone.x }} aria-hidden="true" onAnimationEnd={() => setGone(null)}>
              <span className="field-freight__wagon" style={{ display: 'block', width: wagonWidth(gone.tag), animation: 'none' }}>
                <span className="field-freight__tag">{gone.tag}</span>
                <Wheels at={[5, wagonWidth(gone.tag) - 14]} />
              </span>
            </span>
          )}
        </div>
        <label className="field-freight__slip" data-full={full ? full % 2 : undefined}>
          <input
            id={id}
            className="field-freight__input"
            aria-label="Add a tag"
            placeholder={tags.length >= MAX ? 'Train is full' : 'Add a tag'}
            maxLength={9}
            value={draft}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' || e.key === ',') {
                e.preventDefault();
                add();
              } else if (e.key === 'Backspace' && !draft && tags.length) remove(tags.length - 1);
            }}
          />
          <span className="field-freight__key" aria-hidden="true">
            Enter ↵
          </span>
        </label>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/input-freight
Source on GitHub

About this component

A tag field as a goods train: press Enter and a wagon with your tag rolls in and couples on with a jolt and a puff of smoke; Backspace in the empty field uncouples the last one.

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

A native input named "Add a tag" with a placeholder that says when the train is full. The tags are a list labelled "Tags", and each wagon's remove button is named "Remove <tag>" and shows on hover or keyboard focus. The engine and the rolling copies are aria-hidden. Under prefers-reduced-motion wagons appear and leave without rolling.

More inputs

See all
Growth
Jersey
Key
Notebook