diff --git a/packages/junipero/lib/TagsField/index.js b/packages/junipero/lib/TagsField/index.js
new file mode 100644
index 000000000..06a8d2e94
--- /dev/null
+++ b/packages/junipero/lib/TagsField/index.js
@@ -0,0 +1,446 @@
+import React, {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useReducer,
+ useRef,
+} from 'react';
+import PropTypes from 'prop-types';
+
+import { classNames, mockState } from '../utils';
+import { useTimeout } from '../hooks';
+import Dropdown from '../Dropdown';
+import DropdownMenu from '../DropdownMenu';
+import DropdownToggle from '../DropdownToggle';
+import DropdownItem from '../DropdownItem';
+
+const TagsField = forwardRef(({
+ autoFocus,
+ className,
+ label,
+ options,
+ placeholder,
+ search,
+ autoAddOnBlur = false,
+ disabled = false,
+ forceLabel = false,
+ noSearchResults = 'No result found :(',
+ onlyAllowSearchResults = false,
+ onlyAllowOneOccurence = true,
+ required = false,
+ searchMinCharacters = 2,
+ searchThreshold = 400,
+ value,
+ onBlur = () => {},
+ onChange = () => {},
+ onFocus = () => {},
+ onKeyDown = () => {},
+ onKeyPress = () => {},
+ parseValue = val => val?.trim?.() || val,
+ parseTitle = val => val,
+ validate = val => !!val?.length || !required,
+ validateInput = val => !!val?.trim?.(),
+ validateTag = val => !!val,
+}, ref) => {
+ const innerRef = useRef();
+ const wrapperRef = useRef();
+ const inputRef = useRef();
+ const dropdownRef = useRef();
+ const menuRef = useRef();
+ const [state, dispatch] = useReducer(mockState, {
+ value: value || [],
+ valid: validate(value || []) && (value || []).every(t => validateTag(t)),
+ availableOptions: options,
+ inputValue: '',
+ inputValid: true,
+ inputDirty: false,
+ dirty: false,
+ focused: autoFocus ?? false,
+ searchResults: null,
+ searching: false,
+ opened: false,
+ });
+
+ useImperativeHandle(ref, () => ({
+ innerRef,
+ wrapperRef,
+ inputRef,
+ dropdownRef,
+ menuRef,
+ internalValue: state.value,
+ inputValue: state.inputValue,
+ dirty: state.dirty,
+ opened: state.opened,
+ searchResults: state.searchResults,
+ searching: state.searching,
+ valid: state.valid,
+ focus,
+ blur,
+ reset,
+ add,
+ remove,
+ }));
+
+ useEffect(() => {
+ state.value = value || [];
+
+ dispatch({
+ value: state.value,
+ valid: validate(state.value) && state.value.every(t => validateTag(t)),
+ });
+ }, [value]);
+
+ useTimeout(() => {
+ search_();
+ }, searchThreshold, [state.inputValue]);
+
+ const onInputChange_ = e => {
+ dispatch({
+ inputValue: e.target.value,
+ inputDirty: true,
+ inputValid: validateInput(e.target.value),
+ searching: !!search,
+ });
+
+ if (!e.target.value.trim() && !!search && !options) {
+ dropdownRef.current?.close();
+ }
+ };
+
+ const onInputFocus_ = e => {
+ dispatch({ focused: true });
+
+ if (
+ (state.searchResults && state.inputValue) ||
+ state.availableOptions?.length
+ ) {
+ dropdownRef.current?.open();
+ }
+
+ onFocus(e);
+ };
+
+ const onInputBlur_ = e => {
+ dispatch({ focused: false });
+
+ if (state.inputValue && autoAddOnBlur && validateInput(state.inputValue)) {
+ add(state.inputValue);
+ }
+
+ onBlur(e);
+ };
+
+ const onKeyPress_ = e => {
+ if (disabled) {
+ return;
+ }
+
+ if (
+ e.key === 'Enter' &&
+ !onlyAllowSearchResults &&
+ validateInput(state.inputValue)
+ ) {
+ add(state.inputValue);
+ }
+
+ onKeyPress(e);
+ };
+
+ const onKeyDown_ = e => {
+ if (disabled) {
+ return;
+ }
+
+ if (
+ (e.key === 'Backspace' || e.key === 'ArrowLeft') &&
+ !state.inputValue.trim() &&
+ state.value.length
+ ) {
+ focus(state.value.length - 1);
+ }
+
+ onKeyDown(e);
+ };
+
+ const onTagKeyDown_ = (index, e) => {
+ if (disabled) {
+ return;
+ }
+
+ if (e.key === 'Backspace') {
+ remove(index);
+ inputRef.current?.focus();
+ } else if (e.key === 'ArrowLeft' && index > 0) {
+ focus(index - 1);
+ } else if (e.key === 'ArrowRight') {
+ focus(index + 1);
+ }
+ };
+
+ const onWrapperClick_ = e => {
+ if (disabled || e.target !== wrapperRef.current) {
+ return;
+ } else {
+ e.preventDefault();
+ }
+
+ focus();
+ };
+
+ const onDropdownToggle_ = ({ opened }) =>
+ dispatch({ opened });
+
+ const onOptionClick_ = (option, e) => {
+ e.preventDefault();
+
+ add(option);
+ };
+
+ const add = item => {
+ if (disabled || (typeof item === 'string' && !item.trim())) {
+ return;
+ }
+
+ state.value.push(item);
+ state.availableOptions = options?.filter(o =>
+ !onlyAllowOneOccurence ||
+ !state.value.find(i => parseValue(i) === parseValue(o)));
+
+ dispatch({
+ value: state.value,
+ valid: validate(state.value) && state.value.every(t => validateTag(t)),
+ availableOptions: state.availableOptions,
+ inputValue: '',
+ inputDirty: false,
+ inputValid: true,
+ dirty: true,
+ });
+ onChange({ value: state.value.map(i => parseValue(i)) });
+
+ if (state.searchResults) {
+ dispatch({ searchResults: null, searching: false });
+ dropdownRef.current?.close();
+ }
+ };
+
+ const remove = (index, e) => {
+ e?.preventDefault?.();
+
+ if (disabled) {
+ return;
+ }
+
+ state.value.splice(index, 1);
+ state.availableOptions = options?.filter(o =>
+ !onlyAllowOneOccurence ||
+ !state.value.find(i => parseValue(i) === parseValue(o)));
+
+ dispatch({
+ value: state.value,
+ valid: validate(state.value) && state.value.every(t => validateTag(t)),
+ availableOptions: state.availableOptions,
+ dirty: true,
+ });
+ onChange({ value: state.value.map(i => parseValue(i)) });
+ };
+
+ const focus = index => {
+ if (index >= 0 && index < state.value.length) {
+ innerRef.current
+ ?.querySelector(`.tag:nth-child(${index + 1})`)?.focus?.();
+ } else {
+ inputRef.current?.focus();
+ }
+ };
+
+ const blur = () => {
+ inputRef.current?.blur();
+ innerRef.current?.querySelector('.tag:focus')?.blur();
+ };
+
+ const reset = () => {
+ state.value = value || [];
+
+ dispatch({
+ value: state.value,
+ dirty: false,
+ valid: validate(state.value) && state.value.every(t => validateTag(t)),
+ inputValue: '',
+ inputDirty: false,
+ inputValid: true,
+ });
+ };
+
+ const search_ = async () => {
+ if (!state.inputValue) {
+ dispatch({ searching: false, searchResults: null });
+ }
+
+ if (!search || state.inputValue?.length < searchMinCharacters) {
+ return;
+ }
+
+ const results = await search(state.inputValue);
+ dispatch({ searchResults: results, searching: false });
+ dropdownRef.current?.open();
+ };
+
+ const isEmpty = () =>
+ !state.inputValue && !state.value.length;
+
+ const renderOption = (o, index) => (
+