The screen between "a CSV arrived" and "rows went into the database": it reads the head of the file, shows what it found, and lets the person correct every guess it made — the separator, whether the first row is names, and which column feeds which field. Reach for it wherever a file somebody else exported has to become records here: the import step of a B2B onboarding, a bulk upload of customers, contacts, subscribers, members or leads, an inventory, product or price-list load, an expense or transactions import, a CRM or help-desk migration, a "import from spreadsheet" button in an admin panel, a payroll or timesheet upload, a student or roster list, and the receiving half of whatever another tool's Export button produced. Common asks it answers: "csv column mapper react", "csv import mapping ui", "map csv columns to fields react", "shadcn csv import", "csv importer component react", "react-papaparse alternative", "papaparse parse csv react", "flatfile alternative open source", "csv upload wizard react", "column matching ui", "import csv to database frontend", "csv preview table react", "detect csv delimiter javascript", "guess csv separator", "csv semicolon vs comma", "excel csv semicolon delimiter", "csv parse quoted comma", "csv newline inside quotes", "csv split breaks rows", "rfc 4180 parser javascript", "csv bom first column name", "csv utf-8 bom header mismatch", "read large csv in browser", "stream csv file javascript", "file.slice textdecoder stream", "csv header detection", "does csv have header row", "validate csv column types", "csv date column validation", "csv インポート 列 マッピング", "csv 取り込み 画面", "csv 区切り文字 判定", "csv 見出し行 判定", "csv 文字化け BOM". **Guessing the separator by counting characters is the bug this is built around, and the fix is not the obvious one.** Counting occurrences in the first line is wrong in both directions: it overcounts a separator sitting inside quoted data — `id,note` over `1,"a;b;c;d;e"` has five semicolons and one comma per record, so counting picks the semicolon and the file reads as five ragged columns — and it undercounts whenever the header is the one line with no quoting in it. So each candidate is used to actually read the file and the result is scored, but *how* it is scored decided this component: the first version here ranked by columns times agreement, which picked the semicolon on that very file (five columns at half agreement beats two at full) and lost to naive counting on the case it was written for. Agreement has to lead and width only break ties, because a wrong separator does not produce a narrower table, it produces a ragged one. With agreement leading, all ten measured sample files resolve to the separator they were written with, including both quoted-data traps and a European export whose cells hold decimal commas. A one-column result is never returned as a confident guess, because that is exactly the shape a wrong separator makes — reading a semicolon file with a comma yields one column on every row, no exception and no error — and that silence is what the chooser on screen exists to break. **Records are found by parsing, never by splitting on newlines.** `id,note\n1,"line one\nline two"\n2,ok` is three records and `text.split("\n")` returns four: the quoted newline becomes a record boundary and lands every following row one column over, which is a file that opens without complaint and is wrong from that row down. Quoted separators stay in their cell, doubled quotes become one, CRLF and lone CR both end a record, a blank line in the middle is kept while trailing ones are dropped, and text that ends inside a quote is reported rather than silently closed. The reader is resumable — the quote flag, the half-built cell and the half-built row live across calls — because a chunk boundary lands wherever the byte offset says it does, including the middle of a quoted cell, and a parser restarted per chunk reads the second half as a different file. Every split point of a quoted test file is asserted to give identical records. **The preview is a window on the file, not the file.** Only a prefix is read (64 KiB by default) and the decoder is kept across chunks with `{ stream: true }`, which is not optional: slicing `名前,住所` at byte 8, the second byte of `住`, and decoding that slice alone yields a U+FFFD replacement character with no exception thrown, while a streaming decoder holds the partial bytes back and the halves rejoin exactly. A record the window cut in half is dropped rather than shown as a short row, the BOM is stripped because `"\ufeffid" === "id"` is false and a surviving one silently unmatches the first header against its field, and every check on screen says it covers the records read so far and not the whole file. **Whether the first row is a header cannot be decided from the data, so there is a toggle and an honest reason beside it.** `Tokyo,Osaka,Kyoto` is a credible header and a credible first row; a monthly sheet whose columns are `2026-01-01,2026-02-01` has a header made entirely of values. Only one piece of evidence actually settles anything, and it runs one way: a first record whose every cell is a number is not a header. Everything else is a lean — a repeated name, or a column that holds numbers below a first cell that does not — and `looksLikeHeader` returns `certain: false` on every input, so the uncertainty is in the return type rather than in a comment. **Type mismatches are reported per row, because that is how they arrive.** A column of a thousand dates where three read `n/a` is not a text column and not a date column; it is a date column with three rows to fix, and a check that answers only "is this column dates?" says no and hides the three rows that are the entire problem. So `checkColumn` names the failing row numbers and counts blanks separately, blank being a different thing to decide about. The predicates are narrower than the platform's on purpose, each for a measured reason: `Number("")` and `Number(" ")` are both 0, so a blank cell in a quantity column would import as the quantity zero — a value nobody entered and the most expensive item on this list; `Number("0x10")` is 16, `Number("0b101")` is 5 and `Number("1e3")` is 1000, none of them something a person typed into a spreadsheet; `Date.parse("42")` is a day in 2041 and `Date.parse("1699")` is in 1699, so a column of quantities or short ids reads as a column of dates; and `Date.parse("2026-02-30")` does not fail but rolls forward to the 2nd of March, so "parses" is not the question "is a day". Dates are therefore matched as ISO and then verified by rebuilding them, and `1,50`, `11/08/2026` and `08.11.2026` are all reported as text rather than guessed at — `1,50` is one and a half in a European export and one thousand and fifty elsewhere, and a date guessed wrong is wrong by months. Nothing is converted on the way out: `applyMapping` hands back strings and the caller converts knowing their own locale. **The correspondence is not one-to-one in either direction, and both halves are reported at once.** A file can carry columns nobody asked for — an internal id, a legacy note, a column of empties — while required fields sit empty, and both are true at the same time; a screen that shows one half is the ordinary way an import loses a column quietly. Columns left out are listed by name rather than warned about, since skipping one is a normal decision the reader only needs to be told was taken, while a required field with no column blocks the confirm button and marks its own control invalid. One column feeding two fields is reported and not undone, because `name` and `display_name` off one column is a real thing to want and `email` and `phone` off one column is a mistake, and nothing here can tell them apart. A stale column index left over from a different separator counts as unmapped instead of passing. Matching runs exact normalised equality across every field before any looser rule gets a turn, so `name` cannot swallow `company_name` by being declared first, and a column is claimed once; two columns of the same name — which merged exports really do carry — are told apart in the chooser while matching still runs on the written names. A choice the reader makes sticks, and every field they have not touched keeps following the suggestion, so correcting the separator rebuilds the suggestions instead of freezing them. Official shadcn/ui has nothing of the kind and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of it, `csv`, `parse`, `delimiter`, `quote`, `mapping` and `column` are every one of them a zero hit, and the 15 hits on `header` are table and card headings with nothing to do with importing. Within pulld it is the receiving end: `csv-export-button` writes a file out, `file-dropzone` and `upload-list` take one in without reading it, `paste-area` catches what the clipboard carries, and `transfer-list` moves items between two lists — this is the one that interprets a table somebody else exported. `createCsvParser`, `parseCsv`, `sniffDelimiter`, `csvShape`, `readCsvPreview`, `looksLikeHeader`, `isPlainNumber`, `isPlainDate`, `checkColumn`, `autoMatchFields`, `mappingReport` and `applyMapping` are all exported as plain functions, so the route handler that receives the confirmed mapping reaches the same answers the screen showed. The controls are one labelled `role="group"`, every select and checkbox separately labelled, the preview is a real table with a caption and `scope="col"` headings, required fields are marked for a screen reader and not only with an asterisk, the one polite live region ships empty and is wired to the invalid controls with `aria-describedby`, and every message is replaceable for translation. No dependencies beyond React and your `cn`; every colour is a shadcn token, so it follows light and dark.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/csv-column-mapper.json"