Flatbed

No photo yet · JPG, PNG or WebP

No dependencies
Code format
React + Tailwind
import { useEffect, useId, useRef, useState } from 'react';

type Scan = { url: string; name: string; size: string; done: boolean };
const size = (n: number) => (n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1e3))} KB`);

const css = `
  .field-flatbed { display: grid; justify-items: center; gap: 9px; width: 262px; }
  .field-flatbed__bed { position: relative; display: block; width: 250px; height: 156px; border-radius: 12px; background: linear-gradient(light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent), inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 20px -10px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); cursor: pointer; }
  .field-flatbed__bed::before { content: ''; position: absolute; left: 22px; right: 22px; top: 6px; height: 7px; border-radius: 3px; background: repeating-linear-gradient(90deg, light-dark(#cbced3, #393c41) 0 30px, transparent 30px 176px); }
  .field-flatbed__bed:has(.field-flatbed__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
  .field-flatbed__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  /* The glass: dark, with a sheen that slides across when something is dragged over. */
  .field-flatbed__glass { position: absolute; left: 12px; right: 12px; top: 20px; bottom: 14px; overflow: hidden; border-radius: 4px; background: linear-gradient(115deg, transparent 30%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 7%, transparent) 42%, transparent 52%) -120px 0 / 200% 100% no-repeat, light-dark(#fbfcfd, #121417); box-shadow: inset 0 2px 6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); transition: background-position 0.6s ease-out; }
  .field-flatbed:hover .field-flatbed__glass, .field-flatbed[data-over] .field-flatbed__glass { background-position: 120px 0, 0 0; }
  .field-flatbed__hint { position: absolute; inset: 10px; display: grid; place-content: center; justify-items: center; gap: 4px; border-radius: 4px; background: linear-gradient(90deg, light-dark(#cbced3, #393c41) 50%, transparent 50%) 0 0 / 10px 1.5px repeat-x, linear-gradient(90deg, light-dark(#cbced3, #393c41) 50%, transparent 50%) 0 100% / 10px 1.5px repeat-x, linear-gradient(light-dark(#cbced3, #393c41) 50%, transparent 50%) 0 0 / 1.5px 10px repeat-y, linear-gradient(light-dark(#cbced3, #393c41) 50%, transparent 50%) 100% 0 / 1.5px 10px repeat-y; color: light-dark(#5f626a, #a7abb2); font: 500 11px/1.3 system-ui, sans-serif; text-align: center; }
  .field-flatbed__hint b { color: light-dark(#42454c, #e5e6e9); font-size: 12.5px; font-weight: 600; }
  .field-flatbed__hint svg { width: 22px; height: 22px; margin-bottom: 2px; fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
  .field-flatbed[data-over] .field-flatbed__hint { animation: field-flatbed-ants 0.6s linear infinite; color: light-dark(#42454c, #e5e6e9); }
  @keyframes field-flatbed-ants { to { background-position: 10px 0, -10px 100%, 0 -10px, 100% 10px; } }
  /* The photo lies on the glass; the scanned copy is revealed behind the light bar. */
  .field-flatbed__photo { position: absolute; inset: 12px 26px; padding: 4px; background: light-dark(#fbfcfd, #e5e6e9); rotate: -1.6deg; box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .field-flatbed__photo img { position: absolute; inset: 4px; width: calc(100% - 8px); height: calc(100% - 8px); object-fit: cover; }
  .field-flatbed__raw { filter: brightness(0.5) blur(0.6px) saturate(0.6); }
  .field-flatbed__scan { filter: grayscale(1) contrast(1.15) brightness(1.04); clip-path: inset(0 0 100% 0); animation: field-flatbed-reveal 1.7s cubic-bezier(0.45, 0, 0.55, 1) forwards; }
  @keyframes field-flatbed-reveal { to { clip-path: inset(0 0 0 0); } }
  .field-flatbed__bar { position: absolute; left: 0; right: 0; top: 0; height: 3px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 10px 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent), 0 0 32px 10px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 14%, transparent); opacity: 0; animation: field-flatbed-sweep 1.7s cubic-bezier(0.45, 0, 0.55, 1); }
  @keyframes field-flatbed-sweep { 0% { opacity: 0; transform: translateY(0); } 8%, 92% { opacity: 1; } 100% { opacity: 0; transform: translateY(119px); } }
  .field-flatbed__read { display: flex; gap: 8px; align-items: baseline; max-width: 250px; color: light-dark(#5f626a, #a7abb2); font: 500 11.5px system-ui, sans-serif; }
  .field-flatbed__read b { overflow: hidden; color: light-dark(#373a40, #f1f2f3); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
  .field-flatbed__read i { font-style: normal; white-space: nowrap; }
  @media (prefers-reduced-motion: reduce) {
    .field-flatbed * { animation: none !important; transition: none !important; }
    .field-flatbed__scan { clip-path: none; }
  }
`;

export function Preview() {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const [scan, setScan] = useState<Scan | null>(null);
  const [over, setOver] = useState(false);
  const url = scan?.url;
  // Free the old preview when a new photo replaces it (or the field goes away).
  useEffect(() => () => void (url && URL.revokeObjectURL(url)), [url]);
  const take = (file?: File | null) => {
    if (!file || !file.type.startsWith('image/')) return;
    // With reduced motion the bar never runs, so the scan is done at once.
    setScan({ url: URL.createObjectURL(file), name: file.name, size: size(file.size), done: matchMedia('(prefers-reduced-motion: reduce)').matches });
  };
  return (
    <>
      <style href="kit-inputs-flatbed" precedence="kit">
        {css}
      </style>
      <div className="field-flatbed" data-over={over || undefined}>
        <label
          className="field-flatbed__bed"
          htmlFor={id}
          onDragOver={(e) => {
            e.preventDefault();
            setOver(true);
          }}
          onDragLeave={() => setOver(false)}
          onDrop={(e) => {
            e.preventDefault();
            setOver(false);
            // Hand the dropped file to the input too, so a form posts it.
            if (input.current && e.dataTransfer.files.length) input.current.files = e.dataTransfer.files;
            take(e.dataTransfer.files[0]);
          }}
        >
          <input ref={input} id={id} className="field-flatbed__input" type="file" accept="image/*" aria-label="Photo to scan" onChange={(e) => take(e.target.files?.[0])} />
          <span className="field-flatbed__glass" aria-hidden="true">
            {scan ? (
              <span key={scan.url}>
                <span className="field-flatbed__photo">
                  <img className="field-flatbed__raw" src={scan.url} alt="" />
                  <img className="field-flatbed__scan" src={scan.url} alt="" />
                </span>
                <span className="field-flatbed__bar" onAnimationEnd={() => setScan((s) => s && { ...s, done: true })} />
              </span>
            ) : (
              <span className="field-flatbed__hint">
                <svg viewBox="0 0 24 24">
                  <path d="M12 15V4M7.5 8.5L12 4L16.5 8.5M4 15V19H20V15" />
                </svg>
                <b>Drop a photo</b>
                or click to choose
              </span>
            )}
          </span>
        </label>
        <p className="field-flatbed__read" aria-live="polite">
          {scan ? (
            <>
              <b>{scan.name}</b>
              <i>
                {scan.size} · {scan.done ? 'Scanned' : 'Scanning'}
              </i>
            </>
          ) : (
            <i>No photo yet · JPG, PNG or WebP</i>
          )}
        </p>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

An image upload on a flatbed scanner: drop or choose a photo and a light bar sweeps down the glass, scanning it to black and white as it goes.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, click, hover, keyframe
Techniques
keyframes, clip path, filter, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native file input named "Photo to scan", so the keyboard and screen readers get the system file picker; focus outlines the scanner bed. The file name, size and status are read out from an aria-live line, and the images are decorative (alt=""). Under prefers-reduced-motion the photo shows scanned at once.

More inputs

See all
  • motion
  • ui kit
Freight
Growth
Jersey
Key