For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

Blobatar

Locally generated geometric avatars with profile photo fallback and opt-in motion.

Playground

Live preview
vandor
Choose a Composition, then click the avatar to open its overlay. Install the selected overlay separately. Photos take priority; Follow pointer overrides Animation and stays still on touch or under reduced motion.
Customize Blobatar
Customize
3.00
64.00
blobatar-demo.tsx
"use client";

import { Blobatar } from "@/components/ui/blobatar";

export function Demo() {
  return (
    <Blobatar
      name={"vandor"}
      alt={"vandor"}
      size={64}
    />
  );
}

Installation

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/blobatar.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies4 required packages
  • @base-ui/react@^1.8.0
  • blobatar@^2.7.0
  • @blobatar/react@^2.7.0
  • cn
$ pnpm add @base-ui/react@^1.8.0 blobatar@^2.7.0 @blobatar/react@^2.7.0 cn

Usage

import { Blobatar } from "@/components/ui/blobatar";
 
export function Demo() {
  return <Blobatar name="vandor" alt="Vandor" size={48} />;
}

The name can be a username, ID, email, or team name. Generation runs locally, with no call to blobatar.dev. Prefer a stable public ID when names could expose personal information in rendered markup. Names are NFC-normalized, trimmed, and lowercased unless you pass blobatar={{ normalize: false }}.

alainvandorlunamars
blobatar-demo.tsx
import { Blobatar } from "@/components/ui/blobatar";

export const BlobatarDemo = () => (
  <div className="flex flex-wrap items-center justify-center gap-3">
    {["alain", "vandor", "luna", "mars"].map((name) => (
      <Blobatar key={name} name={name} alt={name} size={48} />
    ))}
  </div>
);

Installation adds only this wrapper and its declared packages. It uses Base UI Avatar directly; it does not install or overwrite a separate Avatar component. Requires React 19, Tailwind CSS 4, and Base UI 1.8+. The generated face has no site-specific theme token requirements.

Overlay Compositions

Choose Composition in the playground to try the avatar as a Popover, Dropdown, Dialog, or Drawer trigger. Appearance, photos, expressions, and pointer tracking still apply; copied code includes the selected overlay. Composition is a playground setting, not a Blobatar prop.

Install only the overlay you need alongside Blobatar, for example:

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/blobatar.json https://vandor-ui.vercel.app/r/popover.json

Replace popover.json with dropdown.json, dialog.json, or drawer.json for the other examples. Blobatar itself does not depend on these overlays.

With Popover

Click the avatar for a compact profile summary. Escape or an outside click dismisses it.

blobatar-popover-demo.tsx
"use client";

import { Blobatar } from "@/components/ui/blobatar";
import type { BlobatarProps } from "@/components/ui/blobatar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

