HextaUI

Profile

The Profile section of an AI product's settings. Crop a photo into a circle, pick a username that's checked as you type, confirm a new email with a 6-digit code, add links that recognize the site, and see a live card of how others see you.

ProfileSettings is the Profile page from a product like Claude or Cursor, built to drop into any SettingsShell section. Give it the saved profile and an onSave, and it handles the rest: a draft that only saves when you say so, field errors from your checks or your server, and the floating save bar from Settings with ⌘S.

At the top, a card shows how others see your profile and changes as you type: photo, name, handle, bio and links. Turn off Show my profile and the card dims and says only you can see it. Copy link puts the profile URL on the clipboard.

Pick a photo, or drop one on the row, and it opens in a crop dialog. Drag to move it, zoom with the slider, the wheel or a trackpad pinch, and the photo is cropped to a square in the browser before it reaches onSave as a data URL. Files of the wrong type, too large or under 128 pixels are refused in the row with the reason.

The username is checked as you type, without slowing typing. Checks wait 350ms after the last key, stale checks are cancelled through an AbortSignal, and names you already checked answer instantly. A taken name shows your suggestions as one-tap fixes. If you save before a check finishes, the save waits for it.

Email isn't part of the draft. Change opens a two-step dialog: enter the new address, then the 6-digit code sent to it, which checks itself when the last digit goes in. A wrong code shakes and clears, Resend unlocks after 30 seconds, and the row updates once the code is confirmed. An unverified address shows Resend link.

Links take whatever people paste: github.com/you, a full URL or a www address. Known sites get their icon, anything else its first letter. On save they're normalized to https URLs and empty ones are dropped. Addresses that aren't links or repeat another are marked one by one.

  1. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  3. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/profile

Wire it to your API

Pass the saved profile and an onSave that returns field errors or throws. checkUsername gets an AbortSignal, so stale checks are cancelled as people type.

"use client"

import { SettingsSection, SettingsShell } from "../settings/settings"

import { ProfileSettings, type ProfileValues } from "@/components/blocks/profile/profile"

export function ProfilePage({ profile, email }: { profile: ProfileValues; email: string }) {
  return (
    <SettingsShell sections={[{ id: "profile", label: "Profile" }]} className="h-svh">
      <SettingsSection id="profile">
        <ProfileSettings
          profile={profile}
          email={email}
          checkUsername={async (username, signal) => {
            const response = await fetch(`/api/usernames/${username}`, { signal })
            return response.json()
          }}
          onSave={async (values) => {
            const response = await fetch("/api/profile", {
              method: "PATCH",
              body: JSON.stringify(values),
            })
            if (response.status === 409) return { username: "That username is taken." }
            if (!response.ok) throw new Error("Check your connection and try again.")
          }}
        />
      </SettingsSection>
    </SettingsShell>
  )
}

Change and verify email

Add onRequestEmailCode and onVerifyEmailCode to turn on the Change button. Return a message from onRequestEmailCode to refuse an address, and false from onVerifyEmailCode for a wrong code.

"use client"

import * as React from "react"

import { SettingsSection, SettingsShell } from "../settings/settings"

import { ProfileSettings, type ProfileValues } from "@/components/blocks/profile/profile"

