Avatar

Avatar Editor

An avatar with an edit menu on the HTML popover: Escape and outside clicks close it for free, and remove is separated by a rule and named as an action.

  • avatar
  • upload
  • popover
  • menu

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/avatar-015?lang=en

Анна РеброваPNG или JPG до 2 МБ, квадрат от 200 пикселей
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "avatar-015" (Avatar Editor) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/avatar-015.json

Registry item: https://vibeui.ru/r/avatar-015.json
Installs to: components/vibeui/avatar-015.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
An avatar with an edit menu on the HTML popover: Escape and outside clicks close it for free, and remove is separated by a rule and named as an action.

An avatar with an edit menu: a native popover, an accessible label carrying the name, remove separated and named as an action. Zero dependencies, one file.

## 3. How to use it
import { Avatar015 } from "@/components/vibeui/avatar-015"

<Avatar015
  name="Anna Rebrova"
  hint="PNG or JPG up to 2 MB, square from 200 pixels"
  editLabel="Upload photo"
  removeLabel="Remove photo"
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-avatar-015-* palette — do not swap it for your theme tokens
- the native popover: do not rewrite Escape and outside-click closing with your own handlers
- the name inside the button label — "change photo" is useless in a list of cards
- the rule-separated remove item and the word "remove" instead of "reset"
- the format and size hint next to the control, not inside a post-upload error
- focus styles on the button and the menu items
- the card own light surface: without it the dark text disappears on a dark background

## 6. You may change
- the name and the format hint
- the editLabel and removeLabel captions
- the accent colour through accent
- the portrait size in the variable

## 7. Rules
- Uploading is not implemented: the component shows the choice, the caller takes the file.
- The menu opens from the button, not from the portrait: clicking a portrait usually opens a viewer.
- Remove asks for no confirmation: add a dialog outside if the photo cannot be restored.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/avatar-015.json
https://vibeui.ru/r/avatar-015.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-avatar-015-*. Клиентский: "use client", useState и useRef держат выбранное действие и ссылку на popover. Меню — нативный HTML popover с popovertarget: закрытие по Escape и клику мимо достаётся от браузера, а не пишется руками. Позиционирование сделано CSS anchor positioning под @supports (anchor-name: --a) с обычным абсолютным запасным вариантом. Кнопка правки уменьшена и сдвинута в угол, её доступная подпись называет имя — в списке карточек «изменить фото» без имени бесполезно.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useRef, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Avatar015Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  name?: string
  hint?: string
  editLabel?: string
  removeLabel?: string
  accent?: string
}