export const BlobatarPopoverDemo = ({
  name = "vandor",
  ...props
}: Partial<BlobatarProps>) => (
  <Popover>
    <PopoverTrigger render={<button type="button" aria-label={`Open profile for ${name}`} className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"><Blobatar {...props} name={name} alt="" /></button>} />
    <PopoverContent className="w-64 max-w-[calc(100vw-2rem)]">
      <p className="break-words font-medium">{name}</p>
      <p className="text-sm text-muted-foreground">Product designer</p>
      <p className="mt-3 text-sm text-muted-foreground">
        Building thoughtful interfaces with Vandor UI.
      </p>
    </PopoverContent>
  </Popover>
);

With Dropdown

Open the account menu and choose a status. The selection updates the text below the avatar; use arrow keys to navigate the menu.

Available

blobatar-dropdown-demo.tsx
"use client";

import { useState } from "react";

import { Blobatar } from "@/components/ui/blobatar";
import type { BlobatarProps } from "@/components/ui/blobatar";
import { Dropdown } from "@/components/ui/dropdown";

export const BlobatarDropdownDemo = ({
  name = "vandor",
  ...props
}: Partial<BlobatarProps>) => {
  const [status, setStatus] = useState("Available");
  return (
    <div className="flex flex-col items-center gap-3">
      <Dropdown
        trigger={
          <button
            type="button"
            aria-label={`Open account menu for ${name}`}
            className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
          >
            <Blobatar {...props} name={name} alt="" />
          </button>
        }
        items={[
          {
            id: "status",
            items: [
              {
                id: "available",
                label: "Available",
                onSelect: () => setStatus("Available"),
              },
              { id: "busy", label: "Busy", onSelect: () => setStatus("Busy") },
              { id: "away", label: "Away", onSelect: () => setStatus("Away") },
            ],
            label: "Set status",
            type: "group",
          },
        ]}
      />
      <p role="status" className="text-sm text-muted-foreground">
        {status}
      </p>
    </div>
  );
};

With Dialog

Use a dialog for focused profile details. Focus stays inside until it closes, then returns to the avatar button.

blobatar-dialog-demo.tsx
"use client";

import { Blobatar } from "@/components/ui/blobatar";
import type { BlobatarProps } from "@/components/ui/blobatar";
import { Dialog, DialogBody } from "@/components/ui/dialog";

export const BlobatarDialogDemo = ({
  name = "vandor",
  ...props
}: Partial<BlobatarProps>) => (
  <Dialog
    title="Profile details"
    description="A closer look at this team member."
    trigger={
      <button
        type="button"
        aria-label={`Open profile dialog for ${name}`}
        className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
      >
        <Blobatar {...props} name={name} alt="" />
      </button>
    }
  >
    <DialogBody>
      <p className="break-words font-medium">{name}</p>
      <p className="text-sm text-muted-foreground">Product designer</p>
      <p className="mt-3 text-sm text-muted-foreground">
        Building thoughtful interfaces with Vandor UI.
      </p>
    </DialogBody>
  </Dialog>
);

With Drawer

A drawer keeps the profile in a side panel. Use its close button, Escape, or swipe toward the right edge on touch.

blobatar-drawer-demo.tsx
"use client";

import { Blobatar } from "@/components/ui/blobatar";
import type { BlobatarProps } from "@/components/ui/blobatar";
import { Drawer, DrawerBody } from "@/components/ui/drawer";

export const BlobatarDrawerDemo = ({
  name = "vandor",
  ...props
}: Partial<BlobatarProps>) => (
  <Drawer
    title="Profile details"
    description="Keep this team member's details close by."
    trigger={
      <button
        type="button"
        aria-label={`Open profile drawer for ${name}`}
        className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
      >
        <Blobatar {...props} name={name} alt="" />
      </button>
    }
  >
    <DrawerBody className="pt-5">
      <p className="break-words font-medium">{name}</p>
      <p className="text-sm text-muted-foreground">Product designer</p>
      <p className="mt-3 text-sm text-muted-foreground">
        Building thoughtful interfaces with Vandor UI.
      </p>
    </DrawerBody>
  </Drawer>
);

Each trigger is a real button with an accessible name and a visible keyboard focus ring. The avatar inside is decorative (alt="") so its label is not repeated. Triggers keep a minimum 44px hit area even for small avatars.

Profile Photos

Pass src when a profile photo exists. The generated fallback is visible while the photo loads and after a load error. Once loaded, the round photo replaces the fallback. Changing src lets Base UI resolve the new image; removing it restores the fallback.

Profile photo
Failed photo fallback
blobatar-photo-demo.tsx
import { Blobatar } from "@/components/ui/blobatar";

const photo = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="#d9e4d5"/><circle cx="50" cy="38" r="18" fill="#596d51"/><path d="M15 100a35 35 0 0 1 70 0" fill="#596d51"/></svg>'
)}`;

export const BlobatarPhotoDemo = () => (
  <div className="flex flex-wrap items-center justify-center gap-6">
    <div className="flex items-center gap-3">
      <Blobatar name="alain" src={photo} size={48} />
      <span className="text-sm">Profile photo</span>
    </div>
    <div className="flex items-center gap-3">
      <Blobatar name="vandor" src="data:image/png;base64,invalid" size={48} />
      <span className="text-sm">Failed photo fallback</span>
    </div>
  </div>
);

Motion and Expressions

Animation is off by default. Static faces use a single <img> with a local SVG data URI. animate: "hover" or "always" switches the fallback to inline SVG. Import the package stylesheet once in your application's global entry or the animated component:

import "blobatar/motion.css";
import { happy } from "blobatar/expression";
import { Blobatar } from "@/components/ui/blobatar";
 
<Blobatar name="vandor" blobatar={{ animate: "hover", expression: happy }} />;

Expressions are imported values, not strings. The upstream roster includes idle, happy, sad, mad, surprised, wink, sleepy, smug, unsure, scared, love, shy, sick, and thinking. Poses remain until changed. A pose works without animate; morphing between poses requires inline SVG. Thinking's eye cycle also requires blobatar/motion.css.

The selected pose stays until you choose another.

blobatar-motion-demo.tsx
"use client";

import "blobatar/motion.css";
import { happy, idle, thinking, wink } from "blobatar/expression";
import { useState } from "react";

import { Blobatar } from "@/components/ui/blobatar";
import { Button } from "@/components/ui/button";

const expressions = { happy, idle, thinking, wink };

export const BlobatarMotionDemo = () => {
  const [expression, setExpression] =
    useState<keyof typeof expressions>("idle");
  return (
    <div className="flex flex-col items-center gap-5">
      <Blobatar
        name="vandor"
        size={96}
        blobatar={{ animate: "always", expression: expressions[expression] }}
      />
      <div
        className="flex flex-wrap justify-center gap-2"
        aria-label="Avatar expression"
      >
        {(Object.keys(expressions) as (keyof typeof expressions)[]).map(
          (pose) => (
            <Button
              key={pose}
              variant="outline"
              size="sm"
              aria-pressed={expression === pose}
              onClick={() => setExpression(pose)}
            >
              {pose}
            </Button>
          )
        )}
      </div>
      <p className="text-sm text-muted-foreground">
        The selected pose stays until you choose another.
      </p>
    </div>
  );
};

Prefer static faces for long lists and hover motion for small grids. Reserve always-on motion for a single prominent avatar. Upstream motion becomes static under prefers-reduced-motion and hover motion does not activate on touch.

Following the Mouse

Enable followPointer for eyes that follow your mouse anywhere on the page. This uses the official useGaze hook, automatically switches the fallback to inline SVG with always-on motion, and requires both stylesheets:

import "blobatar/motion.css";
import "blobatar/gaze.css";
import { Blobatar } from "@/components/ui/blobatar";
 
<Blobatar name="vandor" size={128} followPointer pointerTravel={3} />;

Move your mouse around the page. Eyes stay still on touch or with reduced motion.

blobatar-pointer-demo.tsx
"use client";

import "blobatar/motion.css";
import "blobatar/gaze.css";
import { Blobatar } from "@/components/ui/blobatar";

export const BlobatarPointerDemo = () => (
  <div className="flex flex-col items-center gap-4">
    <Blobatar name="vandor" size={128} followPointer pointerTravel={3} />
    <p className="text-center text-sm text-muted-foreground">
      Move your mouse around the page. Eyes stay still on touch or with reduced
      motion.
    </p>
  </div>
);

pointerTravel defaults to 3, measured in SVG viewBox units (the face is 100 units wide), not screen pixels. The playground offers 1–4. It is easier to see at 96–128px than at list-avatar sizes. followPointer takes precedence over blobatar.animate, including false. Disabling it restores the configured animation mode.

Tracking attaches only for a fine pointer with hover support and without reduced motion. It stops on unmount, while a loaded photo replaces the fallback, and when those media preferences change. Photos do not track the pointer. Importing this wrapper includes the upstream gaze driver, but no pointer tracking listeners attach unless followPointer is enabled. The public ref still targets the outer span.

Appearance

Pass generator options through blobatar. The fallback defaults to a full-frame "circle" backdrop, matching the photo's occupied area. The upstream body sits inside its SVG viewBox with intentional breathing room; this is not CSS padding. The backdrop fills that room without cropping the body or its motion. Set background: false for the original transparent silhouette. background also accepts "square" and "squircle". Hue, tone, traits, palette, normalize, and contrast are forwarded to the official renderer:

<Blobatar name="vandor" blobatar={{ hue: 220, background: "squircle" }} />

The outer size controls both photo and fallback. Override dimensions through style; className merges on the outer span. Photo styling is fixed to a circle; fallback backdrop configuration does not change the photo. Body shapes are generated from the name, not the backdrop.

Identical names produce identical faces within a package major version. Registry dependencies stay on major 2; upgrading to a new major may change the name-to-face mapping. Custom palette colors bypass upstream's eye/body contrast guarantee. See the upstream API for traits and palette configuration.

Behavior and Accessibility

alt defaults to an empty string, making the face decorative when a name is already written beside it. Pass alt="Person's name" for a standalone avatar. The label is used for both the photo's alternative text and the generated SVG title. Expressions are decorative: announce loading or presence separately in real text, not through facial poses.

Native span attributes, event handlers, style, and the public ref target the outer span. There is no internal click behavior or keyboard control. For an actionable avatar, wrap it in an accessible button or link rather than adding an unlabeled click handler. Children and Base UI's render-composition API are not exposed.

The local SVG data URI requires your Content Security Policy to allow data: in img-src. Photo URLs need their own allowed origins. Animation CSS is an explicit consumer setup step; copying Source alone will not enable motion.

Portable Storybook Stories

Install Blobatar first, then optionally install stories without replacing your customized component:

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/blobatar-stories.json

The stories import the colocated ./blobatar component. They do not install Storybook packages or change .storybook/. Supply your existing matching Storybook React packages (types checked against @storybook/react 10.6.1), React 19, Tailwind CSS 4, and your consumer theme. Include **/*.stories.tsx under your UI directory in the Storybook stories glob. Import blobatar/motion.css in .storybook/preview.ts to enable the motion stories, plus blobatar/gaze.css for PointerTracking. No optional addons are required.

Ordinary Blobatar installation includes neither stories nor Storybook dependencies. Storybook UI is not verified here because this project has no Storybook server.

Props

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
altstring

Accessible name shared by photo and fallback. Empty means decorative; pass a label when the avatar stands alone.

""
blobatarBlobatarAppearance

Appearance options: animate, expression, background, hue, tone, traits, palette, normalize, and contrast. size and title are owned by the wrapper's size and alt props.

Not set
blobatar.animatefalse | "hover" | "always"

Opt-in inline SVG motion. Follow pointer overrides this to always. Requires blobatar/motion.css and respects reduced motion.

false
blobatar.backgroundfalse | "circle" | "square" | "squircle"

Full-frame circle by default, matching a photo. Choose none for the upstream transparent silhouette.

"circle"
blobatar.expressionExpression

Expression value imported from blobatar/expression, not a string. The playground shows four of fourteen upstream poses.

idle
classNamestring

Merged classes on the outer span. Use style to override its pixel dimensions.

Not set
followPointerboolean

Eyes follow a fine mouse pointer. Forces inline SVG with always motion; requires motion.css and gaze.css. Inert on touch and under reduced motion.

false
namestring

Stable identity for local generation. NFC-normalized, trimmed, and lowercased by default.

Required
pointerTravelnumber

Eye movement range in SVG viewBox units. Only used when followPointer is enabled.

3
sizenumber

Wrapper width and height in pixels. Explicit style overrides these dimensions; the playground starts at 64px.

40
srcstring

Profile photo URL. Loading, missing, and failed photos show the generated fallback.

Not set
styleCSSProperties

Outer span styles, applied after size. Native span attributes, events, and ref are supported; children are not.

Not set

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

Source

"use client";
 
import { Avatar } from "@base-ui/react/avatar";
import { Blobatar as GeneratedBlobatar } from "@blobatar/react";
import { useGaze } from "@blobatar/react/gaze";
import type { BlobatarOptions } from "blobatar";
import { cn } from "cn";
import type * as React from "react";
 
export type BlobatarAppearance = Omit<
  BlobatarOptions,
  "size" | "title" | "animate"
> & {
  animate?: false | "hover" | "always";
};
 
export type BlobatarProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** Stable identity used to generate the fallback locally. */
  name: string;
  /** Optional profile photo. Loading and failed photos show the fallback. */
  src?: string;
  /** Accessible label for both photo and fallback. Empty means decorative. */
  alt?: string;
  /** Width and height in pixels. Explicit style can override them. */
  size?: number;
  /** Follow a fine pointer. Requires motion.css and gaze.css; forces inline SVG. */
  followPointer?: boolean;
  /** Eye excursion in SVG viewBox units, not screen pixels. */
  pointerTravel?: number;
  /** Generator options. Motion requires blobatar/motion.css. */
  blobatar?: BlobatarAppearance;
};
 
export const Blobatar = ({
  name,
  src,
  alt = "",
  size = 40,
  followPointer = false,
  pointerTravel = 3,
  blobatar,
  className,
  style,
  ...props
}: BlobatarProps) => {
  const { ref: gazeRef } = useGaze({
    lookAt: followPointer ? "pointer" : null,
    travel: pointerTravel,
  });
  return (
    <Avatar.Root
      data-slot="blobatar"
      className={cn("relative inline-flex shrink-0 align-middle", className)}
      style={{ height: size, width: size, ...style }}
      {...props}
    >
      {src ? (
        <Avatar.Image
          data-slot="blobatar-image"
          src={src}
          alt={alt}
          keepMounted
          className="absolute inset-0 size-full rounded-full object-cover data-[loading]:invisible data-[error]:invisible"
        />
      ) : null}
      <Avatar.Fallback data-slot="blobatar-fallback" className="flex size-full">
        <GeneratedBlobatar
          background="circle"
          {...blobatar}
          animate={followPointer ? "always" : blobatar?.animate || undefined}
          ref={followPointer ? gazeRef : undefined}
          name={name}
          title={alt || undefined}
          className="size-full"
        />
      </Avatar.Fallback>
    </Avatar.Root>
  );
};
 

Credits

  • Blobatar by Alain: MIT-licensed generator and React adapter used as dependencies. Vandor UI adds a Base UI photo/fallback composition, sizing, playground, and portable stories.