export function ProfileWithEmail({ profile, account }: {
  profile: ProfileValues
  account: { email: string; verified: boolean }
}) {
  const [email, setEmail] = React.useState(account.email)

  return (
    <SettingsShell sections={[{ id: "profile", label: "Profile" }]} className="h-svh">
      <SettingsSection id="profile">
        <ProfileSettings
          profile={profile}
          email={email}
          emailVerified={account.verified}
          onSave={(values) => fetch("/api/profile", { method: "PATCH", body: JSON.stringify(values) }).then(() => {})}
          onResendVerification={() => fetch("/api/email/verify", { method: "POST" })}
          onRequestEmailCode={async (next) => {
            const response = await fetch("/api/email/change", {
              method: "POST",
              body: JSON.stringify({ email: next }),
            })
            if (response.status === 409) return "That address belongs to another account."
            if (!response.ok) throw new Error("Couldn’t send the code. Try again.")
          }}
          onVerifyEmailCode={async (next, code) => {
            const response = await fetch("/api/email/change/verify", {
              method: "POST",
              body: JSON.stringify({ email: next, code }),
            })
            return response.ok
          }}
          onEmailChange={setEmail}
        />
      </SettingsSection>
    </SettingsShell>
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
ProfileSettingsThe section: preview card, photo, name, what the assistant calls you, username, bio, public profile, links and email.
ProfileCardThe live preview of the public profile, with Copy link. Usable on its own.
AvatarCropDialogCrops an image to a square with drag, zoom and keyboard, and returns a data URL.
ChangeEmailDialogThe two-step email change: new address, then a 6-digit code.
useUsernameCheckDebounced, cancellable, cached availability checks for a username.

ProfileSettings

Use inside a SettingsSection.

PropTypeDefault
profileWhat's saved now: { avatar, name, nickname, username, bio, links, public }. When it changes and there are no edits, the draft follows it.
ProfileValues–
onSaveCalled with trimmed values and normalized links. Return { field: message } for field errors, or throw to show the message in the save bar.
(values) => void | errors | Promise<void | errors>–
emailThe sign-in address.
string–
emailVerifiedShows Verified, or Not verified with Resend link.
booleantrue
checkUsernameChecks if a username is free. Gets the lowercased name and an AbortSignal. Without it, only the format is checked.
(username, signal) => Promise<{ available, message?, suggestions? }>–
onRequestEmailCodeSends a code to a new address. Return a message to refuse the address. With onVerifyEmailCode, turns on Change.
(email) => Promise<void | string>–
onVerifyEmailCodeChecks the code. Return false for a wrong code, or throw to show your message.
(email, code) => Promise<boolean>–
onEmailChangeCalled once the new address is confirmed.
(email) => void–
onResendVerificationAdds Resend link while the address is unverified.
() => Promise<unknown>–
profileUrlThe public profile URL, shown under the username and copied by Copy link.
(username) => stringhttps://hexta.app/@username
assistantNameUsed in “What should Hexta call you?”.
string"Hexta"
maxAvatarBytesLargest photo accepted.
number5 MB
maxLinksHow many links a profile can have.
number5
maxBioBio length, with a counter.
number160
PropTypeDefault
profileWhat to show.
{ avatar, name, username, bio, links, public }–
urlThe profile URL to show and copy.
string–
onCopiedCalled after the link is copied.
() => void–
PropTypeDefault
sourceThe image to crop. Open while set.
{ url, width, height } | null–
onOpenChangeClear source when it closes.
(open) => void–
onApplyGets the cropped square as WebP, or PNG where WebP isn't supported.
(dataUrl) => void–
outputSizeLargest side of the output in pixels. Never upscales past the crop.
number512
maxZoomHow far you can zoom in.
number4
PropTypeDefault
openWhether it's open.
boolean–
currentEmailShown in the intro and refused as the new address.
string–
onRequestCodeSends the code. Return a message to refuse.
(email) => Promise<void | string>–
onVerifyCodeChecks the code.
(email, code) => Promise<boolean>–
onChangedCalled after the code is confirmed, before the dialog closes.
(email) => void–
resendAfterSeconds before Resend code unlocks.
number30

useUsernameCheck(username, options)

Returns { status, result, problem, retry, resolve }. status is idle, invalid, checking, available, taken or error.

PropTypeDefault
currentThe saved username, which is never checked.
string–
checkYour availability check.
(username, signal) => Promise<UsernameCheck>–
delayMilliseconds to wait after typing stops.
number350
KeyAction
EnterIn the last link, adds another link and moves to it.
←→↑↓In the crop dialog, move the photo. Shift moves farther.
+−In the crop dialog, zoom in and out. 0 resets.
⌘SSaves while something is unsaved. Ctrl+S on Windows and Linux.
EscCloses a dialog and returns focus to the button that opened it.
  • Every field is named by its row, and buttons that repeat a word say what they act on: Upload photo, Change email, Remove link 2, github.com/you.
  • Username results, copied links, photo changes, removed links and a changed email are announced politely. Checking itself isn't announced, so typing stays quiet.
  • Field errors mark only the field at fault, and focus moves to the first one when a save is refused. The email code announces checking, confirmed and wrong.
  • Removing a link moves focus to the next link, or to Add link when none are left. Removing the photo moves focus to Upload.
  • The crop area is focusable and works with arrow keys and plus and minus, and the zoom slider says its value as a percentage.
  • On touch screens, inputs use 16px text so iOS doesn't zoom, and icon buttons have 44px targets.

Code

6 files, added to components/blocks/profile.