Emblor: a headless tag input that owns the state and leaves the markup to you
A fully-featured tag input component built with shadcn/ui
At a glance
- What is it?
- Emblor v2 is a headless React tag input primitive that handles tag and draft state, keyboard behaviour, validation, native form submission and accessible announcements while consumers supply the markup and styling. It is published as an alpha, and it deliberately excludes autocomplete, popovers and styling runtimes.
- Who is it for?
- Adopt Emblor v2 if you need a tag input with accessible announcements and native form submission and you want to keep control of the markup and styling. Do not adopt it expecting the autocomplete and popover behaviour of the v1 component, since the v2 package intentionally excludes it, and treat the 2.0.0-alpha tags as what they are.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 32 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 25, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What changed in v2: the component became a primitive
The repository description calls Emblor a fully-featured tag input component built with shadcn/ui, and that describes v1. The v2 README opens differently, and the difference is the whole story of the project. Emblor v2 is a headless tag-input primitive. It owns tag and draft state, keyboard behavior, validation, focus coordination, native forms, and accessible announcements. Consumers own markup and styling.
That is a substantial change of category. A shadcn/ui component, in the usual sense of that term, is source code you copy into your project, styled with Tailwind, and structured around a set of Radix primitives. The v2 package is the opposite: a published package on npm whose job is behaviour, with the markup assembled by you from a set of compound components.
The release list shows this as a recent and unfinished transition. [email protected] was published on 2025-03-28, then [email protected] on 2026-08-16 and [email protected] on 2026-08-18. So the v2 line is about five weeks old at the time of the last push on 2026-09-23, and it is an alpha, which in the changesets-driven workflow the repository uses means the API may still move.
The primary language is TypeScript, the licence is MIT, and the topics name the lineage and the shape: autocomplete, input, popover, radix, react-tag-input, shadcn-ui and tag-input. The homepage is a documentation site at emblor.jaleelbennett.com.
For someone evaluating this, the honest framing is that there are two products with one name. The v1 line is a finished shadcn component. The v2 line is a rewrite with a different contract, published as alpha, with a migration document at packages/emblor/MIGRATION.md and the full contract at packages/emblor/README.md.
The compound API, and the parts you must and must not include
The usage example in the README shows the shape of the API, and it is a compound component with a render-prop list.
<EmblorRoot defaultValue={['react']} name="skills">
<EmblorLabel>Skills</EmblorLabel>
<EmblorTagList>
{(tag, index) => (
<EmblorTag key={tag} index={index}>
<EmblorTagText />
<EmblorTagRemove />
</EmblorTag>
)}
</EmblorTagList>
<EmblorInput placeholder="Add a skill" />
<EmblorClear>Clear</EmblorClear>
</EmblorRoot>;The set of parts is EmblorRoot, EmblorLabel, EmblorTagList, EmblorTag, EmblorTagText, EmblorTagRemove, EmblorInput and EmblorClear, all imported from the package root. The README is explicit that you should import all supported parts from the package root.
Two structural rules follow. The compound API requires exactly one EmblorInput, so that is not optional in the way the others are, and EmblorTagList is optional. EmblorTagList takes a render function receiving the tag and its index, which is how you map over the committed tags, and EmblorTag takes an index prop so the primitive knows which tag it is.
The state model is in three pieces. Use value and onValueChange, or defaultValue, for the string array of committed tags. Draft control, when you need it, lives on Root through inputValue, defaultInputValue and onInputValueChange. That separation is the point of the design: a controlled consumer holds both the committed list and the text currently in the input, and the primitive never has to guess whether it owns that state.
One more detail is worth flagging because it is the kind of thing that bites. Consumers own React keys and optional tag IDs. The example uses the tag value itself as the key, which works only because duplicates are disabled. If you enable duplicates, you need to maintain stable occurrence IDs alongside the string array, because two identical strings as React keys will produce duplicate-key warnings and incorrect reconciliation.
Commit rules, validation order, and how rejections are reported
The behavioural contract is the part of a headless primitive that has to be exactly right, and the README specifies it in detail.
Enter commits. Literal delimiters default to a single comma, in the array [',']. addOnTab opts into committing on Tab, which is off by default because Tab is a focus movement key and hijacking it breaks keyboard navigation. addOnPaste defaults to true, so pasted comma-separated text is split into tags. blurBehavior defaults to 'noop', which means nothing special happens when the input loses focus.
The order of operations is stated explicitly, and that is the useful part. transform runs before length, duplicate, capacity, and boolean validate checks. So your transform function, whatever it does to the incoming text, is applied first, and then the built-in checks run against the transformed value. If you were expecting your validator to see raw input, this ordering settles the question.
Rejections are reported structurally rather than by throwing. onReject receives structured rejection details, so the consumer decides what to show, whether to log it, or to ignore it. And the paste path is defined in the same sentence: pasted values are evaluated left-to-right and accepted in one batch transition, which means a paste of five values either produces five tags in a single state update or produces the ones that pass, and it is not five separate transitions.
The native form behaviour is the other half of the contract. With a name prop, committed tags submit as ordered repeated native form values, which is the HTML shape a server expects for a multi-value field. required means at least one committed tag, so an empty tag list does not satisfy a required field. Labels associate automatically, actions remain accessible under as polymorphism, and every part forwards applicable DOM, ARIA, data, style and event props.
That last clause is what makes the primitive usable in a real design system. Since each part forwards props, you can attach a data attribute, an aria-describedby, an onBlur or a className to any piece and have it land on the right element.
What the package refuses to include
The README states the exclusions as a list, and it is the most useful paragraph in it for anyone deciding whether to use this. The package intentionally excludes autocomplete, cmdk, popovers, sorting, drag-and-drop, Tailwind, CVA, and styling runtimes.
Read that list carefully, because it describes two different kinds of exclusion. cmdk, popovers and autocomplete are behavioural features that a v1-style component with a shadcn/ui heritage would plausibly have included, since a tag input with a suggestion list is the common case in the ecosystem. Tailwind, CVA and styling runtimes are dependencies, and excluding them is a statement about what the package will never require of you.
The consequence for v1 users is significant. If your v1 implementation used Emblor's autocomplete, migrating to v2 means building that layer yourself, with the popover, the command palette behaviour and the suggestion filtering. The MIGRATION.md document at packages/emblor/MIGRATION.md is the place to find out exactly what changed, and the README's framing of v1 to v2 as a different contract rather than a feature addition is consistent with that.
The upside of the exclusion is what a headless primitive is for. The package ships no styling runtime and no class-name variance authority, so it imposes nothing on your design system. It also has no dependency on Tailwind, which means it can be used in a project that does not use Tailwind at all, or in one that has moved to a different styling approach, without a wrapper. And the exclusion of sorting and drag-and-drop keeps the primitive's internal state model simple: tags are an ordered list, and the ordering is yours to control.
The decision this forces is straightforward. If what you need is a tag field with suggestions, this is not the package and the v1 line or something else is. If what you need is reliable tag input behaviour, with accessibility and native form semantics handled for you, and you are willing to build the suggestion UI on top, the exclusions are the feature.
The monorepo, the release gate and the distribution scripts
The repository is a pnpm and turborepo monorepo, and the release process is unusually visible in the root package.json.
The workspace holds packages/ with the emblor package, a website/ directory for the documentation site, and a scripts/ directory. There is a pnpm-workspace.yaml, a pnpm-lock.yaml, turbo.json for the task runner, a .changeset/ directory for versioning, tsconfig.json and tsconfig.base.json for the TypeScript configuration, an eslint.config.js and a .prettierrc.js. The .node-version and .npmrc files pin the toolchain, and there are .project-planning/ and project-status/ directories, which is an unusual amount of process documentation for a component library and suggests the project is being run with some formality.
The distribution scripts are the interesting part. There is a distribution:format that runs prettier-check, a distribution:build that runs the build filtered to the packages, a distribution:lint, a distribution:typecheck, a distribution:test, a distribution:release-boundary that runs a test file called release-boundary.test.mjs, and a distribution:artifact that runs test:distribution. The umbrella is a distribution script that calls scripts/run-distribution-gate.mjs, and the ci script runs the distribution gate with --skip-install. prepublishOnly runs npm run ci, so nothing gets published to npm without passing the gate.
That release-boundary test is worth noting. A release-boundary check in a package that is mid-migration from v1 to v2 is a way of asserting what the published artefact does and does not contain, which is exactly the kind of thing that stops an alpha from shipping something inconsistent with its own documentation.
The alpha releases are driven by changesets. release:alpha:prepare runs the changesets CLI in pre mode to enter alpha and then versions, and release:dry-run runs scripts/validate-release.mjs. There is a scripts/ directory with mjs files, a .github/releases/ directory referenced in the prettier format glob, and a .github/ISSUE_TEMPLATE/ directory, so the release notes and issue templates are part of the repository rather than a manual step.
Where Emblor v2 is the wrong choice
There are three situations where this is the wrong tool, and one of them applies to a lot of people.
The first is the obvious one. If you want autocomplete, a suggestion dropdown, or a command palette inside your tag field, v2 will not give it to you. The exclusion list is explicit, and the migration document exists because v1 users will hit this. Writing that layer on top of the primitive is possible, but it is a piece of work, and the primitive is not designed to make it trivial, since it deliberately does not own the popover or the filtering logic.
The second is that v2 is an alpha. Two alpha tags in a five-week-old line means the API is expected to change, and the semver in the tags says so. If you are shipping a product this quarter and the tag input is on a critical path, an alpha dependency is a risk that has to be managed, either by pinning exactly and accepting the maintenance, or by waiting for the 2.0.0 stable tag.
The third is the one that catches teams out: v1 and v2 are different products with the same name, and the repository description still describes v1. A developer who lands on the repository, reads the description, sees a shadcn/ui component with autocomplete, and installs the latest npm tag, may get the alpha instead of the component the description promised. Checking which line the version you are installing belongs to is the first thing to do.
There is a fourth, smaller point. The primitive owns the keyboard behaviour, which is the point of it, but that means it is opinionated about how the input should behave, and you cannot easily re-implement Enter to commit or Tab to move focus without fighting the primitive. If your design system has a rule that Tab must always move focus, for example, then addOnTab is off by default and you keep it that way, which is correct, but you should know the constraint exists.
And the duplicate-key issue deserves repeating because it is a runtime bug rather than a type error. If you enable duplicates and key on the tag value, React will warn and will reconcile incorrectly. The README's own example dodges this by using the value as the key only because duplicates are disabled, and tells you to maintain stable occurrence IDs if you turn them on.
Editorial conclusion
Adopt Emblor v2 if you need a tag input with accessible announcements and native form submission and you want to keep control of the markup and styling. Do not adopt it expecting the autocomplete and popover behaviour of the v1 component, since the v2 package intentionally excludes it, and treat the 2.0.0-alpha tags as what they are. Verify first that the alpha API matches what you need, since v1 and v2 have different contracts and there is a separate migration document, and that you are prepared to own the keys and stable occurrence IDs for tags if you enable duplicates.
Frequently asked questions
What is Emblor v2 and how is it different from v1?
Emblor v2 is a headless tag-input primitive that owns tag and draft state, keyboard behaviour, validation, focus coordination, native forms and accessible announcements while consumers own markup and styling. The v1 line was a shadcn/ui component with autocomplete and popovers, and a MIGRATION.md document covers the change.
How do I install and use the Emblor tag input?
Import the parts from the package root, including EmblorRoot, EmblorInput, EmblorTagList, EmblorTag, EmblorTagText and EmblorTagRemove. The compound API requires exactly one EmblorInput, EmblorTagList is optional, and you use value with onValueChange or defaultValue for the string array of tags.
Does Emblor v2 support autocomplete or a suggestion dropdown?
No. The README states the package intentionally excludes autocomplete, cmdk, popovers, sorting, drag-and-drop, Tailwind, CVA and styling runtimes, so a suggestion layer has to be built on top of the primitive.
How does Emblor handle validation and rejected tags?
The transform function runs before the length, duplicate, capacity and boolean validate checks, and onReject receives structured rejection details rather than an exception. Pasted values are evaluated left-to-right and accepted in one batch transition.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/jaleelb-emblor)Was this useful?