diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx
new file mode 100644
index 0000000000000..5362236a8b24d
--- /dev/null
+++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx
@@ -0,0 +1,37 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import React, { useState } from 'react';
+
+import Box from 'design/Box';
+
+import { FieldMultiInput } from './FieldMultiInput';
+
+export default {
+ title: 'Shared',
+};
+
+export function Story() {
+ const [items, setItems] = useState([]);
+ return (
+
+
+
+ );
+}
+Story.storyName = 'FieldMultiInput';
diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx
new file mode 100644
index 0000000000000..ce023a071053a
--- /dev/null
+++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx
@@ -0,0 +1,71 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import userEvent from '@testing-library/user-event';
+import React, { useState } from 'react';
+
+import { render, screen } from 'design/utils/testing';
+
+import { FieldMultiInput, FieldMultiInputProps } from './FieldMultiInput';
+
+const TestFieldMultiInput = ({
+ onChange,
+ ...rest
+}: Partial) => {
+ const [items, setItems] = useState([]);
+ const handleChange = (it: string[]) => {
+ setItems(it);
+ onChange?.(it);
+ };
+ return ;
+};
+
+test('adding, editing, and removing items', async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render();
+
+ await user.type(screen.getByRole('textbox'), 'apples');
+ expect(onChange).toHaveBeenLastCalledWith(['apples']);
+
+ await user.click(screen.getByRole('button', { name: 'Add More' }));
+ expect(onChange).toHaveBeenLastCalledWith(['apples', '']);
+
+ await user.type(screen.getAllByRole('textbox')[1], 'oranges');
+ expect(onChange).toHaveBeenLastCalledWith(['apples', 'oranges']);
+
+ await user.click(screen.getAllByRole('button', { name: 'Remove Item' })[0]);
+ expect(onChange).toHaveBeenLastCalledWith(['oranges']);
+
+ await user.click(screen.getAllByRole('button', { name: 'Remove Item' })[0]);
+ expect(onChange).toHaveBeenLastCalledWith([]);
+});
+
+test('keyboard handling', async () => {
+ const user = userEvent.setup();
+ const onChange = jest.fn();
+ render();
+
+ await user.click(screen.getByRole('textbox'));
+ await user.keyboard('apples{Enter}oranges');
+ expect(onChange).toHaveBeenLastCalledWith(['apples', 'oranges']);
+
+ await user.click(screen.getAllByRole('textbox')[0]);
+ await user.keyboard('{Enter}bananas');
+ expect(onChange).toHaveBeenLastCalledWith(['apples', 'bananas', 'oranges']);
+});
diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx
new file mode 100644
index 0000000000000..eaa98ef0a6511
--- /dev/null
+++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx
@@ -0,0 +1,139 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import Box from 'design/Box';
+import { ButtonSecondary } from 'design/Button';
+import ButtonIcon from 'design/ButtonIcon';
+import Flex from 'design/Flex';
+import * as Icon from 'design/Icon';
+import Input from 'design/Input';
+import { useRef } from 'react';
+import styled, { useTheme } from 'styled-components';
+
+export type FieldMultiInputProps = {
+ label?: string;
+ value: string[];
+ disabled?: boolean;
+ onChange?(val: string[]): void;
+};
+
+/**
+ * Allows editing a list of strings, one value per row. Use instead of
+ * `FieldSelectCreatable` when:
+ *
+ * - There are no predefined values to be picked from.
+ * - Values are expected to be relatively long and would be unreadable after
+ * being truncated.
+ */
+export function FieldMultiInput({
+ label,
+ value,
+ disabled,
+ onChange,
+}: FieldMultiInputProps) {
+ if (value.length === 0) {
+ value = [''];
+ }
+
+ const theme = useTheme();
+ // Index of the input to be focused after the next rendering.
+ const toFocus = useRef();
+
+ const setFocus = element => {
+ element?.focus();
+ toFocus.current = undefined;
+ };
+
+ function insertItem(index: number) {
+ onChange?.(value.toSpliced(index, 0, ''));
+ }
+
+ function removeItem(index: number) {
+ onChange?.(value.toSpliced(index, 1));
+ }
+
+ function handleKeyDown(index: number, e: React.KeyboardEvent) {
+ if (e.key === 'Enter') {
+ insertItem(index + 1);
+ toFocus.current = index + 1;
+ }
+ }
+
+ return (
+
+
+
+ );
+}
+
+const Fieldset = styled.fieldset`
+ border: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: ${props => props.theme.space[2]}px;
+`;
+
+const Legend = styled.legend`
+ margin: 0 0 ${props => props.theme.space[1]}px 0;
+ padding: 0;
+ ${props => props.theme.typography.body3}
+`;