- motion
- ui kit
Flatbed
No photo yet · JPG, PNG or WebP
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldFlatbed(el) on the mounted element instead.
<div class="field-flatbed">
<label class="field-flatbed__bed" for="field-flatbed">
<input id="field-flatbed" class="field-flatbed__input" type="file" accept="image/*" aria-label="Photo to scan"/>
<span class="field-flatbed__glass" aria-hidden="true">
<span class="field-flatbed__hint">
<svg viewBox="0 0 24 24">
<path d="M12 15V4M7.5 8.5L12 4L16.5 8.5M4 15V19H20V15"></path>
</svg>
<b>Drop a photo</b>
or click to choose
</span>
</span>
</label>
<p class="field-flatbed__read" aria-live="polite">
<i>No photo yet · JPG, PNG or WebP</i>
</p>
</div>.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; }
}function initFieldFlatbed(root) {
var bed = root.querySelector('.field-flatbed__bed');
var input = root.querySelector('.field-flatbed__input');
var glass = root.querySelector('.field-flatbed__glass');
var read = root.querySelector('.field-flatbed__read');
var url = '';
function size(n) {
return n > 1e6 ? (n / 1e6).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB';
}
function status(name, detail) {
read.textContent = '';
if (name) {
var b = document.createElement('b');
b.textContent = name;
read.appendChild(b);
}
var i = document.createElement('i');
i.textContent = detail;
read.appendChild(i);
}
// The photo twice: dimmed under the lid, and a grayscale copy revealed behind the light bar.
function take(file) {
if (!file || !/^image\//.test(file.type)) return;
// Free the old preview when a new photo replaces it.
if (url) URL.revokeObjectURL(url);
url = URL.createObjectURL(file);
var label = size(file.size);
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
glass.innerHTML = '<span><span class="field-flatbed__photo"><img class="field-flatbed__raw" alt=""><img class="field-flatbed__scan" alt=""></span><span class="field-flatbed__bar"></span></span>';
glass.querySelectorAll('img').forEach(function (img) {
img.src = url;
});
glass.querySelector('.field-flatbed__bar').addEventListener('animationend', function () {
status(file.name, label + ' · Scanned');
});
status(file.name, label + ' · ' + (still ? 'Scanned' : 'Scanning'));
}
function onChange() {
take(input.files && input.files[0]);
}
function onOver(e) {
e.preventDefault();
root.setAttribute('data-over', 'true');
}
function onLeave() {
root.removeAttribute('data-over');
}
function onDrop(e) {
e.preventDefault();
root.removeAttribute('data-over');
// Hand the dropped file to the input too, so a form posts it.
if (e.dataTransfer.files.length) input.files = e.dataTransfer.files;
take(e.dataTransfer.files[0]);
}
input.addEventListener('change', onChange);
bed.addEventListener('dragover', onOver);
bed.addEventListener('dragleave', onLeave);
bed.addEventListener('drop', onDrop);
return function () {
input.removeEventListener('change', onChange);
bed.removeEventListener('dragover', onOver);
bed.removeEventListener('dragleave', onLeave);
bed.removeEventListener('drop', onDrop);
if (url) URL.revokeObjectURL(url);
};
}
document.querySelectorAll('.field-flatbed').forEach(initFieldFlatbed);Goal: Create a "Flatbed" input.
Visual behavior: 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.
Technique:
- A native file input (accept image/*) sits inside the scanner's label, so a click chooses a file; a drop hands the dropped files to the same input, so a form posts the photo either way.
- The photo is shown twice from one object URL: the raw image dimmed under the lid, and a grayscale copy above it whose clip-path wipes down in step with the light bar, so the scan follows the light.
- The light bar is one keyframe sweep; its animationend switches the status from Scanning to Scanned. The previous object URL is revoked whenever a new photo replaces it.
- Dragging over the glass slides a sheen across it and sets the dashed drop outline marching.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Flatbed" input.
Visual behavior: 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.
Technique:
- A native file input (accept image/*) sits inside the scanner's label, so a click chooses a file; a drop hands the dropped files to the same input, so a form posts the photo either way.
- The photo is shown twice from one object URL: the raw image dimmed under the lid, and a grayscale copy above it whose clip-path wipes down in step with the light bar, so the scan follows the light.
- The light bar is one keyframe sweep; its animationend switches the status from Scanning to Scanned. The previous object URL is revoked whenever a new photo replaces it.
- Dragging over the glass slides a sheen across it and sets the dashed drop outline marching.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.