Form — Repeatable field rows (add/remove)
A variable-length team-invite form where each member is a repeatable row of name, work email, and role select. Add appends a fresh row with a smooth height transition, while each row carries its own trash button, per-field validation, aria-invalid error helpers, and success states. Rows renumber on removal, a minimum of one is always enforced, duplicate emails are flagged, and a live seat counter plus an empty-state placeholder keep the whole list keyboard accessible.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.page {
display: flex;
justify-content: center;
align-items: flex-start;
padding: clamp(1rem, 3vw, 3rem) 1rem;
min-height: 100vh;
}
/* ── Card shell ─────────────────────────────── */
.card {
width: 100%;
max-width: 720px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
padding: clamp(1.25rem, 3vw, 2rem);
position: relative;
}
.card__head {
margin-bottom: 1.5rem;
}
.card__eyebrow {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand-d);
background: var(--brand-50);
border-radius: 999px;
padding: 0.3rem 0.65rem;
margin-bottom: 0.85rem;
}
.card__title {
font-size: clamp(1.4rem, 3.5vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.02em;
color: var(--ink);
}
.card__sub {
margin-top: 0.4rem;
color: var(--muted);
font-size: 0.92rem;
max-width: 46ch;
}
.card__sub strong {
color: var(--ink-2);
font-weight: 600;
}
/* ── Rows fieldset ─────────────────────────── */
.rows {
border: 0;
}
.rows__legend {
display: flex;
align-items: baseline;
gap: 0.65rem;
width: 100%;
font-size: 0.95rem;
font-weight: 700;
color: var(--ink);
margin-bottom: 0.85rem;
padding: 0;
}
.rows__count {
font-size: 0.78rem;
font-weight: 600;
color: var(--brand-d);
background: var(--brand-50);
border-radius: 999px;
padding: 0.18rem 0.6rem;
}
.rows__list {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
/* ── Single row ────────────────────────────── */
.row {
display: grid;
grid-template-columns: 28px 1fr 40px;
align-items: start;
gap: 0.65rem;
padding: 0.9rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-md);
/* height animation */
overflow: hidden;
transition:
opacity 0.28s ease,
transform 0.28s ease,
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.row:hover {
border-color: var(--line-2);
}
.row:focus-within {
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-50);
}
/* enter / leave animation states (toggled in JS) */
.row.is-entering {
opacity: 0;
transform: translateY(-6px);
}
.row.is-leaving {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
.row__index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin-top: 1.55rem;
border-radius: 50%;
background: var(--white);
border: 1px solid var(--line-2);
font-size: 0.8rem;
font-weight: 700;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.row__grid {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.9fr;
gap: 0.65rem;
min-width: 0;
}
.row__remove {
appearance: none;
-webkit-appearance: none;
margin-top: 1.4rem;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--white);
color: var(--muted);
cursor: pointer;
transition:
background 0.16s ease,
color 0.16s ease,
border-color 0.16s ease,
transform 0.1s ease;
}
.row__remove:hover {
color: var(--danger);
border-color: var(--danger);
background: #fdecea;
}
.row__remove:active {
transform: scale(0.94);
}
.row__remove:focus-visible {
outline: none;
border-color: var(--danger);
box-shadow: 0 0 0 3px rgba(212, 80, 62, 0.22);
}
.row__remove:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.row__remove:disabled:hover {
color: var(--muted);
border-color: var(--line);
background: var(--white);
}
/* ── Field ─────────────────────────────────── */
.field {
min-width: 0;
display: flex;
flex-direction: column;
}
.field__label {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-2);
margin-bottom: 0.3rem;
}
.req {
color: var(--danger);
font-weight: 700;
}
.field__input {
width: 100%;
font: inherit;
font-size: 0.9rem;
color: var(--ink);
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 0.55rem 0.7rem;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease,
background 0.16s ease;
}
.field__input::placeholder {
color: #aab0c8;
}
.field__input:hover {
border-color: rgba(16, 19, 34, 0.28);
}
.field__input:focus-visible,
.field__input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-50);
}
.field__input:disabled {
background: var(--bg);
color: var(--muted);
cursor: not-allowed;
}
/* select chevron */
.field--role {
min-width: 0;
}
.select-wrap {
position: relative;
}
.field__select {
appearance: none;
-webkit-appearance: none;
padding-right: 1.9rem;
cursor: pointer;
}
.select-wrap__chev {
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
pointer-events: none;
}
.field__help {
font-size: 0.74rem;
color: var(--muted);
margin-top: 0.3rem;
min-height: 1em;
}
/* error state */
.field.is-error .field__input {
border-color: var(--danger);
background: #fdf3f1;
}
.field.is-error .field__input:focus-visible,
.field.is-error .field__input:focus {
box-shadow: 0 0 0 3px rgba(212, 80, 62, 0.18);
}
.field.is-error .field__help {
color: var(--danger);
font-weight: 600;
}
/* success state */
.field.is-ok .field__input {
border-color: var(--ok);
}
.field.is-ok .field__help {
color: var(--ok);
}
/* ── Empty state ───────────────────────────── */
.rows__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.6rem;
padding: 1.75rem 1rem;
border: 1px dashed var(--line-2);
border-radius: var(--r-md);
color: var(--muted);
background: var(--bg);
}
.rows__empty svg {
color: var(--brand);
}
.rows__empty p {
font-size: 0.88rem;
max-width: 30ch;
}
/* ── Add button ────────────────────────────── */
.btn-add {
appearance: none;
-webkit-appearance: none;
margin-top: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.45rem;
font: inherit;
font-size: 0.88rem;
font-weight: 600;
color: var(--brand-d);
background: var(--white);
border: 1px dashed var(--brand);
border-radius: var(--r-sm);
padding: 0.6rem 0.95rem;
cursor: pointer;
transition:
background 0.16s ease,
border-color 0.16s ease,
transform 0.1s ease;
}
.btn-add:hover {
background: var(--brand-50);
}
.btn-add:active {
transform: scale(0.98);
}
.btn-add:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--brand-50);
border-style: solid;
}
.btn-add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-add:disabled:hover {
background: var(--white);
}
/* ── Footer ────────────────────────────────── */
.card__foot {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line);
}
.form-status {
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 0.85rem;
min-height: 1.1em;
}
.form-status.is-error {
color: var(--danger);
}
.form-status.is-ok {
color: var(--ok);
}
.card__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.seat-meter {
font-size: 0.82rem;
color: var(--muted);
}
.seat-meter strong {
color: var(--ink);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.btn-submit {
appearance: none;
-webkit-appearance: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font: inherit;
font-size: 0.92rem;
font-weight: 600;
color: var(--white);
background: var(--brand);
border: 0;
border-radius: var(--r-sm);
padding: 0.7rem 1.25rem;
cursor: pointer;
box-shadow: var(--sh-1);
transition:
background 0.16s ease,
transform 0.1s ease,
box-shadow 0.16s ease;
}
.btn-submit:hover {
background: var(--brand-d);
}
.btn-submit:active {
transform: translateY(1px);
}
.btn-submit:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--brand-50);
}
.btn-submit:disabled {
background: var(--muted);
cursor: not-allowed;
box-shadow: none;
}
/* ── Success state ─────────────────────────── */
.success {
margin-top: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
padding: 1.1rem 1.25rem;
border: 1px solid var(--accent);
background: var(--accent-soft);
border-radius: var(--r-md);
flex-wrap: wrap;
}
.success__badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--accent);
color: var(--white);
flex-shrink: 0;
}
.success__title {
font-size: 1.02rem;
font-weight: 700;
color: var(--ink);
}
.success__text {
font-size: 0.85rem;
color: var(--ink-2);
}
.success div {
min-width: 0;
}
.btn-reset {
appearance: none;
-webkit-appearance: none;
margin-left: auto;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
color: var(--brand-d);
background: var(--white);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 0.55rem 0.95rem;
cursor: pointer;
transition: background 0.16s ease;
}
.btn-reset:hover {
background: var(--brand-50);
}
.btn-reset:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--brand-50);
}
/* ── Toast ─────────────────────────────────── */
.toast-region {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 50;
pointer-events: none;
width: max-content;
max-width: calc(100vw - 2rem);
}
.toast {
display: flex;
align-items: center;
gap: 0.55rem;
background: var(--ink);
color: var(--white);
font-size: 0.85rem;
font-weight: 500;
padding: 0.65rem 1rem;
border-radius: var(--r-sm);
box-shadow: var(--sh-2);
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.toast svg {
flex-shrink: 0;
}
.toast--ok svg {
color: #6ee0c4;
}
.toast--warn svg {
color: #ffcf8a;
}
/* ── Responsive ────────────────────────────── */
@media (max-width: 640px) {
.row__grid {
grid-template-columns: 1fr 1fr;
}
.field--role {
grid-column: 1 / -1;
}
}
@media (max-width: 520px) {
.card {
border-radius: var(--r-md);
padding: 1.1rem;
}
.row {
grid-template-columns: 1fr;
gap: 0.4rem;
position: relative;
padding-top: 2.4rem;
}
.row__grid {
grid-template-columns: 1fr;
}
.field--role {
grid-column: auto;
}
.row__index {
position: absolute;
top: 0.7rem;
left: 0.9rem;
margin-top: 0;
}
.row__remove {
position: absolute;
top: 0.6rem;
right: 0.7rem;
margin-top: 0;
width: 34px;
height: 34px;
}
.card__actions {
flex-direction: column-reverse;
align-items: stretch;
}
.btn-submit {
justify-content: center;
}
.seat-meter {
text-align: center;
}
.toast-region {
bottom: 0.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}(function () {
"use strict";
var MAX_ROWS = 10;
var ROLE_LABELS = {
viewer: "Viewer",
editor: "Editor",
admin: "Admin",
owner: "Owner",
};
var form = document.getElementById("teamForm");
var list = document.getElementById("rowsList");
var tmpl = document.getElementById("rowTemplate");
var emptyState = document.getElementById("rowsEmpty");
var addBtn = document.getElementById("addRow");
var countEl = document.getElementById("rowCount");
var seatEl = document.getElementById("seatTotal");
var statusEl = document.getElementById("formStatus");
var submitBtn = document.getElementById("submitBtn");
var successState = document.getElementById("successState");
var successText = document.getElementById("successText");
var resetBtn = document.getElementById("resetBtn");
var toastRegion = document.getElementById("toastRegion");
var uid = 0;
var prefersReduced =
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ── Toast helper ─────────────────────────────
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast toast--" + (kind || "ok");
var icon =
kind === "warn"
? '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/>'
: '<path fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5"/>';
el.innerHTML =
'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">' +
icon +
"</svg><span></span>";
el.querySelector("span").textContent = msg;
toastRegion.appendChild(el);
requestAnimationFrame(function () {
el.classList.add("is-visible");
});
setTimeout(function () {
el.classList.remove("is-visible");
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 280);
}, 2600);
}
// ── Field validation ─────────────────────────
function setFieldState(field, state, message) {
field.classList.remove("is-error", "is-ok");
var input = field.querySelector("[data-input]");
var help = field.querySelector("[data-help]");
if (state === "error") {
field.classList.add("is-error");
input.setAttribute("aria-invalid", "true");
} else {
input.removeAttribute("aria-invalid");
if (state === "ok") field.classList.add("is-ok");
}
if (message != null && help) help.textContent = message;
}
function validateName(field) {
var input = field.querySelector("[data-input]");
var v = input.value.trim();
if (!v) {
setFieldState(field, "error", "Name is required.");
return false;
}
if (v.length < 2) {
setFieldState(field, "error", "Enter at least 2 characters.");
return false;
}
setFieldState(field, "ok", "Looks good.");
return true;
}
function validateEmail(field) {
var input = field.querySelector("[data-input]");
var v = input.value.trim();
var re = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
if (!v) {
setFieldState(field, "error", "Email is required.");
return false;
}
if (!re.test(v)) {
setFieldState(field, "error", "Enter a valid email address.");
return false;
}
setFieldState(field, "ok", "We send the invite here.");
return true;
}
function validateRow(row, focusFirst) {
var nameField = row.querySelector('[data-field="name"]');
var emailField = row.querySelector('[data-field="email"]');
var okName = validateName(nameField);
var okEmail = validateEmail(emailField);
if (focusFirst && (!okName || !okEmail)) {
var bad = !okName ? nameField : emailField;
bad.querySelector("[data-input]").focus();
}
return okName && okEmail;
}
// Duplicate-email detection across rows
function checkDuplicateEmails() {
var seen = {};
var rows = list.querySelectorAll("[data-row]");
var hasDupe = false;
rows.forEach(function (row) {
var field = row.querySelector('[data-field="email"]');
var input = field.querySelector("[data-input]");
var v = input.value.trim().toLowerCase();
if (!v) return;
if (seen[v]) {
setFieldState(field, "error", "Duplicate email — already invited above.");
hasDupe = true;
} else {
seen[v] = true;
}
});
return !hasDupe;
}
// ── Row creation ─────────────────────────────
function addRow(focus) {
if (list.children.length >= MAX_ROWS) {
toast("Seat limit reached (10 max).", "warn");
return null;
}
var node = tmpl.content.firstElementChild.cloneNode(true);
uid += 1;
var id = "row" + uid;
node.dataset.id = id;
// unique ids + label wiring for a11y
["name", "email", "role"].forEach(function (key) {
var field = node.querySelector('[data-field="' + key + '"]');
var input = field.querySelector("[data-input]");
var label = field.querySelector("label");
var help = field.querySelector("[data-help]");
var inputId = id + "_" + key;
input.id = inputId;
label.setAttribute("for", inputId);
if (help) {
var helpId = inputId + "_help";
help.id = helpId;
input.setAttribute("aria-describedby", helpId);
}
// live re-validation
if (key === "name") {
input.addEventListener("blur", function () {
validateName(field);
});
input.addEventListener("input", function () {
if (field.classList.contains("is-error")) validateName(field);
});
} else if (key === "email") {
input.addEventListener("blur", function () {
validateEmail(field);
});
input.addEventListener("input", function () {
if (field.classList.contains("is-error")) validateEmail(field);
});
}
});
node.querySelector("[data-remove]").addEventListener("click", function () {
removeRow(node);
});
list.appendChild(node);
// smooth enter transition
if (!prefersReduced) {
node.classList.add("is-entering");
requestAnimationFrame(function () {
requestAnimationFrame(function () {
node.classList.remove("is-entering");
});
});
}
renumber();
if (focus) node.querySelector('[data-input="name"]').focus();
return node;
}
function removeRow(row) {
if (list.children.length <= 1) {
toast("At least one member is required.", "warn");
return;
}
// remember focus target: next row, else previous
var next = row.nextElementSibling || row.previousElementSibling;
var finalize = function () {
if (row.parentNode) row.parentNode.removeChild(row);
renumber();
if (next) {
var input = next.querySelector('[data-input="name"]');
if (input) input.focus();
} else {
addBtn.focus();
}
toast("Member removed.", "ok");
};
if (prefersReduced) {
finalize();
return;
}
// animate collapse using fixed height -> 0
row.style.height = row.offsetHeight + "px";
row.classList.add("is-leaving");
requestAnimationFrame(function () {
row.style.height = "0px";
row.style.marginTop = "0px";
row.style.paddingTop = "0px";
row.style.paddingBottom = "0px";
});
row.addEventListener("transitionend", function handler(e) {
if (e.propertyName !== "height") return;
row.removeEventListener("transitionend", handler);
finalize();
});
// safety fallback
setTimeout(function () {
if (row.parentNode && row.classList.contains("is-leaving")) finalize();
}, 400);
}
// ── Renumber + counts + empty state ──────────
function renumber() {
var rows = list.querySelectorAll("[data-row]");
var n = rows.length;
rows.forEach(function (row, i) {
var idx = row.querySelector("[data-index]");
if (idx) idx.textContent = String(i + 1);
var remove = row.querySelector("[data-remove]");
// disable remove when only one row remains
remove.disabled = n <= 1;
});
countEl.textContent = n + (n === 1 ? " member" : " members");
seatEl.textContent = String(n);
addBtn.disabled = n >= MAX_ROWS;
emptyState.hidden = n !== 0;
// hide success once edited again
if (!successState.hidden) {
successState.hidden = true;
}
}
// ── Submit ───────────────────────────────────
function setStatus(msg, kind) {
statusEl.textContent = msg || "";
statusEl.classList.remove("is-error", "is-ok");
if (kind) statusEl.classList.add("is-" + kind);
}
form.addEventListener("submit", function (e) {
e.preventDefault();
var rows = list.querySelectorAll("[data-row]");
if (rows.length === 0) {
setStatus("Add at least one team member before sending.", "error");
toast("Add at least one member first.", "warn");
return;
}
var allValid = true;
var firstInvalid = null;
rows.forEach(function (row) {
var valid = validateRow(row, false);
if (!valid && !firstInvalid) firstInvalid = row;
if (!valid) allValid = false;
});
var noDupes = checkDuplicateEmails();
if (!noDupes) allValid = false;
if (!allValid) {
var bad = rows.length;
var count = list.querySelectorAll(".field.is-error").length;
setStatus(
"Please fix " +
count +
(count === 1 ? " field" : " fields") +
" before sending.",
"error"
);
toast("Some rows need attention.", "warn");
if (firstInvalid) {
var input = firstInvalid.querySelector(".field.is-error [data-input]");
if (input) input.focus();
} else {
var dupe = list.querySelector(".field.is-error [data-input]");
if (dupe) dupe.focus();
}
return;
}
// success
submitBtn.disabled = true;
addBtn.disabled = true;
setStatus("", null);
var n = rows.length;
var roleCounts = {};
rows.forEach(function (row) {
var role = row.querySelector('[data-input="role"]').value;
roleCounts[role] = (roleCounts[role] || 0) + 1;
});
var roleSummary = Object.keys(roleCounts)
.map(function (k) {
return roleCounts[k] + " " + ROLE_LABELS[k].toLowerCase();
})
.join(", ");
successText.textContent =
"Sent " +
n +
(n === 1 ? " invitation" : " invitations") +
" (" +
roleSummary +
"). Recipients will receive an email shortly.";
successState.hidden = false;
successState.scrollIntoView({ behavior: prefersReduced ? "auto" : "smooth", block: "nearest" });
resetBtn.focus();
toast("Invites sent successfully.", "ok");
});
// ── Reset / invite more ──────────────────────
resetBtn.addEventListener("click", function () {
successState.hidden = true;
submitBtn.disabled = false;
list.innerHTML = "";
uid = 0;
addRow(true);
setStatus("", null);
toast("Ready for new invites.", "ok");
});
addBtn.addEventListener("click", function () {
addRow(true);
});
// ── Init ─────────────────────────────────────
addRow(false);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Form — Repeatable field rows</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<form class="card" id="teamForm" novalidate>
<header class="card__head">
<div class="card__eyebrow">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11"
/>
</svg>
Project invite
</div>
<h1 class="card__title">Invite your team</h1>
<p class="card__sub">
Add the people who should get access to <strong>Northwind Studio</strong>. You can edit
roles later from project settings.
</p>
</header>
<fieldset class="rows" id="rowsFieldset">
<legend class="rows__legend">
Team members
<span class="rows__count" id="rowCount" aria-live="polite">1 member</span>
</legend>
<!-- Repeatable rows injected here by script.js -->
<div class="rows__list" id="rowsList" role="group" aria-label="Team member rows"></div>
<!-- Empty state placeholder (shown only when list is emptied to zero) -->
<div class="rows__empty" id="rowsEmpty" hidden>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM19 8v6M22 11h-6"
/>
</svg>
<p>No members yet. Add the first teammate to invite.</p>
</div>
<button type="button" class="btn-add" id="addRow">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 5v14M5 12h14"
/>
</svg>
Add member
</button>
</fieldset>
<footer class="card__foot">
<p class="form-status" id="formStatus" role="status" aria-live="polite"></p>
<div class="card__actions">
<span class="seat-meter" aria-live="polite">
<strong id="seatTotal">1</strong> of 10 seats used
</span>
<button type="submit" class="btn-submit" id="submitBtn">
Send invites
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 12h14M13 6l6 6-6 6"
/>
</svg>
</button>
</div>
</footer>
<!-- Success confirmation, revealed after a valid submit -->
<div class="success" id="successState" hidden role="alert">
<span class="success__badge">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M20 6 9 17l-5-5"
/>
</svg>
</span>
<div>
<h2 class="success__title">Invites sent</h2>
<p class="success__text" id="successText">Your invitations are on the way.</p>
</div>
<button type="button" class="btn-reset" id="resetBtn">Invite more</button>
</div>
</form>
</main>
<!-- Row template -->
<template id="rowTemplate">
<div class="row" data-row>
<span class="row__index" data-index aria-hidden="true">1</span>
<div class="row__grid">
<div class="field" data-field="name">
<label class="field__label">
Full name <span class="req" aria-hidden="true">*</span>
</label>
<input
type="text"
class="field__input"
data-input="name"
placeholder="e.g. Priya Raman"
autocomplete="off"
required
/>
<p class="field__help" data-help>First and last name.</p>
</div>
<div class="field" data-field="email">
<label class="field__label">
Work email <span class="req" aria-hidden="true">*</span>
</label>
<input
type="email"
class="field__input"
data-input="email"
placeholder="name@company.com"
autocomplete="off"
required
/>
<p class="field__help" data-help>We send the invite here.</p>
</div>
<div class="field field--role" data-field="role">
<label class="field__label">Role</label>
<div class="select-wrap">
<select class="field__input field__select" data-input="role">
<option value="viewer">Viewer</option>
<option value="editor" selected>Editor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
<svg class="select-wrap__chev" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="m6 9 6 6 6-6"
/>
</svg>
</div>
</div>
</div>
<button type="button" class="row__remove" data-remove aria-label="Remove this member">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"
/>
</svg>
</button>
</div>
</template>
<!-- Toast region -->
<div class="toast-region" id="toastRegion" aria-live="assertive" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Repeatable field rows (add/remove)
A team-invite form built around a variable-length list of rows. Each row collects a teammate’s full name, work email, and role (Viewer, Editor, Admin, Owner). The “Add member” button appends a blank row with a smooth enter transition and moves focus into its first field, while every row carries a trash button that collapses the row by animating its height to zero before removing it.
Validation runs per row: required name and email checks with inline helper messages, aria-invalid, and live re-validation as the user fixes a field. On submit the form validates every row, flags duplicate emails across rows, focuses the first problem, and otherwise reveals a success confirmation summarising how many invites were sent by role. A minimum of one row is always enforced — the lone remove button disables itself — and an empty-state placeholder is wired in for the zero-row case.
Rows renumber automatically after any removal, a running member count and a “seats used” meter stay in sync, and an aria-live status plus a small toast helper announce changes. The layout collapses from a desktop grid to fully stacked cards below 520px, and all interactions are keyboard-operable with visible focus rings.