Skip to contentVibraUI
Data display

Avatars

A deterministic face for a name, as a URL, and the route that draws it — no photograph, no image host.

Its own kit item, outside sample-data, so a marketing section can draw a face without importing the store: testimonials and team sections need one, and the section contract's "marketing copy is copy" rule bars every section from lib/sample-data. avatarFor(name) is a URL, not a picture — /avatars/<slug>.svg — that the route file this item also ships draws on request, as DiceBear's Lorelei (CC0): line art in ink on a light disc, never a photograph. The same name always seeds the same face, from the letters alone — an accent or a stray space changes nothing — so a row keeps its face across pages and across renders. sample-data depends on this item by name for the people its entities draw; a project that only needs a face for copy of its own installs avatars alone.

Install

npx shadcn@latest add @vibra/avatars

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
avatarFor()(name: string) => string—The URL of the face for a name, forty bytes long and the same one every time.
avatarSeed()(name: string) => string—"Aisha Adeyemi" → "aisha-adeyemi": the seed, and the file name, of the face.
AVATAR_ROUTEstring"/avatars"Where the route that draws the faces is mounted.

Dependencies

Source

lib/avatars.ts
/**
 * A face for a name.
 *
 * Every person in the sample data — a member, an employee, a customer's
 * contact, a guest — can be drawn with the same face every time, from the name
 * alone, so a row keeps its face across pages and across renders. The face is
 * DiceBear's Lorelei (CC0, Lisa Wischofsky): line art in ink on a light disc,
 * the one look that sits inside an achromatic dashboard without shouting, and
 * no photograph of a real person.
 *
 * What this returns is a URL, not the picture: `/avatars/<slug>.svg`, which
 * `avatar-route.ts` draws on request. A data URI would have been nine
 * kilobytes a row, and a table of two hundred rows carries its rows to the
 * browser whole; a URL is forty bytes, the browser caches the picture, and a
 * row that is never drawn costs nothing. In a test, where no image loads,
 * every avatar falls back to its initials, which is what the tests read.
 *
 * This lives outside `sample-data` on purpose: a marketing section may never
 * import the store (the section contract's "marketing copy is copy"), but a
 * testimonial or a team section still needs a face, so `avatars` is its own
 * kit item. `sample-data` depends on it by name for the entity modules that
 * still reach it by the relative `./avatars` every sibling module here uses.
 */

/** Where the route that draws the faces is mounted. */
export const AVATAR_ROUTE = "/avatars"

/** `Aisha Adeyemi` → `aisha-adeyemi`: the seed, and the file name, of the face. */
export function avatarSeed(name: string): string {
  return name
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
}

/** The URL of the face for a name. */
export function avatarFor(name: string): string {
  return `${AVATAR_ROUTE}/${avatarSeed(name)}.svg`
}
app/avatars/[seed]/route.ts
import { createAvatar } from "@dicebear/core"
import * as lorelei from "@dicebear/lorelei"

/**
 * The route that draws the faces `avatarFor()` names: `GET /avatars/<seed>.svg`
 * answers with DiceBear's Lorelei for that seed, as an SVG. Installed at
 * `app/avatars/[seed]/route.ts`. Deterministic — the same seed is the same
 * face forever — so the browser may keep it for a year.
 *
 * The disc behind the face is light in both themes on purpose: a photograph
 * would be too, and a face drawn on the dark plane would need its ink
 * redrawn, which an image cannot do from the page's CSS.
 */

/** The plane the face sits on: the light theme's muted ground. */
const DISC = "f4f4f4"

/** The picture for one seed, whole. */
export function renderAvatar(seed: string): string {
  return createAvatar(lorelei, { seed, backgroundColor: [DISC], radius: 50 }).toString()
}

export async function GET(_request: Request, context: { params: Promise<{ seed: string }> }) {
  const { seed } = await context.params
  // The URL names a file; the seed is the name without its extension, and a
  // seed nobody could type — a path, a query — is refused rather than drawn.
  // Next hands the param over decoded already: decoding it again threw on
  // `/avatars/%25.svg` (a 500) and would have read `%2561` as "a".
  const name = seed.replace(/\.svg$/, "")
  if (!/^[a-z0-9-]{1,80}$/.test(name)) return new Response("Not found", { status: 404 })

  return new Response(renderAvatar(name), {
    headers: {
      "Content-Type": "image/svg+xml; charset=utf-8",
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  })
}