Web InnoventixFreeCode

Profile Form

Original · free

profile settings form

byWeb InnoventixReact + Tailwind
formprofileforms
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/form-profile.json
form-profile.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import type {
  FormEvent,
  KeyboardEvent as ReactKeyboardEvent,
  ReactNode,
  Ref,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type AccentKey = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";
type Visibility = "public" | "members" | "private";

interface ProfileState {
  displayName: string;
  username: string;
  headline: string;
  bio: string;
  email: string;
  website: string;
  timezone: string;
  visibility: Visibility;
  accent: AccentKey;
  notifyProduct: boolean;
  notifyMentions: boolean;
  notifyDigest: boolean;
}

const BIO_MAX = 180;

const TAKEN = new Set(["admin", "design", "hello", "team", "lena", "support"]);

const ACCENTS: Record<
  AccentKey,
  { label: string; swatch: string; ring: string; grad: string }
> = {
  indigo: {
    label: "Indigo",
    swatch: "bg-indigo-500",
    ring: "ring-indigo-500",
    grad: "from-indigo-500 to-violet-600",
  },
  violet: {
    label: "Violet",
    swatch: "bg-violet-500",
    ring: "ring-violet-500",
    grad: "from-violet-500 to-indigo-600",
  },
  emerald: {
    label: "Emerald",
    swatch: "bg-emerald-500",
    ring: "ring-emerald-500",
    grad: "from-emerald-500 to-sky-600",
  },
  rose: {
    label: "Rose",
    swatch: "bg-rose-500",
    ring: "ring-rose-500",
    grad: "from-rose-500 to-amber-500",
  },
  amber: {
    label: "Amber",
    swatch: "bg-amber-500",
    ring: "ring-amber-500",
    grad: "from-amber-500 to-rose-500",
  },
  sky: {
    label: "Sky",
    swatch: "bg-sky-500",
    ring: "ring-sky-500",
    grad: "from-sky-500 to-indigo-600",
  },
};

const ACCENT_KEYS = Object.keys(ACCENTS) as AccentKey[];

const TIMEZONES: { value: string; label: string }[] = [
  { value: "America/Los_Angeles", label: "Pacific Time — Los Angeles (UTC−8)" },
  { value: "America/New_York", label: "Eastern Time — New York (UTC−5)" },
  { value: "Europe/London", label: "GMT — London (UTC+0)" },
  { value: "Europe/Warsaw", label: "Central European — Warsaw (UTC+1)" },
  { value: "Asia/Kolkata", label: "India Standard — Kolkata (UTC+5:30)" },
  { value: "Asia/Singapore", label: "Singapore Time — Singapore (UTC+8)" },
  { value: "Australia/Sydney", label: "Eastern Australia — Sydney (UTC+11)" },
];

const VISIBILITY: { value: Visibility; label: string; desc: string }[] = [
  {
    value: "public",
    label: "Public",
    desc: "Anyone on the web can find and view your profile.",
  },
  {
    value: "members",
    label: "Members only",
    desc: "Only signed-in members of your workspace can view it.",
  },
  {
    value: "private",
    label: "Private",
    desc: "Only you can see this profile. It stays hidden from search.",
  },
];

const INITIAL: ProfileState = {
  displayName: "Lena Kowalski",
  username: "lenak",
  headline: "Product designer · design systems",
  bio: "I design calm, high-contrast interfaces and the systems that keep them consistent — currently building the component library at Fieldnotes.",
  email: "lena@fieldnotes.studio",
  website: "https://lena.studio",
  timezone: "Europe/Warsaw",
  visibility: "members",
  accent: "indigo",
  notifyProduct: true,
  notifyMentions: true,
  notifyDigest: false,
};

const inputBase =
  "w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-zinc-900 shadow-sm outline-none transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:ring-offset-zinc-900";

function inputClass(hasError: boolean): string {
  return `${inputBase} ${
    hasError
      ? "border-rose-400 focus-visible:ring-rose-500 dark:border-rose-500/70"
      : "border-zinc-300 focus-visible:ring-indigo-500 dark:border-zinc-700"
  }`;
}

const labelClass =
  "mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200";
const hintClass = "mt-1.5 text-xs text-zinc-500 dark:text-zinc-400";

function validate(f: ProfileState): Partial<Record<keyof ProfileState, string>> {
  const e: Partial<Record<keyof ProfileState, string>> = {};
  if (f.displayName.trim().length < 2)
    e.displayName = "Enter your name — at least 2 characters.";
  if (!/^[a-z0-9_]{3,20}$/.test(f.username))
    e.username = "3–20 characters: lowercase letters, numbers, or underscores.";
  else if (TAKEN.has(f.username)) e.username = "That username is already taken.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(f.email.trim()))
    e.email = "Enter a valid email address.";
  if (f.website.trim() && !/^https?:\/\/[^\s.]+\.[^\s]{2,}$/.test(f.website.trim()))
    e.website = "Use a full URL, e.g. https://example.com.";
  if (f.bio.length > BIO_MAX)
    e.bio = `Keep your bio under ${BIO_MAX} characters.`;
  return e;
}

/* ---------- inline icons ---------- */

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
      className={className}
    >
      <path
        d="M4 10.5 8 14.5 16 6"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function WarnIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className={className}>
      <path
        fillRule="evenodd"
        d="M9.13 3.36a1 1 0 0 1 1.74 0l6.5 11.25A1 1 0 0 1 16.5 16h-13a1 1 0 0 1-.87-1.39l6.5-11.25ZM10 7a.9.9 0 0 0-.9.98l.3 3.2a.6.6 0 0 0 1.2 0l.3-3.2A.9.9 0 0 0 10 7Zm0 6.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9Z"
        clipRule="evenodd"
      />
    </svg>
  );
}

function XIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M6 6l8 8M14 6l-8 8"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
      />
    </svg>
  );
}

function ChevronIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <path
        d="M6 8l4 4 4-4"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ClockIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <circle cx="10" cy="10" r="7.2" stroke="currentColor" strokeWidth="1.6" />
      <path
        d="M10 6v4.2l2.8 1.6"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function GlobeIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={className}>
      <circle cx="10" cy="10" r="7.2" stroke="currentColor" strokeWidth="1.6" />
      <path
        d="M2.8 10h14.4M10 2.8c2 2 3 4.6 3 7.2s-1 5.2-3 7.2c-2-2-3-4.6-3-7.2s1-5.2 3-7.2Z"
        stroke="currentColor"
        strokeWidth="1.6"
      />
    </svg>
  );
}

function Spinner({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={`fp-spin ${className ?? ""}`}>
      <circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="2.2" className="opacity-25" />
      <path
        d="M10 2.5a7.5 7.5 0 0 1 7.5 7.5"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
      />
    </svg>
  );
}

/* ---------- reusable text field ---------- */

function TextField({
  id,
  label,
  value,
  onChange,
  error,
  type = "text",
  placeholder,
  hint,
  autoComplete,
  inputMode,
  inputRef,
}: {
  id: string;
  label: string;
  value: string;
  onChange: (v: string) => void;
  error?: string;
  type?: string;
  placeholder?: string;
  hint?: string;
  autoComplete?: string;
  inputMode?: "text" | "email" | "url";
  inputRef?: Ref<HTMLInputElement>;
}) {
  const errId = `${id}-err`;
  const hintId = `${id}-hint`;
  return (
    <div>
      <label htmlFor={id} className={labelClass}>
        {label}
      </label>
      <input
        id={id}
        ref={inputRef}
        type={type}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        autoComplete={autoComplete}
        inputMode={inputMode}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? errId : hint ? hintId : undefined}
        className={inputClass(!!error)}
      />
      {error ? (
        <p
          id={errId}
          role="alert"
          className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
        >
          <WarnIcon className="h-3.5 w-3.5 shrink-0" />
          {error}
        </p>
      ) : hint ? (
        <p id={hintId} className={hintClass}>
          {hint}
        </p>
      ) : null}
    </div>
  );
}

/* ---------- toggle switch ---------- */

