Replace File

Helps prevent an upload from overwriting a newer file with an older one by comparing both copies before replacing.

StopsOlder upload overwrote newer file

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/replace-file.json

The files land in your project. They're yours to edit.

Usage

replace-file-usage.tsx
import { ReplaceFile, type ConflictChoices, type FileConflict } from "@/components/ui/replace-file"

<ReplaceFile
  key={run}
  conflicts={CONFLICTS}
  otherCount={3}
  existingNames={["hero (1).png"]}
  defaultValue={ALL_REPLACE}
  formatDate={(d) => d.toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", timeZone: "UTC" })}
  onContinue={(result) =>
    new Promise<void>((resolve) =>
      setTimeout(() => {
        setDone(result.map((r) => `${r.name}: ${r.savedAs ? `kept both as ${r.savedAs}` : r.action}`).join(" · "))
        resolve()
      }, 600),
    )
  }
/>

What it catches

  • Older incoming file about to replace a newer existing one
  • Identical copies, with a one-tap Skip
  • Newer and larger copy highlighted per clash
  • Name the kept copy will get ("report (1).pdf")
  • Clashes still undecided before Continue

Props

ReplaceFile props
PropTypeDefault
conflicts* (required)

Each name clash: the existing and incoming file.

FileConflict[]—
otherCount

Files in the same upload that don't clash; said out loud so "apply to all" never seems to touch them.

number0
existingNames

Every name already in the folder, so "Keep both" never picks a taken name.

string[][]
value

Chosen action per file name (controlled).

ConflictChoices—
defaultValue

Starting choices when uncontrolled.

ConflictChoices{}
onValueChange

Called with every choice change.

(choices: ConflictChoices) => void—
onContinue* (required)

Runs with every decision. Return a promise to show a pending state; a rejection is caught.

(result: { name: string; action: ConflictAction; savedAs?: string }[]) => void | Promise<void>—
formatDate

Formats file dates.

(date: Date) => string(d) => d.toLocaleString(undefined, { day: "numeric", mont…
labels

Labels of the replace, keep-both and skip options.

{ replace: string; "keep-both": string; skip: string }{ replace: "Replace", "keep-both": "Keep both", skip: "Sk…
title

Heading for the given number of clashes.

(count: number) => string(n) => (n === 1 ? "This file already exists" : `${n} file…
othersText

Says how many non-clashing files upload as normal.

(count: number) => string(n) => `${n} other ${n === 1 ? "file uploads" : "files up…
applyAllLabel

Label of an apply-to-all button.

(count: number) => string(n) => `Apply to all ${n}:`
olderText

Warning when an older file would replace a newer one.

string"The upload is older than the copy it would replace."
keepBothFix

Label of the one-tap Keep both fix.

string"Keep both instead"
bulkOlderText

Warning when apply-to-all would put older files over newer ones.

(count: number) => string(n) => `${n} of these would replace a newer file.`
bulkOlderFix

Label of the bulk Keep both fix.

(count: number) => string(n) => `Keep both for ${n === 1 ? "that one" : `those ${n…
identicalText

Shown when both copies look identical.

string"Same size and date. Probably the same file."
skipFix

Label of the one-tap Skip for identical copies.

string"Skip it"
savedAsText

Preview of the renamed copy's name.

(name: string) => string(name) => `Saved as ${name}`
existingLabel

Label above the existing copy.

string"In folder"
incomingLabel

Label above the incoming copy.

string"Uploading"
continueLabel

Label of the Continue button.

string"Upload"
undecidedLabel

Shown while clashes are undecided.

(count: number) => string(n) => `Choose for ${n} more`
pendingLabel

Continue label while onContinue runs.

string"Uploading…"

* required. Also accepts the native props of <form> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Form labelled by its title; each clash is a radiogroup of native radios named after the file
  • Warnings and fixes render in polite live regions
  • Continue is disabled until every clash is decided, with aria-busy while pending
  • Newer/larger shown in bold text, not colour alone; animation skipped under reduced motion

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Check the stored file's version or ETag at write time so a stale client choice cannot overwrite a newer upload.

  • Compares the dates and sizes you pass; file timestamps can be wrong after copies or downloads
  • "Identical" is inferred from size and date, not a content hash
  • Does not upload; onContinue receives the choices

What it stops

Older upload overwrote newer file

Also known as

file conflict · replace or keep both · upload · duplicate file name · overwrite · dropbox · file manager

Field type

Dependencies

Pairs well with