// Идея компонента: аватар с меню правки. Меню — HTML popover, поэтому закрытие
// по Escape и клику мимо достаётся от браузера. Кнопка правки лежит поверх
// портрета, но не закрывает лицо: она сдвинута в угол и уменьшена, а её
// доступная подпись называет имя — в списке из пяти карточек «изменить фото»
// без имени бесполезно. Удаление отделено чертой и названо действием, а не
// «сбросить»: человек должен понимать, что вернётся заглушка.
const STYLES = `
:where([data-vibeui-block="avatar-015"]){
--vibeui-avatar-015-size:4.5rem;
--vibeui-avatar-015-bg:oklch(1 0 0);
--vibeui-avatar-015-fg:oklch(0.24 0.014 265);
--vibeui-avatar-015-muted:oklch(0.55 0.014 265);
--vibeui-avatar-015-border:oklch(0.9 0.006 265);
--vibeui-avatar-015-hover:oklch(0.55 0.02 265 / 9%);
--vibeui-avatar-015-accent:oklch(0.55 0.2 262);
--vibeui-avatar-015-danger:oklch(0.56 0.19 25);
--vibeui-avatar-015-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
/* Своя светлая подложка: тёмный текст обязан читаться на любом фоне. */
[data-vibeui-block="avatar-015"]{
display:inline-flex;align-items:center;gap:0.875rem;
box-sizing:border-box;padding:0.875rem 1.125rem 0.875rem 0.875rem;
background:var(--vibeui-avatar-015-bg);
border:1px solid var(--vibeui-avatar-015-border);border-radius:1rem;
font-family:var(--vibeui-avatar-015-font);color:var(--vibeui-avatar-015-fg);
}
[data-vibeui-block="avatar-015"] *{box-sizing:border-box}
[data-vibeui-block="avatar-015"] [data-part="slot"]{position:relative;flex:none}
[data-vibeui-block="avatar-015"] [data-part="face"]{
display:grid;place-items:center;
width:var(--vibeui-avatar-015-size);height:var(--vibeui-avatar-015-size);
border-radius:9999px;
background:oklch(0.9 0.07 var(--vibeui-avatar-015-hue,265));
color:oklch(0.35 0.13 var(--vibeui-avatar-015-hue,265));
font-size:calc(var(--vibeui-avatar-015-size) * 0.32);font-weight:700;
}
/* Кнопка в углу и мелкая: правка не должна закрывать лицо. */
[data-vibeui-block="avatar-015"] [data-part="edit"]{
position:absolute;right:-0.125rem;bottom:-0.125rem;
appearance:none;cursor:pointer;
width:1.75rem;height:1.75rem;border-radius:9999px;
border:2px solid var(--vibeui-avatar-015-bg);
background:var(--vibeui-avatar-015-accent);color:oklch(1 0 0);
font:inherit;font-size:0.75rem;line-height:1;
}
[data-vibeui-block="avatar-015"] [data-part="edit"]:focus-visible{outline:2px solid var(--vibeui-avatar-015-accent);outline-offset:2px}
[data-vibeui-block="avatar-015"] [data-part="menu"]{
position:fixed;margin:0;padding:0.25rem;min-width:11rem;
border:1px solid var(--vibeui-avatar-015-border);border-radius:0.625rem;
background:var(--vibeui-avatar-015-bg);color:var(--vibeui-avatar-015-fg);
box-shadow:0 16px 36px -18px oklch(0.2 0.03 265 / 45%);
font-family:var(--vibeui-avatar-015-font);
}
/* Якорь привязывает меню к кнопке; без поддержки оно встанет по центру экрана. */
@supports (anchor-name: --a){
[data-vibeui-block="avatar-015"] [data-part="edit"]{anchor-name:--vibeui-avatar-015-anchor}
[data-vibeui-block="avatar-015"] [data-part="menu"]{
position-anchor:--vibeui-avatar-015-anchor;
position-area:bottom span-right;margin-top:0.375rem;
position-try-fallbacks:flip-block,flip-inline;
}
}
[data-vibeui-block="avatar-015"] [data-part="item"]{
display:flex;align-items:center;width:100%;
min-height:2rem;padding:0 0.5rem;border-radius:0.4375rem;
appearance:none;border:0;background:none;cursor:pointer;
font:inherit;font-size:0.8125rem;color:inherit;text-align:left;
}
[data-vibeui-block="avatar-015"] [data-part="item"]:hover{background:var(--vibeui-avatar-015-hover)}
[data-vibeui-block="avatar-015"] [data-part="item"]:focus-visible{outline:2px solid var(--vibeui-avatar-015-accent);outline-offset:-2px}
/* Удаление отделено чертой: соседство с правкой стоит промаха. */
[data-vibeui-block="avatar-015"] [data-danger="true"]{
margin-top:0.25rem;padding-top:0.625rem;min-height:2.375rem;
border-top:1px solid var(--vibeui-avatar-015-border);border-radius:0 0 0.4375rem 0.4375rem;
color:var(--vibeui-avatar-015-danger);
}
[data-vibeui-block="avatar-015"] [data-part="text"]{display:flex;flex-direction:column;gap:0.125rem;min-width:0}
[data-vibeui-block="avatar-015"] [data-part="name"]{font-size:0.9375rem;font-weight:650}
[data-vibeui-block="avatar-015"] [data-part="hint"]{font-size:0.75rem;line-height:1.4;color:var(--vibeui-avatar-015-muted)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="avatar-015"] *{animation:none!important;transition:none!important}}
`

function hue(name: string) {
  let hash = 2166136261
  for (const symbol of name) {
    hash ^= symbol.codePointAt(0)!
    hash = Math.imul(hash, 16777619)
  }
  return ((hash >>> 0) % 12) * 30
}

function initials(name: string) {
  return name
    .split(" ")
    .slice(0, 2)
    .map((part) => part.charAt(0).toUpperCase())
    .join("")
}

/**
 * Аватар с меню правки на HTML popover: закрытие и якорь от браузера.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Avatar015({
  name = "Анна Реброва",
  hint = "PNG или JPG до 2 МБ, квадрат от 200 пикселей",
  editLabel = "Загрузить фото",
  removeLabel = "Удалить фото",
  accent,
  className,
  style,
  ...props
}: Avatar015Props) {
  const menu = useRef<HTMLDivElement>(null)
  const [removed, setRemoved] = useState(false)

  const palette = {
    "--vibeui-avatar-015-hue": hue(name),
    ...(accent ? { "--vibeui-avatar-015-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-avatar-015" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="avatar-015"
        className={className}
        style={palette}
      >
        <span data-part="slot">
          <span data-part="face" aria-hidden="true">
            {initials(name)}
          </span>
          <button
            type="button"
            data-part="edit"
            popoverTarget="vibeui-avatar-015-menu"
            aria-label={`Изменить фото: ${name}`}
          >
            ✎
          </button>
          <div
            id="vibeui-avatar-015-menu"
            data-part="menu"
            popover="auto"
            role="menu"
            ref={menu}
          >
            <button type="button" data-part="item" role="menuitem">
              {editLabel}
            </button>
            <button type="button" data-part="item" role="menuitem">
              Сделать снимок
            </button>
            <button
              type="button"
              data-part="item"
              data-danger="true"
              role="menuitem"
              onClick={() => {
                setRemoved(true)
                menu.current?.hidePopover()
              }}
            >
              {removeLabel}
            </button>
          </div>
        </span>

        <span data-part="text">
          <span data-part="name">{name}</span>
          <span data-part="hint">
            {removed ? "Фото удалено, показаны инициалы" : hint}
          </span>
        </span>
      </div>
    </>
  )
}