function Toggle({
  checked,
  onChange,
  labelId,
  descId,
}: {
  checked: boolean;
  onChange: () => void;
  labelId: string;
  descId: string;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-labelledby={labelId}
      aria-describedby={descId}
      onClick={onChange}
      className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
        checked ? "bg-emerald-500" : "bg-zinc-300 dark:bg-zinc-700"
      }`}
    >
      <span
        className={`inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform ${
          checked ? "translate-x-[22px]" : "translate-x-0.5"
        }`}
      />
    </button>
  );
}

/* ---------- card wrapper ---------- */

function Card({
  title,
  description,
  children,
}: {
  title: string;
  description: string;
  children: ReactNode;
}) {
  return (
    <section className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900/60">
      <div className="mb-5">
        <h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
          {title}
        </h3>
        <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
          {description}
        </p>
      </div>
      {children}
    </section>
  );
}

export default function ProfileSettingsForm() {
  const reduce = useReducedMotion();
  const uid = useId();
  const fid = (s: string) => `${uid}-${s}`;

  const [baseline, setBaseline] = useState<ProfileState>(INITIAL);
  const [form, setForm] = useState<ProfileState>(INITIAL);
  const [errors, setErrors] = useState<
    Partial<Record<keyof ProfileState, string>>
  >({});
  const [saving, setSaving] = useState(false);
  const [showToast, setShowToast] = useState(false);
  const [availability, setAvailability] = useState<
    "idle" | "checking" | "available" | "taken" | "invalid"
  >("idle");

  const nameRef = useRef<HTMLInputElement>(null);
  const usernameRef = useRef<HTMLInputElement>(null);
  const emailRef = useRef<HTMLInputElement>(null);
  const websiteRef = useRef<HTMLInputElement>(null);
  const bioRef = useRef<HTMLTextAreaElement>(null);
  const swatchRefs = useRef<(HTMLButtonElement | null)[]>([]);

  function setField<K extends keyof ProfileState>(
    key: K,
    value: ProfileState[K]
  ) {
    setForm((f) => ({ ...f, [key]: value }));
    setErrors((prev) => {
      if (!prev[key]) return prev;
      const next = { ...prev };
      delete next[key];
      return next;
    });
  }

  const dirty = useMemo(
    () => JSON.stringify(form) !== JSON.stringify(baseline),
    [form, baseline]
  );

  const initials = useMemo(() => {
    const parts = form.displayName.trim().split(/\s+/).filter(Boolean);
    if (parts.length === 0) return "?";
    return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
  }, [form.displayName]);

  const bioLeft = BIO_MAX - form.bio.length;

  // Debounced username availability check.
  useEffect(() => {
    const u = form.username.trim();
    if (u === baseline.username) {
      setAvailability("idle");
      return;
    }
    if (!/^[a-z0-9_]{3,20}$/.test(u)) {
      setAvailability(u.length > 0 ? "invalid" : "idle");
      return;
    }
    setAvailability("checking");
    const t = window.setTimeout(() => {
      setAvailability(TAKEN.has(u) ? "taken" : "available");
    }, 600);
    return () => window.clearTimeout(t);
  }, [form.username, baseline.username]);

  // Auto-dismiss the success toast.
  useEffect(() => {
    if (!showToast) return;
    const t = window.setTimeout(() => setShowToast(false), 3800);
    return () => window.clearTimeout(t);
  }, [showToast]);

  function focusFirst(errs: Partial<Record<keyof ProfileState, string>>) {
    const order: [keyof ProfileState, HTMLElement | null][] = [
      ["displayName", nameRef.current],
      ["username", usernameRef.current],
      ["email", emailRef.current],
      ["website", websiteRef.current],
      ["bio", bioRef.current],
    ];
    for (const [key, el] of order) {
      if (errs[key] && el) {
        el.focus();
        break;
      }
    }
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const errs = validate(form);
    setErrors(errs);
    if (Object.keys(errs).length > 0) {
      focusFirst(errs);
      return;
    }
    setSaving(true);
    window.setTimeout(() => {
      setSaving(false);
      setBaseline(form);
      setShowToast(true);
    }, 700);
  }

  function handleReset() {
    setForm(baseline);
    setErrors({});
    setShowToast(false);
  }

  function onAccentKey(e: ReactKeyboardEvent<HTMLButtonElement>, idx: number) {
    let next = idx;
    if (e.key === "ArrowRight" || e.key === "ArrowDown")
      next = (idx + 1) % ACCENT_KEYS.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (idx - 1 + ACCENT_KEYS.length) % ACCENT_KEYS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = ACCENT_KEYS.length - 1;
    else return;
    e.preventDefault();
    setField("accent", ACCENT_KEYS[next]);
    swatchRefs.current[next]?.focus();
  }

  const usernameStatusId = fid("username-status");
  const usernameErrId = fid("username-err");
  const usernameDescribedBy =
    [errors.username ? usernameErrId : null, usernameStatusId]
      .filter(Boolean)
      .join(" ") || undefined;

  const availabilityMessage =
    availability === "checking"
      ? "Checking availability…"
      : availability === "available"
      ? `@${form.username} is available.`
      : availability === "taken"
      ? "That username is already taken."
      : availability === "invalid"
      ? "Use 3–20 lowercase letters, numbers, or underscores."
      : "";

  const activeTz = TIMEZONES.find((t) => t.value === form.timezone);

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-4 py-16 text-zinc-900 sm:px-6 sm:py-20 lg:px-8 dark:bg-zinc-950 dark:text-zinc-100">
      <style>{`
        @keyframes fp-spin { to { transform: rotate(360deg); } }
        @keyframes fp-pop {
          0% { transform: scale(.5); opacity: 0; }
          60% { transform: scale(1.12); }
          100% { transform: scale(1); opacity: 1; }
        }
        .fp-spin { animation: fp-spin .8s linear infinite; }
        .fp-pop { animation: fp-pop .28s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .fp-spin, .fp-pop { animation: none !important; }
        }
      `}</style>

      {/* soft background accents */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-500/10"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-24 -right-16 h-72 w-72 rounded-full bg-violet-200/40 blur-3xl dark:bg-violet-500/10"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="mb-8 sm:mb-10">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Account
          </p>
          <h2 className="mt-2 text-2xl font-bold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
            Profile settings
          </h2>
          <p className="mt-2 max-w-2xl text-sm text-zinc-600 dark:text-zinc-400">
            Update how you appear across the workspace. Changes are visible to
            teammates once you save.
          </p>
        </header>

        <form
          onSubmit={handleSubmit}
          noValidate
          className="grid gap-8 lg:grid-cols-[300px_minmax(0,1fr)]"
        >
          {/* live preview */}
          <aside className="lg:sticky lg:top-8 lg:self-start">
            <div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900/60">
              <div
                className={`h-20 bg-gradient-to-r ${ACCENTS[form.accent].grad}`}
                aria-hidden="true"
              />
              <div className="px-5 pb-5">
                <div
                  className={`-mt-10 flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br ${ACCENTS[form.accent].grad} text-2xl font-bold text-white shadow-md ring-4 ring-white dark:ring-zinc-900`}
                  aria-hidden="true"
                >
                  {initials}
                </div>
                <p className="mt-3 truncate text-lg font-semibold text-zinc-900 dark:text-zinc-50">
                  {form.displayName || "Your name"}
                </p>
                <p className="truncate text-sm text-zinc-500 dark:text-zinc-400">
                  @{form.username || "username"}
                </p>
                {form.headline ? (
                  <p className="mt-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
                    {form.headline}
                  </p>
                ) : null}
                {form.bio ? (
                  <p className="mt-3 line-clamp-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
                    {form.bio}
                  </p>
                ) : null}

                <dl className="mt-4 space-y-2 border-t border-zinc-100 pt-4 text-xs dark:border-zinc-800">
                  <div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
                    <ClockIcon className="h-4 w-4 shrink-0" />
                    <span className="truncate">
                      {activeTz ? activeTz.label : form.timezone}
                    </span>
                  </div>
                  {form.website.trim() &&
                  /^https?:\/\//.test(form.website.trim()) ? (
                    <div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
                      <GlobeIcon className="h-4 w-4 shrink-0" />
                      <a
                        href={form.website}
                        target="_blank"
                        rel="noopener"
                        className="truncate rounded text-indigo-600 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-400"
                      >
                        {form.website.replace(/^https?:\/\//, "")}
                      </a>
                    </div>
                  ) : null}
                  <div className="pt-1">
                    <span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                      <span
                        className={`h-1.5 w-1.5 rounded-full ${
                          form.visibility === "public"
                            ? "bg-emerald-500"
                            : form.visibility === "members"
                            ? "bg-amber-500"
                            : "bg-zinc-400 dark:bg-zinc-500"
                        }`}
                      />
                      {VISIBILITY.find((v) => v.value === form.visibility)?.label}
                    </span>
                  </div>
                </dl>
              </div>
            </div>
            <p className="mt-3 px-1 text-xs text-zinc-500 dark:text-zinc-400">
              Live preview — this is how your card looks to others.
            </p>
          </aside>

          {/* form fields */}
          <div className="space-y-6">
            <Card
              title="Public profile"
              description="Your name, handle, and a short bio shown across the workspace."
            >
              <div className="space-y-5">
                <TextField
                  id={fid("name")}
                  label="Display name"
                  value={form.displayName}
                  onChange={(v) => setField("displayName", v)}
                  error={errors.displayName}
                  placeholder="e.g. Lena Kowalski"
                  autoComplete="name"
                  inputRef={nameRef}
                />

                {/* username with availability */}
                <div>
                  <label htmlFor={fid("username")} className={labelClass}>
                    Username
                  </label>
                  <div className="relative">
                    <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-zinc-400 dark:text-zinc-500">
                      @
                    </span>
                    <input
                      id={fid("username")}
                      ref={usernameRef}
                      type="text"
                      value={form.username}
                      onChange={(e) =>
                        setField(
                          "username",
                          e.target.value.toLowerCase().replace(/\s+/g, "")
                        )
                      }
                      autoComplete="username"
                      aria-invalid={errors.username ? true : undefined}
                      aria-describedby={usernameDescribedBy}
                      className={`${inputClass(!!errors.username)} pl-7 pr-10`}
                    />
                    <span className="absolute right-3 top-1/2 -translate-y-1/2">
                      {availability === "checking" ? (
                        <Spinner className="h-4 w-4 text-zinc-400" />
                      ) : availability === "available" ? (
                        <CheckIcon className="h-4 w-4 text-emerald-500" />
                      ) : availability === "taken" ||
                        availability === "invalid" ? (
                        <XIcon className="h-4 w-4 text-rose-500" />
                      ) : null}
                    </span>
                  </div>
                  {errors.username ? (
                    <p
                      id={usernameErrId}
                      role="alert"
                      className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                    >
                      <WarnIcon className="h-3.5 w-3.5 shrink-0" />
                      {errors.username}
                    </p>
                  ) : null}
                  <p
                    id={usernameStatusId}
                    aria-live="polite"
                    className={`mt-1.5 text-xs ${
                      availability === "available"
                        ? "text-emerald-600 dark:text-emerald-400"
                        : availability === "taken" || availability === "invalid"
                        ? "text-rose-600 dark:text-rose-400"
                        : "text-zinc-500 dark:text-zinc-400"
                    }`}
                  >
                    {availabilityMessage ||
                      "Letters, numbers and underscores. This is your public @handle."}
                  </p>
                </div>

                <TextField
                  id={fid("headline")}
                  label="Headline"
                  value={form.headline}
                  onChange={(v) => setField("headline", v)}
                  placeholder="e.g. Product designer · design systems"
                  hint="A short line shown just under your name."
                />

                {/* bio with counter */}
                <div>
                  <div className="mb-1.5 flex items-baseline justify-between">
                    <label htmlFor={fid("bio")} className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
                      Bio
                    </label>
                    <span
                      aria-live="polite"
                      className={`text-xs tabular-nums ${
                        bioLeft < 0
                          ? "font-semibold text-rose-600 dark:text-rose-400"
                          : bioLeft < 20
                          ? "text-amber-600 dark:text-amber-400"
                          : "text-zinc-400 dark:text-zinc-500"
                      }`}
                    >
                      {bioLeft} left
                    </span>
                  </div>
                  <textarea
                    id={fid("bio")}
                    ref={bioRef}
                    rows={4}
                    value={form.bio}
                    onChange={(e) => setField("bio", e.target.value)}
                    aria-invalid={errors.bio ? true : undefined}
                    aria-describedby={errors.bio ? fid("bio-err") : undefined}
                    className={`${inputClass(!!errors.bio)} resize-y`}
                    placeholder="Tell people what you work on."
                  />
                  {errors.bio ? (
                    <p
                      id={fid("bio-err")}
                      role="alert"
                      className="mt-1.5 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                    >
                      <WarnIcon className="h-3.5 w-3.5 shrink-0" />
                      {errors.bio}
                    </p>
                  ) : null}
                </div>

                {/* accent picker (radiogroup) */}
                <div>
                  <span className={labelClass} id={fid("accent-label")}>
                    Avatar color
                  </span>
                  <div
                    role="radiogroup"
                    aria-labelledby={fid("accent-label")}
                    className="flex flex-wrap gap-2.5"
                  >
                    {ACCENT_KEYS.map((key, i) => {
                      const active = form.accent === key;
                      return (
                        <button
                          key={key}
                          ref={(el) => {
                            swatchRefs.current[i] = el;
                          }}
                          type="button"
                          role="radio"
                          aria-checked={active}
                          aria-label={ACCENTS[key].label}
                          tabIndex={active ? 0 : -1}
                          onClick={() => setField("accent", key)}
                          onKeyDown={(e) => onAccentKey(e, i)}
                          className={`relative flex h-9 w-9 items-center justify-center rounded-full ${ACCENTS[key].swatch} outline-none transition focus-visible:ring-2 focus-visible:ring-offset-2 ${ACCENTS[key].ring} focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
                            active
                              ? `ring-2 ring-offset-2 ${ACCENTS[key].ring} ring-offset-white dark:ring-offset-zinc-900`
                              : "ring-1 ring-inset ring-black/10 hover:scale-105 dark:ring-white/15"
                          }`}
                        >
                          {active ? (
                            <CheckIcon className="fp-pop h-4 w-4 text-white" />
                          ) : null}
                        </button>
                      );
                    })}
                  </div>
                </div>
              </div>
            </Card>

            <Card
              title="Contact"
              description="How teammates and visitors can reach you."
            >
              <div className="grid gap-5 sm:grid-cols-2">
                <TextField
                  id={fid("email")}
                  label="Email address"
                  value={form.email}
                  onChange={(v) => setField("email", v)}
                  error={errors.email}
                  type="email"
                  inputMode="email"
                  autoComplete="email"
                  placeholder="you@company.com"
                  inputRef={emailRef}
                />
                <TextField
                  id={fid("website")}
                  label="Website"
                  value={form.website}
                  onChange={(v) => setField("website", v)}
                  error={errors.website}
                  type="url"
                  inputMode="url"
                  autoComplete="url"
                  placeholder="https://example.com"
                  hint="Optional — shown as a link on your profile."
                  inputRef={websiteRef}
                />
              </div>
            </Card>

            <Card
              title="Visibility & timezone"
              description="Control who can see your profile and how times are shown."
            >
              <div className="space-y-6">
                <fieldset>
                  <legend className={labelClass}>Profile visibility</legend>
                  <div className="grid gap-3">
                    {VISIBILITY.map((opt) => {
                      const active = form.visibility === opt.value;
                      return (
                        <label
                          key={opt.value}
                          className={`flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition ${
                            active
                              ? "border-indigo-400 bg-indigo-50/70 dark:border-indigo-500/60 dark:bg-indigo-500/10"
                              : "border-zinc-200 hover:border-zinc-300 dark:border-zinc-800 dark:hover:border-zinc-700"
                          }`}
                        >
                          <input
                            type="radio"
                            name={fid("visibility")}
                            value={opt.value}
                            checked={active}
                            onChange={() => setField("visibility", opt.value)}
                            className="peer sr-only"
                          />
                          <span
                            aria-hidden="true"
                            className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-zinc-900 ${
                              active
                                ? "border-indigo-600 bg-indigo-600"
                                : "border-zinc-300 dark:border-zinc-600"
                            }`}
                          >
                            <span
                              className={`h-1.5 w-1.5 rounded-full bg-white transition-opacity ${
                                active ? "opacity-100" : "opacity-0"
                              }`}
                            />
                          </span>
                          <span className="min-w-0">
                            <span className="block text-sm font-medium text-zinc-900 dark:text-zinc-100">
                              {opt.label}
                            </span>
                            <span className="block text-xs text-zinc-500 dark:text-zinc-400">
                              {opt.desc}
                            </span>
                          </span>
                        </label>
                      );
                    })}
                  </div>
                </fieldset>

                <div>
                  <label htmlFor={fid("tz")} className={labelClass}>
                    Timezone
                  </label>
                  <div className="relative">
                    <select
                      id={fid("tz")}
                      value={form.timezone}
                      onChange={(e) => setField("timezone", e.target.value)}
                      className={`${inputClass(false)} cursor-pointer appearance-none pr-10`}
                    >
                      {TIMEZONES.map((tz) => (
                        <option key={tz.value} value={tz.value}>
                          {tz.label}
                        </option>
                      ))}
                    </select>
                    <ChevronIcon className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
                  </div>
                </div>
              </div>
            </Card>

            <Card
              title="Notifications"
              description="Choose which emails land in your inbox."
            >
              <ul className="divide-y divide-zinc-100 dark:divide-zinc-800">
                {(
                  [
                    {
                      key: "notifyProduct" as const,
                      title: "Product updates",
                      desc: "New features and meaningful changes to the platform.",
                    },
                    {
                      key: "notifyMentions" as const,
                      title: "Mentions & replies",
                      desc: "When someone @mentions you or replies to your comments.",
                    },
                    {
                      key: "notifyDigest" as const,
                      title: "Weekly digest",
                      desc: "A Monday summary of activity across your workspace.",
                    },
                  ]
                ).map((n) => {
                  const lid = fid(`${n.key}-label`);
                  const did = fid(`${n.key}-desc`);
                  return (
                    <li
                      key={n.key}
                      className="flex items-center justify-between gap-4 py-3.5 first:pt-0 last:pb-0"
                    >
                      <div className="min-w-0">
                        <p
                          id={lid}
                          className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
                        >
                          {n.title}
                        </p>
                        <p
                          id={did}
                          className="text-xs text-zinc-500 dark:text-zinc-400"
                        >
                          {n.desc}
                        </p>
                      </div>
                      <Toggle
                        checked={form[n.key]}
                        onChange={() => setField(n.key, !form[n.key])}
                        labelId={lid}
                        descId={did}
                      />
                    </li>
                  );
                })}
              </ul>
            </Card>

            {/* action bar */}
            <div className="sticky bottom-4 z-10 flex flex-col items-stretch gap-3 rounded-2xl border border-zinc-200 bg-white/85 p-3.5 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800 dark:bg-zinc-900/85">
              <p
                aria-live="polite"
                className="px-1 text-xs text-zinc-500 dark:text-zinc-400"
              >
                {dirty ? (
                  <span className="flex items-center gap-1.5 font-medium text-amber-600 dark:text-amber-400">
                    <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
                    You have unsaved changes.
                  </span>
                ) : (
                  "All changes saved."
                )}
              </p>
              <div className="flex items-center gap-2.5">
                <button
                  type="button"
                  onClick={handleReset}
                  disabled={!dirty || saving}
                  className="rounded-lg border border-zinc-300 bg-white px-4 py-2.5 text-sm font-medium text-zinc-700 outline-none transition hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 dark:focus-visible:ring-offset-zinc-900"
                >
                  Reset
                </button>
                <button
                  type="submit"
                  disabled={!dirty || saving}
                  className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm outline-none transition hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-offset-zinc-900"
                >
                  {saving ? (
                    <>
                      <Spinner className="h-4 w-4" />
                      Saving…
                    </>
                  ) : (
                    "Save changes"
                  )}
                </button>
              </div>
            </div>
          </div>
        </form>
      </div>

      {/* success toast */}
      <AnimatePresence>
        {showToast ? (
          <motion.div
            role="status"
            aria-live="polite"
            initial={reduce ? false : { opacity: 0, y: 14, scale: 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: 14, scale: 0.98 }}
            transition={{ duration: 0.28, ease: "easeOut" }}
            className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-emerald-200 bg-white px-4 py-3 shadow-xl sm:left-auto sm:right-6 sm:translate-x-0 dark:border-emerald-500/30 dark:bg-zinc-900"
          >
            <span className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
              <CheckIcon className="h-5 w-5" />
            </span>
            <div>
              <p className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
                Profile updated
              </p>
              <p className="text-xs text-zinc-500 dark:text-zinc-400">
                Your changes are now live for teammates.
              </p>
            </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →