07Type rejection — a gate that computes the reason from MIME prefix matching and bounces the file
Comparing each allowed MIME as a prefix with startsWith decides admission, and a failing file is pushed back to its original slot rather than vanishing, with the rejection reason (not an extension, type mismatch) read out through aria-live; during dragover the cursor shape forewarns the rejection.
Best forDocument-only attachments, contract uploads
File.typestartsWitharia-live

<main class="stage">
<div class="rt" id="rt">
<p class="rt__hint" id="rtHint">Only types starting with image/ are accepted — others bounce back with a reason</p>
<div class="rt__zone" id="rtZone" role="button" tabindex="0" aria-describedby="rtHint" aria-label="Image-only dropzone">
<input type="file" id="rtInput" class="rt__file" aria-label="Choose file">
<svg class="rt__ic" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56Zm0 144H40l68.69-68.68l72 72Z"/></g></svg>
<span class="rt__file-name" id="rtFileName"></span>
</div>
<p class="rt__msg" id="rtMsg" aria-live="polite">Try dropping a file whose type starts with image/</p>
<div class="rt__meta">
<span class="rt__tag" id="rtTag">accepted 0 · rejected 0</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 120ms; // 거부 튕김(변형: 기본 240ms → 120ms)
$easing: $ease-out;
$color: $subject-blue-deep;
// 파스텔 강조(채도 .34 · 명도 +.11)
$p-warn: #ebe0d6;
$p-mint: #93d9c0;
@include stage($stage-paper);
.rt {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.rt__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
color: $subject-ink;
}
.rt__zone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
min-height: 76px;
padding: $sp-3;
border: 2px dashed rgba($subject-ink, .72);
border-radius: $r-card;
cursor: pointer;
overflow: hidden;
transition: border-color $dur-quick $ease-out, background $dur-quick $ease-out;
}
.rt__zone.is-over {
border-color: $p-mint;
background: rgba(147, 217, 192, .14);
}
.rt__zone.is-precheck-reject {
border-color: $p-warn;
background: rgba(243, 201, 161, .18);
cursor: not-allowed;
}
.rt__zone.is-accept {
border-color: $p-mint;
}
.rt__zone.is-reject-live {
border-color: $p-warn;
}
.rt__file {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.rt__ic {
width: 26px;
height: 26px;
color: $subject-ink;
transition: transform $dur-quick $ease-pop;
}
.rt__zone.is-over .rt__ic {
transform: scale(1.15);
}
.rt__file-name {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 700;
font-size: 11px;
color: $subject-ink;
}
.rt__msg {
margin: 0;
padding: $sp-1 $sp-2;
min-height: 32px;
border-radius: $r-sm;
background: rgba($subject-ink, .06);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
color: $subject-ink;
}
.rt__meta {
display: flex;
align-items: baseline;
gap: $sp-2;
padding: 0 $sp-1;
}
.rt__tag {
font-weight: 700;
font-size: 12px;
color: $subject-ink;
}
@keyframes rtBounce {
0% { transform: translateY(-10px); }
55% { transform: translateY(2px); }
100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.rt { max-width: 260px; gap: $sp-1; }
.rt__hint { font-size: 9px; line-height: 1.25; }
.rt__zone { min-height: 44px; padding: $sp-2; gap: $sp-1; }
.rt__ic { width: 20px; height: 20px; }
.rt__file-name, .rt__msg { font-size: 10px; }
.rt__msg { min-height: 0; padding: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rt__zone, .rt__ic { transition: none; }
.rt__zone.is-over .rt__ic { transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rt {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 8px;
}
.rt__hint {
margin: 0;
padding: 0 4px;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
color: #17141a;
}
.rt__zone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 76px;
padding: 12px;
border: 2px dashed rgba(23, 20, 26, 0.72);
border-radius: 16px;
cursor: pointer;
overflow: hidden;
transition: border-color 220ms ease-out, background 220ms ease-out;
}
.rt__zone.is-over {
border-color: #93d9c0;
background: rgba(147, 217, 192, 0.14);
}
.rt__zone.is-precheck-reject {
border-color: #ebe0d6;
background: rgba(243, 201, 161, 0.18);
cursor: not-allowed;
}
.rt__zone.is-accept {
border-color: #93d9c0;
}
.rt__zone.is-reject-live {
border-color: #ebe0d6;
}
.rt__file {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.rt__ic {
width: 26px;
height: 26px;
color: #17141a;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rt__zone.is-over .rt__ic {
transform: scale(1.15);
}
.rt__file-name {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 700;
font-size: 11px;
color: #17141a;
}
.rt__msg {
margin: 0;
padding: 4px 8px;
min-height: 32px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
font-weight: 500;
font-size: 11px;
line-height: 1.4;
color: #17141a;
}
.rt__meta {
display: flex;
align-items: baseline;
gap: 8px;
padding: 0 4px;
}
.rt__tag {
font-weight: 700;
font-size: 12px;
color: #17141a;
}
@keyframes rtBounce {
0% {
transform: translateY(-10px);
}
55% {
transform: translateY(2px);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.rt {
max-width: 260px;
gap: 4px;
}
.rt__hint {
font-size: 9px;
line-height: 1.25;
}
.rt__zone {
min-height: 44px;
padding: 8px;
gap: 4px;
}
.rt__ic {
width: 20px;
height: 20px;
}
.rt__file-name, .rt__msg {
font-size: 10px;
}
.rt__msg {
min-height: 0;
padding: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rt__zone, .rt__ic {
transition: none;
}
.rt__zone.is-over .rt__ic {
transform: none;
}
}// Type rejection(07) — File.type.indexOf('image/')===0 (startsWith 대용) 로 허용 여부를 가르고,
// 거부된 파일은 CSS 트랜짓으로 원래 자리로 튕겨 돌아가며 aria-live 문구가 이유를 읽어준다.
// dragover 시점엔 아직 File 이 없어도 DataTransferItem.type 으로 미리 거부 신호(커서·테두리)를
// 낼 수 있다 — 드롭 전에 이미 계산이 끝나 있는 것이 이 데모의 축.
(function () {
var zone = document.getElementById('rtZone');
var input = document.getElementById('rtInput');
var fnameEl = document.getElementById('rtFileName');
var msgEl = document.getElementById('rtMsg');
var tagEl = document.getElementById('rtTag');
if (!zone || !input || !fnameEl || !msgEl || !tagEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var ALLOW = 'image/';
var BOUNCE_MS = 120; // 변형: 기본 240ms → 이 편은 120ms(queue 변형표)
// 시나리오 — allowed up to/거부가 섞인 순서. startsWith(indexOf===0) 판정이 매번 갈린다.
var FILES = [
{ name: 'photo-01.png', type: 'image/png' },
{ name: 'report.pdf', type: 'application/pdf' },
{ name: 'photo-02.jpg', type: 'image/jpeg' },
{ name: 'notes.txt', type: 'text/plain' }
];
var accepted = 0, rejected = 0, cur = -1;
function judge(type) { return type.startsWith(ALLOW); } // File.type 의 접두사 판정
function announce(f, ok) {
fnameEl.textContent = f.name;
if (ok) {
accepted++;
msgEl.textContent = f.name + ' accepted(' + f.type + ')';
zone.classList.remove('is-reject-live');
zone.classList.add('is-accept');
if (!reduce) { zone.style.animation = 'none'; void zone.offsetWidth; zone.style.animation = ''; }
} else {
rejected++;
msgEl.textContent = f.name + ' rejected — must start with image/ (got ' + f.type + ')';
zone.classList.remove('is-accept');
zone.classList.add('is-reject-live');
if (!reduce) {
zone.style.animation = 'none';
void zone.offsetWidth;
zone.style.animation = 'rtBounce ' + BOUNCE_MS + 'ms ease-out';
}
}
tagEl.textContent = 'accepted ' + accepted + ' · rejected ' + rejected;
}
// 진짜 File 만 심사 — File.type 이 없는 시나리오 객체도 같은 규칙으로 재판정한다
function isFileLike(file) { return !!(file && file.type !== undefined); }
function intake(file) { if (isFileLike(file)) announce(file, judge(file.type)); }
function virtualFile(spec) {
try { return new File([new Blob(['x'])], spec.name, { type: spec.type }); }
catch (e) { return spec; }
}
zone.addEventListener('dragover', function (e) {
e.preventDefault();
zone.classList.add('is-over');
// 드롭 전 미리 판정 — items[0].type 은 File 없이도 읽힌다
var items = e.dataTransfer && e.dataTransfer.items;
var preOk = true;
if (items && items.length) preOk = judge(items[0].type || '');
zone.classList.toggle('is-precheck-reject', !preOk);
if (e.dataTransfer) e.dataTransfer.dropEffect = preOk ? 'copy' : 'none';
});
zone.addEventListener('dragleave', function () {
zone.classList.remove('is-over', 'is-precheck-reject');
});
zone.addEventListener('drop', function (e) {
e.preventDefault();
zone.classList.remove('is-over', 'is-precheck-reject');
var file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
// File.type 이 image/ 로 시작하는지가 이 데모의 심판이다
if (file) intake(file);
});
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (file) intake(file);
input.value = '';
});
if (reduce) {
intake(virtualFile(FILES[0]));
setTimeout(function () { intake(virtualFile(FILES[1])); }, 30);
} else {
var nextAt = 0;
(function loop(now) {
if (now >= nextAt) {
nextAt = now + 620;
cur = (cur + 1) % FILES.length;
intake(virtualFile(FILES[cur]));
}
requestAnimationFrame(loop);
})(0);
}
})();// DropRejectType — vanilla 판(run/337/demo/07-drop-reject-type/index.html) 의 React 포트.
// dragover 단계에서 이미 e.dataTransfer.items[0].type 을 미리 읽어 허용 접두사와 비교해
// dropEffect 를 'none' 으로 앞당겨 결정한다(드롭 완료 전 판정 = 계산 한 겹). 실제 드롭에서도
// 같은 judge() 로 최종 판정해 튕김/수락 애니메이션을 가른다.
import { useEffect, useRef } from "react";
import styles from "./DropRejectType.module.scss";
export type DropRejectTypeProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "허용 접두사 image/ 로 dragover 단계에서 먼저 판정해 드롭 전에 거부를 미리 표시합니다";
const ARIA_LABEL = "허용 형식 드롭존";
type Box<T> = { current: T };
const ALLOW = "image/";
const BOUNCE_MS = 120; // 변형: 큐 변형 칩 07(240ms→120ms) 적용값
type FileSpec = { name: string; type: string };
const FILES: FileSpec[] = [
{ name: "photo-01.png", type: "image/png" },
{ name: "report.pdf", type: "application/pdf" },
{ name: "photo-02.jpg", type: "image/jpeg" },
{ name: "notes.txt", type: "text/plain" }
];
function judge(type: string): boolean {
return type.indexOf(ALLOW) === 0;
}
export default function DropRejectType({
duration = "120ms",
easing = "ease-out",
color = "#0a4fe8"
}: DropRejectTypeProps) {
const zoneRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const fnameRef = useRef<HTMLElement | null>(null);
const msgRef = useRef<HTMLParagraphElement | null>(null);
const tagRef = useRef<HTMLSpanElement | null>(null);
const rafRef = useRef(0) as Box<number>;
const bounceTORef = useRef(0) as Box<number>;
useEffect(() => {
const zone = zoneRef.current;
const input = inputRef.current;
const fnameEl = fnameRef.current;
const msgEl = msgRef.current;
const tagEl = tagRef.current;
if (!zone || !input || !fnameEl || !msgEl || !tagEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let accepted = 0, rejected = 0;
function announce(f: FileSpec, ok: boolean): void {
fnameEl!.textContent = f.name;
tagEl!.textContent = f.type;
if (ok) {
accepted++;
msgEl!.textContent = "수락 — accepted " + accepted + " · rejected " + rejected;
zone!.classList.add(styles.zoneAccept);
zone!.classList.remove(styles.zoneRejectLive);
if (!reduce) {
zone!.style.animation = "none";
void zone!.offsetWidth;
zone!.style.animation = "";
}
} else {
rejected++;
msgEl!.textContent = "거부 — " + f.type + " 는 " + ALLOW + " 형식이 아닙니다 (accepted " + accepted + " · rejected " + rejected + ")";
zone!.classList.add(styles.zoneRejectLive);
zone!.classList.remove(styles.zoneAccept);
if (!reduce) {
zone!.classList.remove(styles.bounceRun);
void zone!.offsetWidth;
zone!.classList.add(styles.bounceRun);
window.clearTimeout(bounceTORef.current);
bounceTORef.current = window.setTimeout(() => zone!.classList.remove(styles.bounceRun), BOUNCE_MS);
}
}
}
function intake(f: FileSpec): void {
announce(f, judge(f.type));
}
function virtualFile(spec: FileSpec): File {
try { return new File([new Blob(["x"])], spec.name, { type: spec.type }); }
catch (e) { return new File([], spec.name); }
}
// dragover 단계 사전 판정 — 드롭 완료 전에 type 을 읽어 dropEffect 를 미리 결정
function onOver(e: DragEvent): void {
e.preventDefault();
zone!.classList.add(styles.zoneOver);
const items = e.dataTransfer && e.dataTransfer.items;
if (items && items.length && items[0].type) {
const ok = judge(items[0].type);
zone!.classList.toggle(styles.zonePrecheckReject, !ok);
if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? "copy" : "none";
}
}
function onLeave(): void {
zone!.classList.remove(styles.zoneOver, styles.zonePrecheckReject);
}
function onDrop(e: DragEvent): void {
e.preventDefault();
zone!.classList.remove(styles.zoneOver, styles.zonePrecheckReject);
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) intake({ name: f.name, type: f.type });
}
function onChange(): void {
const f = input!.files && input!.files[0];
if (f) intake({ name: f.name, type: f.type });
input!.value = "";
}
zone.addEventListener("dragover", onOver as EventListener);
zone.addEventListener("dragleave", onLeave);
zone.addEventListener("drop", onDrop as EventListener);
input.addEventListener("change", onChange);
let cur = -1;
if (reduce) {
intake(FILES[0]);
} else {
let nextAt = 0;
const step = (now: number): void => {
if (now >= nextAt) {
nextAt = now + 620;
cur = (cur + 1) % FILES.length;
intake(FILES[cur]);
}
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
window.clearTimeout(bounceTORef.current);
zone.removeEventListener("dragover", onOver as EventListener);
zone.removeEventListener("dragleave", onLeave);
zone.removeEventListener("drop", onDrop as EventListener);
input.removeEventListener("change", onChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.rt}>
<p className={styles.hint} id="rtHintReact">{HINT}</p>
<div
className={styles.zone}
ref={zoneRef}
role="button"
tabIndex={0}
aria-describedby="rtHintReact"
aria-label={ARIA_LABEL}
>
<input type="file" accept="image/*" className={styles.file} ref={inputRef} aria-label="파일 선택" />
<svg className={styles.ic} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M208 56v144a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h144a8 8 0 0 1 8 8" opacity=".2" />
<path d="M208.49 191.51L136.53 128l71.96-63.51a8 8 0 0 0-10.98-11.64L128 116.53L59.51 52.85a8 8 0 0 0-10.98 11.64L120.53 128l-71.96 63.51a8 8 0 1 0 10.6 12l.38-.34L128 139.47l68.49 63.71a8 8 0 0 0 11.34-.55a8 8 0 0 0-.34-11.12Z" />
</g>
</svg>
<p className={styles.msg} ref={msgRef} aria-live="polite" />
<p className={styles.meta}>
<span className={styles.fileName} ref={fnameRef}>photo-01.png</span>
<span className={styles.tag} ref={tagRef} style={{ color }}>image/png</span>
</p>
</div>
</div>
</main>
);
}07 Type rejection — a gate that computes the reason from MIME prefix matching and bounces the file — Measured area 4.949% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · Drag and Drop File Upload UI: 9 Computed Dropzones
The post this demo lives in
Drag and Drop File Upload UI: 9 Computed DropzonesDropped files turn into previews computed from their bytes: type, size and count drive the layout, images get pixel-read See all demos in this post
If you like this effect - similar demos
drag05 Long-press gate — splitting scroll from drag with a hold timer and a movement thresholdDrag to Reorder UI: 9 Lists, One Drop Index
drag03 Rubber Boundary That Stretches and Snaps Back Past the EdgeDrag and Drop Animation CSS: 9 Physics Patterns
drag06 An Auto Solver That Starts from the BorderJigsaw Puzzle JavaScript: 9 Snapping Tab Boards