chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:08 +08:00
commit 6bc69ac13e
674 changed files with 92148 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[*]
charset = utf-8
indent_style = space
indent_size = 2
tab_width = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.yml]
indent_size = 2
tab_width = 2
[.dist-tag]
insert_final_newline = false
+38
View File
@@ -0,0 +1,38 @@
name: "CodeQL"
on:
push:
branches: ["*.x"]
pull_request:
branches: ["*.x"]
schedule:
- cron: '0 6 * * 1'
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ['javascript']
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
+51
View File
@@ -0,0 +1,51 @@
name: Deploy Editor
on:
workflow_dispatch: {}
permissions:
contents: read
jobs:
test:
uses: dicebear/dicebear/.github/workflows/test.yml@10.x
deploy:
needs: test
environment: editor
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
# Remove all languages except English due GDPR reasons, because we can't translate the legal pages.
- name: Cleanup languages
run: find apps/editor/src/messages -type f ! -name 'en.json' -exec rm -f {} +
- name: Build editor
env:
VITE_PRIVACY_POLICY_URL: ${{ vars.VITE_PRIVACY_POLICY_URL }}
VITE_COOKIE_POLICY_URL: ${{ vars.VITE_COOKIE_POLICY_URL }}
VITE_LEGAL_NOTICE_URL: ${{ vars.VITE_LEGAL_NOTICE_URL }}
run: npx turbo run build --filter='@dicebear/editor...'
- name: Deploy to Bunny.net
uses: ayeressian/bunnycdn-storage-deploy@251c77f4b9e7e683ad00086de2bef5770c3386b2 # v2.4.5
with:
source: 'apps/editor/dist'
destination: ''
storageZoneName: '${{ secrets.EDITOR_STORAGE_NAME }}'
storagePassword: '${{ secrets.EDITOR_STORAGE_PASSWORD }}'
accessKey: '${{ secrets.CDN_ACCESS_KEY }}'
pullZoneId: '${{ secrets.EDITOR_PULL_ZONE_ID }}'
upload: 'true'
remove: 'true'
purgePullZone: 'true'
+52
View File
@@ -0,0 +1,52 @@
name: Deploy Website
on:
workflow_dispatch: {}
permissions:
contents: read
jobs:
test:
uses: dicebear/dicebear/.github/workflows/test.yml@10.x
deploy:
needs: test
environment: ${{ github.ref_name == '9.x' && 'website-v9' || 'website-latest' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Download legal pages
env:
PAT: ${{ secrets.PAT }}
run: ./apps/docs/scripts/download-legal-pages.sh
- name: Build with VitePress
env:
VITE_PRIVACY_POLICY_URL: ${{ vars.VITE_PRIVACY_POLICY_URL }}
VITE_COOKIE_POLICY_URL: ${{ vars.VITE_COOKIE_POLICY_URL }}
VITE_LEGAL_NOTICE_URL: ${{ vars.VITE_LEGAL_NOTICE_URL }}
run: npx turbo run build --filter='@dicebear/docs...'
- name: Deploy to Bunny.net
uses: ayeressian/bunnycdn-storage-deploy@251c77f4b9e7e683ad00086de2bef5770c3386b2 # v2.4.5
with:
source: 'apps/docs/.vitepress/dist'
destination: ''
storageZoneName: '${{ secrets.WEBSITE_STORAGE_NAME }}'
storagePassword: '${{ secrets.WEBSITE_STORAGE_PASSWORD }}'
accessKey: '${{ secrets.CDN_ACCESS_KEY }}'
pullZoneId: '${{ secrets.WEBSITE_PULL_ZONE_ID }}'
upload: 'true'
remove: 'true'
purgePullZone: 'true'
+141
View File
@@ -0,0 +1,141 @@
name: Publish
on:
push:
tags:
- 'v*'
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
test:
uses: dicebear/dicebear/.github/workflows/test.yml@10.x
publish:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npx turbo run build --filter='!@dicebear/docs' --filter='!@dicebear/editor'
- name: Determine dist tag
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
TAG=${GITHUB_REF#refs/tags/}
if [[ "$TAG" =~ -(alpha|beta|rc)\. ]]; then
echo "DIST_TAG=next" >> $GITHUB_ENV
else
git fetch origin "$DEFAULT_BRANCH" --depth=1
if git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH"; then
echo "DIST_TAG=latest" >> $GITHUB_ENV
else
MAJOR=$(echo "$TAG" | sed 's/^v//' | cut -d. -f1)
echo "DIST_TAG=v${MAJOR}-lts" >> $GITHUB_ENV
fi
fi
- run: node scripts/publish.mjs "$DIST_TAG"
publish-python:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Build dicebear-core
working-directory: src/python/core
run: |
python -m pip install --upgrade build
python -m build
# Trusted Publishing (OIDC) — no token. Configure the publisher on PyPI:
# project dicebear-core, repo dicebear/dicebear, workflow publish.yml.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: src/python/core/dist/
publish-rust:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Verify the crate compiles
working-directory: src/rust/core
run: cargo check --all-features
# Trusted Publishing (OIDC) — no token. Configure the publisher on
# crates.io: crate dicebear-core, repo dicebear/dicebear, workflow
# publish.yml.
- name: Authenticate to crates.io
id: auth
uses: rust-lang/crates-io-auth-action@v1.0.4
- name: Publish to crates.io
working-directory: src/rust/core
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
publish-pub:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dart-lang/setup-dart@v1
# Automated publishing (OIDC via setup-dart, no stored token). Configure
# on pub.dev: package dicebear_core, repository dicebear/dicebear, tag
# pattern v{{version}}. Unlike the Go and PHP ports, pub.dev publishes
# straight from the monorepo subdirectory — no split repository.
- name: Copy the project changelog into the package
# CHANGELOG.md inside the package is a git-ignored build artifact:
# pub.dev expects it in the package directory, while the repository
# maintains a single changelog at the root.
run: cp CHANGELOG.md src/dart/core/CHANGELOG.md
# The first release is published manually (pub.dev's automated-publishing
# settings only exist once the package does), so skip cleanly when the
# pubspec version is already live. This also makes re-runs of the
# workflow safe.
- name: Check whether this version is already on pub.dev
id: pub-version
working-directory: src/dart/core
run: |
VERSION=$(sed -n 's/^version: //p' pubspec.yaml)
if curl -fsSL https://pub.dev/api/packages/dicebear_core \
| jq -e --arg v "$VERSION" '.versions[] | select(.version == $v)' \
> /dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish to pub.dev
if: steps.pub-version.outputs.exists == 'false'
working-directory: src/dart/core
run: dart pub publish --force
+41
View File
@@ -0,0 +1,41 @@
name: Split Go Core
on:
push:
branches:
- '*.x'
tags:
- 'v*'
jobs:
split:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: danharrin/monorepo-split-github-action@v2.4.5
env:
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
with:
package_directory: src/go/core
repository_organization: dicebear
repository_name: dicebear-go
branch: ${{ github.ref_name }}
user_name: dicebear-bot
user_email: bot@dicebear.com
- if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: danharrin/monorepo-split-github-action@v2.4.5
env:
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
with:
package_directory: src/go/core
repository_organization: dicebear
repository_name: dicebear-go
tag: ${{ github.ref_name }}
user_name: dicebear-bot
user_email: bot@dicebear.com
+41
View File
@@ -0,0 +1,41 @@
name: Split PHP Core
on:
push:
branches:
- '*.x'
tags:
- 'v*'
jobs:
split:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: danharrin/monorepo-split-github-action@v2.4.5
env:
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
with:
package_directory: src/php/core
repository_organization: dicebear
repository_name: dicebear-php
branch: ${{ github.ref_name }}
user_name: dicebear-bot
user_email: bot@dicebear.com
- if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: danharrin/monorepo-split-github-action@v2.4.5
env:
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
with:
package_directory: src/php/core
repository_organization: dicebear
repository_name: dicebear-php
tag: ${{ github.ref_name }}
user_name: dicebear-bot
user_email: bot@dicebear.com
+174
View File
@@ -0,0 +1,174 @@
name: Test
on:
push:
branches:
- '*.x'
pull_request:
branches:
- '*.x'
workflow_call: {}
jobs:
test-js:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24, 25]
# See supported Node.js release schedule at https://nodejs.org/en/about/previous-releases
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npx turbo run build --filter='!@dicebear/docs' --filter='!@dicebear/editor'
- run: npx turbo run test
test-php:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: ['8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v6
- name: Use PHP ${{ matrix.php-version }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring
- name: Install dependencies
working-directory: src/php/core
run: composer install --no-interaction --prefer-dist
- name: Run PHPStan
working-directory: src/php/core
run: vendor/bin/phpstan analyse --no-progress
- name: Run tests
working-directory: src/php/core
run: vendor/bin/phpunit
test-python:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v6
- name: Use Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
working-directory: src/python/core
run: pip install -e ".[dev]"
- name: Run Ruff
working-directory: src/python/core
run: |
ruff check .
ruff format --check .
- name: Run mypy
working-directory: src/python/core
run: mypy src
- name: Run tests
working-directory: src/python/core
run: pytest
test-rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Check formatting
working-directory: src/rust/core
run: cargo fmt --check
- name: Run Clippy
working-directory: src/rust/core
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run tests
working-directory: src/rust/core
run: cargo test
test-go:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.23', '1.24', '1.25']
steps:
- uses: actions/checkout@v6
- name: Use Go ${{ matrix.go-version }}
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- name: Check formatting
working-directory: src/go/core
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- name: Run go vet
working-directory: src/go/core
run: go vet ./...
- name: Run tests
working-directory: src/go/core
run: go test ./...
test-dart:
runs-on: ubuntu-latest
strategy:
matrix:
# Lower bound = the pubspec `environment.sdk` floor; keep in sync.
sdk: ['3.4', 'stable']
steps:
- uses: actions/checkout@v6
- name: Use Dart ${{ matrix.sdk }}
uses: dart-lang/setup-dart@v1
with:
sdk: ${{ matrix.sdk }}
- name: Install dependencies
working-directory: src/dart/core
run: dart pub get
- name: Check the embedded web fixtures are up to date
# The web parity suite embeds a copy of the fixtures (dart2js cannot
# read files); regenerate it and fail if it drifted from the canonical
# tests/fixtures/parity.
working-directory: src/dart/core
run: |
dart run tool/generate_web_fixtures.dart
dart format test/parity/embedded_fixtures.dart
git diff --exit-code test/parity/embedded_fixtures.dart
- name: Check formatting
working-directory: src/dart/core
run: dart format --output=none --set-exit-if-changed .
- name: Analyze
working-directory: src/dart/core
run: dart analyze --fatal-infos
- name: Run tests
working-directory: src/dart/core
run: dart test
- name: Run web-eligible tests on dart2js
# The fixture suites are VM-only (they read disk via dart:io), so the
# dart2js build's number formatting and 32-bit PRNG arithmetic is
# asserted by the embedded web_parity_test under Chrome.
# ubuntu-latest ships google-chrome-stable.
working-directory: src/dart/core
run: dart test -p chrome
- name: Copy the project changelog into the package
# CHANGELOG.md inside the package is a git-ignored build artifact:
# pub.dev expects it in the package directory, while the repository
# maintains a single changelog at the root.
run: cp CHANGELOG.md src/dart/core/CHANGELOG.md
- name: Validate the publishable archive
working-directory: src/dart/core
run: dart pub publish --dry-run
+11
View File
@@ -0,0 +1,11 @@
# Cache and log files
.turbo
.eslintcache
.DS_Store
yarn-error.log
# Dependencies
node_modules
# Local environment files
.env*.local
+4
View File
@@ -0,0 +1,4 @@
package.json
LICENSE
.github
tests
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"proseWrap": "always",
"tabWidth": 2
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"vue.volar",
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"editor.wordBasedSuggestions": "off"
}
+222
View File
@@ -0,0 +1,222 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [10.3.0] - 2026-06-13
### Added
- **Core:** Every rendered SVG now starts with the generator comment
`<!-- Generated by DiceBear (https://dicebear.com) -->` as the first child of
the root `<svg>` element. The comment is byte-identical across the JavaScript,
PHP, Python, Rust, Go, and Dart libraries. The byte output of every avatar
changes as a result, including data URIs and content hashes, so consumers that
compare rendered SVGs against stored snapshots need to update them. SVG
optimizers that strip comments (e.g. SVGO with default settings) remove it
again.
- **Dart library:** A new Dart implementation (the
[`dicebear_core`](https://pub.dev/packages/dicebear_core) package) that
produces identical output to the JavaScript library when given the same styles
and options. It validates style definitions and options against the shared
schemas (via `dicebear_schema`) and pairs with the `dicebear_styles` package.
- **Core (PHP, Python):** Added `Style::fromJson()` (PHP) and
`Style.from_json()` (Python) to build a style from a raw JSON string without a
separate `json_decode(..., true)` / `json.loads(...)` call. Malformed JSON
raises the language's native parse error (`JsonException` /
`json.JSONDecodeError`); an invalid definition raises the usual
`StyleValidationError`. Mirrors `Style::from_str` (Rust) and `Style.parse`
(Dart); the existing array/dict constructor is unchanged.
### Deprecated
- **Core (JS, PHP, Python):** Passing a raw style definition to `Avatar` is
deprecated; pass a `Style` instead
(`new Avatar(new Style(definition), options)`), which also lets you reuse one
parsed style across many avatars. The definition still works for now and
renders identically, but emits a deprecation warning (a one-time
`console.warn` in JS, `E_USER_DEPRECATED` in PHP, `DeprecationWarning` in
Python) and will be removed in v11. The Dart, Rust and Go libraries already
require a `Style`, so this brings every port to the same `Avatar(style, …)`
call.
## [10.2.0] - 2026-06-10
### Added
- **Go library:** A new Go implementation (the
`github.com/dicebear/dicebear-go/v10` module) that produces identical output
to the JavaScript library when given the same styles and options.
### Fixed
- **Core:** `Color.luminance()` now derives the sRGB linearization from a
precomputed lookup table (one entry per 8-bit channel value) instead of
calling `pow` at runtime. `pow` is not required to be correctly rounded and
produced last-ULP differences between JS engines (V8 vs. others), the C math
library (PHP, Python, Rust), and Go's pure-Go implementation, so luminance
values, and in contrived cases contrast-based color ordering, could diverge
across languages and even across browsers. The table holds the values the
JavaScript reference produces today, so JavaScript output is unchanged; the
other libraries move by at most one ULP. The Go library additionally forces
intermediate rounding in the weighted sum, which the compiler could otherwise
fuse into FMA instructions on arm64. Rendered SVGs are unaffected.
- **Core (PHP):** `Avatar::toDataUri()` now percent-encodes exactly like
JavaScript's `encodeURIComponent`. Previously the PHP library used plain
`rawurlencode`, which additionally escapes `!*'()`, characters that occur in
every rendered SVG (e.g. `url(#…)` references and `translate(…)` transforms),
so the data URI diverged byte-wise from the JavaScript, Python, Rust, and Go
libraries. The decoded SVG was unaffected.
- **Core (JS):** The `initial` style variable now resolves to the full first
code point of the initials. Previously the JavaScript library emitted a lone
UTF-16 surrogate (ill-formed XML) when the initials started with a character
outside the Basic Multilingual Plane (e.g. an emoji). The PHP, Python, Rust,
and Go libraries already returned the full character; all libraries are now
byte-identical for such seeds.
- **Core (Rust):** `Avatar.to_json()` now records `size` before `title` in the
resolved-options snapshot, matching the JavaScript, PHP, and Python libraries.
The rendered SVG was unaffected; only consumers comparing or hashing the
serialized options JSON across languages were affected.
- **Core (Python):** `Avatar.to_json()` now serializes whole-number floats in
the resolved-options snapshot as integers (`1`, not `1.0`), matching the
JavaScript, Rust, and PHP libraries. Previously snapshot values such as
`scale`, `rotate`, `translateX`/`translateY`, `borderRadius`, color angles,
and per-component transforms were emitted as `1.0`/`0.0`, so the serialized
JSON diverged from the other ports. The rendered SVG was unaffected. The
values were already numerically equal, so only consumers comparing or hashing
the serialized options JSON across languages were affected.
## [10.2.0-rc.1] - 2026-06-07
### Added
- **Rust library:** A new Rust implementation (the `dicebear-core` crate) that
produces identical output to the JavaScript library when given the same styles
and options.
### Fixed
- **Core:** Initials now discard everything from the first `@` to the end of the
seed (e.g. an email domain). Previously the strip stopped at the first line
terminator (at a line feed in PHP and Python, and additionally at a carriage
return or `U+2028`/`U+2029` in JavaScript), so a seed with a line break after
the `@` kept the trailing text as a second word, and the libraries could even
diverge from each other. All language libraries now produce byte-identical
initials for such seeds.
## [10.1.0] - 2026-06-06
### Changed
- **Schema:** Bumped the bundled `@dicebear/schema` to `1.1.0` across the
JavaScript, PHP, and Python libraries. It adds an upper bound of `1000000` to
the canvas and component `width`/`height`, preventing the language ports'
number-to-string formatting from diverging at extreme values. Official styles
use ~100, so no real avatar is affected.
- **Styles:** Bumped `@dicebear/styles` to `10.1.0`. Lorelei's mouth is now
visible through `beard` variants (the overlaying mask was previously rendered
at `0` opacity), and all style definitions now reference
`@dicebear/schema@1.1.0`.
## [10.1.0-rc.1] - 2026-06-02
### Added
- **Python library:** A new Python implementation that produces identical output
to the JavaScript library when given the same styles and options.
## [10.0.2] - 2026-06-02
### Fixed
- **Core:** Numeric values in rendered SVGs are now consistently rounded to at
most 5 decimal places, so the JavaScript and PHP libraries produce
byte-identical output for every input. Previously, fractional or very
small/large values (e.g. a fractional `borderRadius` or `translateX`,
component transforms, or gradient stop offsets) could be stringified
differently between languages (scientific notation, differing precision).
Avatars built from whole-number options are unaffected.
- **Core (PHP):** `Prng::float` now rounds halves toward +Infinity (matching the
JavaScript reference's `Math.round`) instead of PHP's native `round()`, which
rounds halves away from zero. The two diverged for negative values landing
exactly on a `.5` boundary, so a PHP-rendered avatar could differ from the
JavaScript one by `0.0001` in a rotate/translate transform or color angle for
certain seeds. Output is now byte-identical across languages.
- **Core (PHP):** Initials are now derived correctly from seeds containing
multibyte letters such as `ü` or `ô`. The quote-stripping step was missing the
`/u` (Unicode) flag, so it removed raw UTF-8 bytes and corrupted those
letters: e.g. `über` and `côté` produced wrong or empty initials instead of
`ÜB` / `CÔ`. The PHP output now matches the JavaScript reference.
- **Core:** Range options (`scale`, `borderRadius`, `rotate`,
`translateX`/`translateY`, and per-color angle/fill-stops) given as a
single-element array `[n]` are now treated as the fixed value `n` (identical
to the scalar `n`), and an empty array `[]` falls back to the option's
default. Both forms are permitted by the schema. Previously the behaviour
diverged: the JavaScript library emitted `NaN` (e.g. `scale(NaN)`), while PHP
dropped `[n]` to the default. All three now agree.
## [10.0.1] - 2026-05-29
### Fixed
- **CLI:** `dicebear --version` and `dicebear --help` no longer fail by trying
to read a file named `--version`/`--help`. The definition path is now resolved
via the argument parser, so flags (and the values they consume) before the
path are handled correctly, e.g. `dicebear --json my-style.json` and
`dicebear --count 2 my-style.json`.
## [10.0.0] - 2026-05-27
See the
[v10.0.0 release notes](https://github.com/dicebear/dicebear/releases/tag/v10.0.0).
### Added
- **6 new avatar styles:** Disco, Glyphs, Initial Face, Shape Grid, Stripes, and
Triangles.
- **PHP library:** A new PHP implementation that produces identical output to
the JavaScript library when given the same styles and options.
- **CLI support for custom styles:** Generate avatars from a JSON style
definition, e.g. `dicebear ./path/to/style.json --seed test --format svg`.
- **Weighted variants:** Assign weights to component variants to control how
frequently each appears.
- **Gradient support:** Colors can be defined as gradients, including an angle
parameter.
- **Integrated validation:** Built-in validation for avatar styles and options.
- **Redesigned playground:** Adjust options, upload custom styles, batch
download avatars, and view the number of possible combinations.
- **New tools:** WCAG Contrast Picker and Bundle Size Estimator.
- Reorganized and improved documentation, with better style docs and component
previews.
### Changed
- Each avatar style is now stored as a JSON definition file instead of
JavaScript code, separating licensing concerns from implementation.
- Styles are now distributed via `@dicebear/styles` as JSON definitions.
- The JavaScript API now uses `Style` and `Avatar` classes together with
definition imports.
- **BREAKING:** Component options are now suffixed with `Variant` (e.g.
`eyesVariant` instead of `eyes`).
### Removed
- **BREAKING:** Individual style packages (e.g. `@dicebear/initials`) have been
removed in favor of `@dicebear/styles`.
[Unreleased]: https://github.com/dicebear/dicebear/compare/v10.3.0...HEAD
[10.3.0]: https://github.com/dicebear/dicebear/compare/v10.2.0...v10.3.0
[10.2.0]: https://github.com/dicebear/dicebear/compare/v10.2.0-rc.1...v10.2.0
[10.2.0-rc.1]:
https://github.com/dicebear/dicebear/compare/v10.1.0...v10.2.0-rc.1
[10.1.0]: https://github.com/dicebear/dicebear/compare/v10.1.0-rc.1...v10.1.0
[10.1.0-rc.1]:
https://github.com/dicebear/dicebear/compare/v10.0.2...v10.1.0-rc.1
[10.0.2]: https://github.com/dicebear/dicebear/compare/v10.0.1...v10.0.2
[10.0.1]: https://github.com/dicebear/dicebear/compare/v10.0.0...v10.0.1
[10.0.0]: https://github.com/dicebear/dicebear/releases/tag/v10.0.0
+362
View File
@@ -0,0 +1,362 @@
# Contributing
Thanks for your interest in contributing to DiceBear.
This is the main monorepo: the JavaScript, PHP, Python, Rust, Go, and Dart core
libraries, the CLI, the docs site, and the editor all live here. Repositories
covering the JSON Schema, the avatar style definitions, the HTTP API, and the
Figma exporter are separate and each have their own `CONTRIBUTING.md`:
- [`dicebear/schema`](https://github.com/dicebear/schema/blob/main/CONTRIBUTING.md):
JSON Schema for definitions and options
- [`dicebear/styles`](https://github.com/dicebear/styles/blob/main/CONTRIBUTING.md):
Official avatar style definitions
- [`dicebear/api`](https://github.com/dicebear/api/blob/main/CONTRIBUTING.md):
Self-hostable HTTP API
- [`dicebear/exporter-plugin-for-figma`](https://github.com/dicebear/exporter-plugin-for-figma/blob/main/CONTRIBUTING.md):
Figma plugin
If your contribution belongs to one of those repos, read its file first. The
instructions below only cover this monorepo.
## Before you start
- Bug fixes, small improvements, new tests: open a pull request against the
branch that matches the target major (for DiceBear 10 that's `10.x`; the
current stable line lives on `9.x`).
- New avatar styles: contribute them to
[`dicebear/styles`](https://github.com/dicebear/styles), not here. The
walkthrough is in
[Create an avatar style with Figma](https://www.dicebear.com/guides/create-an-avatar-style-with-figma/)
or
[Create an avatar style from scratch](https://www.dicebear.com/guides/create-an-avatar-style-from-scratch/).
- Larger changes (new public API, rendering behavior, breaking changes): open an
issue first so we can agree on scope before you spend time on it.
- Security issues go privately to <contact@dicebear.com>; never file a public
issue for a vulnerability.
- Contributors follow the
[DiceBear Code of Conduct](https://github.com/dicebear/.github/blob/main/CODE_OF_CONDUCT.md).
## Requirements
- [Node.js](https://nodejs.org/) 20 or newer (CI runs on 20, 22, 24, 25)
- npm 11 (this repo pins `packageManager` in `package.json`; use
[Corepack](https://nodejs.org/api/corepack.html) if you don't already)
- For PHP work: PHP 8.2+ and Composer, plus `vendor/bin/phpunit` via
`composer install` inside `src/php/core/`
- For Python work: Python 3.10+ (CI runs on 3.10 to 3.14); install the package
in a virtualenv with `pip install -e ".[dev]"` inside `src/python/core/`
- For Rust work: Rust 1.80+ with the `clippy` and `rustfmt` components; build
and test with `cargo test`, `cargo clippy`, and `cargo fmt` inside
`src/rust/core/`
- For Go work: Go 1.23+ (CI runs on 1.23 to 1.25); test with `go test ./...` and
check with `gofmt -l .` and `go vet ./...` inside `src/go/core/`
- For Dart work: Dart SDK 3.4+ (CI runs on 3.4 and stable); test with
`dart test` and check with `dart format --output=none --set-exit-if-changed .`
and `dart analyze --fatal-infos` inside `src/dart/core/`
## Local setup
```sh
git clone git@github.com:dicebear/dicebear.git
cd dicebear
npm install
```
The monorepo uses npm workspaces (`src/js/*` and `apps/*`) driven by
[Turborepo](https://turborepo.com/). A single install at the root is enough; do
not `npm install` inside individual packages.
## Common scripts
Run these from the repo root:
| Script | What it does |
| ---------------------- | --------------------------------------------------- |
| `npm run build` | Builds every workspace via Turbo |
| `npm test` | Runs every workspace's test target |
| `npm run test:scripts` | Runs the repo-level scripts in `tests/**/*.test.js` |
| `npm run lint` | Runs ESLint across the repo with caching |
| `npm run lint:fix` | Same, with `--fix` |
| `npm run prettier` | Formats `src/` and `apps/` with Prettier |
For faster loops, scope to a single workspace:
```sh
npm run build --workspace @dicebear/core
npm run test --workspace @dicebear/core
```
## Repository layout
```
src/
├── js/ # TypeScript packages published to npm
│ ├── core/ # Rendering engine (@dicebear/core)
│ ├── cli/ # Command-line interface
│ └── converter/ # SVG → raster converter
├── php/ # PHP port (Composer package `dicebear/core`)
├── python/ # Python port (PyPI package `dicebear-core`)
├── rust/ # Rust port (crates.io crate `dicebear-core`)
├── go/ # Go port (module `github.com/dicebear/dicebear-go/v10`)
└── dart/ # Dart port (pub.dev package `dicebear_core`)
apps/
├── docs/ # VitePress documentation site (dicebear.com), including the Playground
└── editor/ # The in-browser editor (editor.dicebear.com)
tests/
└── fixtures/parity/ # Cross-language parity fixtures (see below)
scripts/
└── version.mjs # Bumps versions across the workspace and tags
```
## Working on a package
### TypeScript packages (`src/js/*`)
```sh
npm run build --workspace <package>
npm run test --workspace <package>
```
If you are working on the CLI, call the compiled script directly once you've
built it:
```sh
node src/js/cli/bin/index.js <command>
```
### PHP core (`src/php/core/`)
```sh
cd src/php/core
composer install
vendor/bin/phpunit
```
### Python core (`src/python/core/`)
```sh
cd src/python/core
pip install -e ".[dev]" # in a virtualenv
ruff check .
ruff format --check .
mypy src
pytest
```
The Python core reads the two draft-07 schemas from the `dicebear-schema`
package (the Python counterpart of `@dicebear/schema` / `dicebear/schema`),
which `pip install -e ".[dev]"` pulls in as a runtime dependency. Nothing is
vendored.
### Rust core (`src/rust/core/`)
```sh
cd src/rust/core
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt --check
```
The Rust core reads the two draft-07 schemas from the `dicebear-schema` crate
(the Rust counterpart of `@dicebear/schema` / `dicebear/schema`) as a runtime
dependency. Nothing is vendored.
### Go core (`src/go/core/`)
```sh
cd src/go/core
go test ./...
go vet ./...
gofmt -l . # prints nothing when formatting is correct
```
The Go core reads the two draft-07 schemas from the `github.com/dicebear/schema`
module (the Go counterpart of `@dicebear/schema` / `dicebear/schema`) as a
dependency. Nothing is vendored. Style definitions come from
`github.com/dicebear/styles/v10`. The module path carries the major version
(`/v10`), so a major bump changes the path by hand; `scripts/version.mjs` only
creates the Git tag the module proxy reads.
### Dart core (`src/dart/core/`)
```sh
cd src/dart/core
dart pub get
dart test
dart analyze --fatal-infos
dart format --output=none --set-exit-if-changed .
```
The Dart core reads the two draft-07 schemas from the `dicebear_schema` package
(the Dart counterpart of `@dicebear/schema` / `dicebear/schema`) as a runtime
dependency, validated with `package:json_schema`. Nothing is vendored.
`src/dart/core/CHANGELOG.md` is a git-ignored build artifact: pub.dev expects a
changelog inside the package directory, so the CI workflows copy the repository
root `CHANGELOG.md` there before `dart pub publish [--dry-run]`. This follows
the same pattern as the generated `LICENSE` copies in the styles and schema
repositories. Maintain the root changelog only.
### Cross-language parity
Every port must produce output **byte-identical** to the reference JavaScript
core (`@dicebear/core`) for the same inputs. A shared fixture suite in
`tests/fixtures/parity/` (generated from the JS reference) enforces this, and
each side consumes it:
- JS side: `src/js/core/tests/Parity.test.js`, run via
`npm run test --workspace @dicebear/core`.
- PHP side: `tests/ParityTest.php`, run via `vendor/bin/phpunit` in
`src/php/core/`.
- Python side: `tests/test_parity.py`, run via `pytest` in `src/python/core/`.
- Rust side: the module unit tests plus `tests/avatars.rs`, run via `cargo test`
in `src/rust/core/`.
- Go side: the in-package tests (`parity_test.go`, `avatars_test.go`), run via
`go test ./...` in `src/go/core/`.
- Dart side: the tests under `test/parity/`, run via `dart test` in
`src/dart/core/`.
The fixtures cover `Fnv1a` (hash + hex), `Mulberry32` (chained sequences), every
`Prng` method, number-to-string formatting (`numbers.json`, the `formatNumber`
contract: every emitted number rounded to at most 5 decimal places, which every
port must reproduce identically), initials extraction, the `Color` helpers
(including bit-exact luminance doubles), accept/reject validation outcomes plus
circular color-reference chains, the `OptionsDescriptor` field map, and full
`Avatar.toString()` (plus selected `toDataUri()`) output for the `initials`,
`thumbs`, `glass`, `notionists`, and `shape-grid` styles. That last bucket
covers seed, size, transforms, gradients, `title` escaping, and
component-variant overrides.
Float determinism is part of the parity contract: `pow` is not correctly rounded
(JS engines, libm, and Go all differ in the last bit), so the sRGB linearization
ships as a precomputed 256-entry table in every port, and languages that fuse
multiply-add into FMA instructions (e.g. Go on arm64) must force intermediate
rounding. The details live in the
[Implement DiceBear Core](https://www.dicebear.com/specification/implement-dicebear-core/)
spec.
If your change affects rendering or the PRNG in `@dicebear/core`, regenerate the
fixtures from the JS reference and commit the diff:
```sh
npm run fixtures:parity
```
The PHP, Python, Rust, Go, and Dart suites will then fail loudly until those
sides are brought back in sync. That is the intended signal. If you only intend
to touch one language, expect to update both before your PR can be merged.
When porting DiceBear to another language, run these fixtures against your
implementation to prove it conforms. See
[Implement DiceBear Core](https://www.dicebear.com/specification/implement-dicebear-core/)
for the full spec.
## Documentation changes (`apps/docs/`)
The docs site is a VitePress app under `apps/docs/`.
```sh
npm run dev --workspace @dicebear/docs # live reload on localhost
npm run build --workspace @dicebear/docs # production build check
```
For larger editorial changes, open a draft PR early so reviewers can follow
along.
## Editor changes (`apps/editor/`)
The editor is the standalone app served at
[editor.dicebear.com](https://editor.dicebear.com).
```sh
npm run dev --workspace @dicebear/editor
npm run build --workspace @dicebear/editor
```
## Code style
- ESLint and Prettier decide what counts as correctly formatted code; run
`npm run lint` and `npm run prettier` before you open a PR.
- TypeScript is `strict`. Prefer narrow types to `any` / `unknown` casts.
- PHP code follows PSR-12; `vendor/bin/phpunit` and Composer's built-in scripts
catch the rest.
- Python code is formatted and linted with [Ruff](https://docs.astral.sh/ruff/)
and type-checked with `mypy` in strict mode; run `ruff check .`,
`ruff format .`, and `mypy src` in `src/python/core/` before you open a PR.
- Rust code is formatted with `rustfmt` and linted with Clippy (warnings
denied); run `cargo fmt` and
`cargo clippy --all-targets --all-features -- -D warnings` in `src/rust/core/`
before you open a PR.
- Go code is formatted with `gofmt` and vetted with `go vet`; run `gofmt -w .`
and `go vet ./...` in `src/go/core/` before you open a PR.
- Dart code is formatted with `dart format` and analyzed with
`dart analyze --fatal-infos` (lints from `analysis_options.yaml`); run both in
`src/dart/core/` before you open a PR.
## Releasing (maintainers only)
> Only maintainers with write access can release new versions. This section is
> documented here for completeness.
Releases are triggered by Git tags. The version script updates every package in
the workspace, creates a commit, and creates the tag:
```sh
node scripts/version.mjs <version>
```
The version must be a valid [semver](https://semver.org/) value (e.g. `10.1.0`
or `10.2.0-alpha.1`). The script will:
1. Update `version` in every `package.json` across the workspace
2. Update internal workspace dependency references
3. Update `version` in `src/python/core/pyproject.toml`,
`src/rust/core/Cargo.toml`, and `src/dart/core/pubspec.yaml` (the Python,
Rust, and Dart cores are not npm workspaces, so they are bumped explicitly to
stay in lockstep)
4. Sync `package-lock.json`
5. Create a Git commit and tag (e.g. `v10.1.0`)
Push the commit and the tag:
```sh
git push && git push --tags
```
The tag triggers the
[Publish](https://github.com/dicebear/dicebear/actions/workflows/publish.yml)
workflow, which:
1. Runs the test suite on Node 20, 22, 24, and 25
2. Builds every package
3. Picks the npm dist-tag:
- Tags containing `alpha`, `beta`, or `rc` go out as `next`
- Other tags on the default branch go out as `latest`
- Other tags on any other branch go out as `v<major>-lts` (so that
backporting a patch to `9.x` after `10.x` has shipped does not overwrite
`latest`)
4. Publishes all changed packages to npm via `scripts/publish.mjs`
5. Builds `src/python/core` and publishes `dicebear-core` to PyPI via trusted
publishing (the `publish-python` job): no token and no separate repository,
the same way the npm packages go out
6. Publishes the Rust core `dicebear-core` to crates.io via trusted publishing
(the `publish-rust` job): likewise no token; `cargo publish` builds and
uploads `src/rust/core` in one step
7. Publishes the Dart core `dicebear_core` to pub.dev via automated publishing
(the `publish-pub` job): likewise no token; pub.dev verifies the `v<version>`
tag against the pubspec version and publishes `src/dart/core` straight from
the monorepo
The PHP port is the exception: Composer/Packagist consumes one Git repository
per package rather than a monorepo subdirectory, so `split-php-core.yml` mirrors
`src/php/core` (tags included) to the standalone
[`dicebear/dicebear-php`](https://github.com/dicebear/dicebear-php) repository,
and Packagist publishes `dicebear/core` from that mirror. All five ports ride
the same version the monorepo tagged.
## Licensing
By opening a pull request you agree that your contribution is released under the
repository's [MIT license](./LICENSE). Avatar style artwork in `dicebear/styles`
may carry other licenses; see that repo's `LICENSE.md` for details.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Florian Körner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+103
View File
@@ -0,0 +1,103 @@
<h1><img src="https://www.dicebear.com/logo-readme.svg" width="28" /> DiceBear Avatar Library</h1>
DiceBear is an open source avatar library. It turns any seed string (a username
or an email address, for example) into an SVG avatar in one of 35+ styles
designed by various artists. The same seed always produces the same avatar, so
you store a string instead of an image and never ask users to upload a profile
picture.
Avatars are customizable through style options: colors, backgrounds, rotation,
individual features like hair or glasses.
[Playground](https://www.dicebear.com/playground) |
[Documentation](https://www.dicebear.com/introduction) |
[Editor](https://editor.dicebear.com)
## One library, six languages
DiceBear 10 ships as native libraries for JavaScript, PHP, Python, Rust, Go, and
Dart. Every port passes a shared test suite that requires byte-identical SVG
output to the JavaScript reference. Generate an avatar in the browser,
regenerate it later in a Go or PHP backend, and you get the same bytes.
| Language | Package | Install |
| ----------------------- | ----------------------------------------------------------------------- | -------------------------------------------- |
| JavaScript / TypeScript | [`@dicebear/core`](https://www.npmjs.com/package/@dicebear/core) | `npm install @dicebear/core` |
| PHP | [`dicebear/core`](https://packagist.org/packages/dicebear/core) | `composer require dicebear/core` |
| Python | [`dicebear-core`](https://pypi.org/project/dicebear-core/) | `pip install dicebear-core` |
| Rust | [`dicebear-core`](https://crates.io/crates/dicebear-core) | `cargo add dicebear-core` |
| Go | [`dicebear-go`](https://pkg.go.dev/github.com/dicebear/dicebear-go/v10) | `go get github.com/dicebear/dicebear-go/v10` |
| Dart | [`dicebear_core`](https://pub.dev/packages/dicebear_core) | `dart pub add dicebear_core` |
In JavaScript it looks like this; the
[documentation](https://www.dicebear.com/introduction) has the equivalent for
each language:
```js
import { Avatar } from '@dicebear/core';
import definition from '@dicebear/styles/lorelei.json' with { type: 'json' };
const avatar = new Avatar(definition, {
seed: 'John Doe',
size: 128,
});
avatar.toString(); // SVG string
avatar.toDataUri(); // data:image/svg+xml;charset=utf-8,...
```
The 35+ avatar styles are plain JSON definitions from the
[`dicebear/styles`](https://github.com/dicebear/styles) repository, available as
a package for each language. You can also
[create your own style](https://www.dicebear.com/guides/create-an-avatar-style-with-figma/),
with Figma or from scratch.
## Without writing code
- The [HTTP API](https://www.dicebear.com/how-to-use/http-api/) returns avatars
from a plain URL, free and without an account:
`https://api.dicebear.com/10.x/lorelei/svg?seed=Felix`. For full control and
privacy you can
[host it yourself](https://www.dicebear.com/guides/host-the-http-api-yourself/)
with a single Docker container.
- The [CLI](https://www.dicebear.com/how-to-use/cli/) generates avatar files in
bulk: `npx dicebear lorelei --count 10`.
- The [editor](https://editor.dicebear.com) lets you assemble a single avatar by
hand and export it.
## This repository
This monorepo contains the six core libraries, the CLI, the SVG-to-raster
converter, the documentation site ([dicebear.com](https://www.dicebear.com)),
and the editor. Related projects live in their own repositories:
- [`dicebear/styles`](https://github.com/dicebear/styles): the official avatar
style definitions
- [`dicebear/schema`](https://github.com/dicebear/schema): the JSON Schema
behind definitions and options
- [`dicebear/api`](https://github.com/dicebear/api): the self-hostable HTTP API
- [`dicebear/exporter-plugin-for-figma`](https://github.com/dicebear/exporter-plugin-for-figma):
the Figma plugin for style authors
Contributions are welcome; [CONTRIBUTING.md](./CONTRIBUTING.md) explains the
setup and where each kind of change belongs.
## License
The code is [MIT licensed](./LICENSE), including commercial use. The avatar
styles are the work of their respective artists and carry their own licenses;
the [license overview](https://www.dicebear.com/licenses/) lists them all, and
many only ask for attribution.
## Sponsors
Advertisement: Many thanks to our sponsors who provide us with free or
discounted products.
<a href="https://bunny.net/" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://www.dicebear.com/sponsors/bunny-light.svg">
<source media="(prefers-color-scheme: light)" srcset="https://www.dicebear.com/sponsors/bunny-dark.svg">
<img alt="bunny.net" src="https://www.dicebear.com/sponsors/bunny-dark.svg" height="64">
</picture>
</a>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`dicebear/dicebear`
- 原始仓库:https://github.com/dicebear/dicebear
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+4
View File
@@ -0,0 +1,4 @@
# Legal links shown in the footer.
VITE_PRIVACY_POLICY_URL=
VITE_COOKIE_POLICY_URL=
VITE_SITE_NOTICE_URL=
+9
View File
@@ -0,0 +1,9 @@
# Cache and log files
.DS_STORE
# Dependencies
node_modules
# Vitepress
.vitepress/cache
.vitepress/dist
+263
View File
@@ -0,0 +1,263 @@
import * as path from 'node:path';
import { defineConfig, type DefaultTheme, type HeadConfig } from 'vitepress';
import { ThemeOptions } from '@theme/types';
import sidebarDocs from './config/sidebarDocs';
import sidebarStyles from './config/sidebarStyles';
import sidebarTools from './config/sidebarTools';
import avatarStyles from './config/avatarStyles';
import avatarUniqueCounts from './config/avatarUniqueCounts';
import avatarStyleSizes from './config/avatarStyleSizes';
import { formatStars } from './theme/utils/format';
async function fetchGitHubStars(
repos: string[],
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
const TIMEOUT_MS = 5000;
await Promise.all(
repos.map(async (repo) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(`https://api.github.com/repos/${repo}`, {
signal: controller.signal,
});
if (res.ok) {
const data = await res.json();
result[repo] = formatStars(data.stargazers_count);
}
} catch (err) {
console.warn(
`[github-stars] failed for ${repo}:`,
err instanceof Error ? err.message : err,
);
} finally {
clearTimeout(timer);
}
}),
);
return result;
}
const githubStars = await fetchGitHubStars([
'dicebear/dicebear',
'nusu/avvvatars',
'dmester/jdenticon',
'multiavatar/Multiavatar',
'boringdesigners/boring-avatars',
]);
const isProduction = process.env.NODE_ENV === 'production';
const thirdPartyScripts: HeadConfig[] = isProduction
? [
[
'script',
{
defer: '',
src: 'https://u.dicebear.com/script.js',
'data-website-id': '75d27df5-3441-4530-8f29-70d04ae9085e',
},
],
]
: [];
export default defineConfig<ThemeOptions>({
title: 'DiceBear',
description:
'DiceBear is a free, open source avatar library and avatar API with 35+ avatar styles. Generate profile pictures and user placeholder images for any project.',
head: [
// Most pages load avatars from the HTTP API (seed demo, style showcase,
// playground). Warming up the connection hides the DNS/TLS latency on
// mobile. The avatars are plain <img> requests (no CORS), so the hint
// must not carry a crossorigin attribute to match the connection.
['link', { rel: 'preconnect', href: 'https://api.dicebear.com' }],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: '/favicon-96x96.png',
sizes: '96x96',
},
],
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
['link', { rel: 'shortcut icon', href: '/favicon.ico' }],
[
'link',
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/apple-touch-icon.png',
},
],
['meta', { name: 'apple-mobile-web-app-title', content: 'DiceBear' }],
['link', { rel: 'manifest', href: '/site.webmanifest' }],
['meta', { property: 'og:site_name', content: 'DiceBear' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { name: 'twitter:card', content: 'summary' }],
[
'script',
{ type: 'application/ld+json' },
JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'DiceBear',
url: 'https://www.dicebear.com',
description:
'DiceBear is a free, open source avatar library and Avatar API. Generate unique, deterministic SVG avatars and profile pictures with 35+ styles — privacy-focused and self-hostable.',
}),
],
[
'script',
{ type: 'application/ld+json' },
JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'DiceBear',
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
url: 'https://www.dicebear.com',
description:
'Privacy-focused, open source SVG avatar library with 35+ styles. Free Avatar API, JavaScript library, PHP library, Python library, Rust library, Go library, Dart library, and CLI for generating deterministic profile pictures and user placeholder images.',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
},
}),
],
...thirdPartyScripts,
],
srcDir: path.join(__dirname, '..', 'pages'),
transformHead: (ctx) => {
const result: HeadConfig[] = [];
// The theme renders text with the self-hosted variable Inter (see
// theme/index.ts); VitePress's built-in Inter is disabled via
// theme-without-fonts. Preload the latin subset so the first paint
// doesn't wait a full round trip after the CSS arrives. Font preloads
// always need `crossorigin`, even for same-origin requests.
const interFont = ctx.assets.find((asset) =>
/inter-latin-wght-normal\.[\w-]+\.woff2$/.test(asset),
);
if (interFont) {
result.push([
'link',
{
rel: 'preload',
href: interFont,
as: 'font',
type: 'font/woff2',
crossorigin: '',
},
]);
}
if (ctx.pageData.relativePath) {
const canonicalPath = ctx.pageData.relativePath
.replace('index.md', '')
.replace(/\.md$/, '');
const canonicalUrl = `https://www.dicebear.com/${canonicalPath}`;
result.push(['link', { rel: 'canonical', href: canonicalUrl }]);
if (canonicalPath.startsWith('legal/legal-notice')) {
result.push(['meta', { name: 'robots', content: 'noindex, nofollow' }]);
}
const pageTitle =
ctx.pageData.frontmatter.title || ctx.pageData.title || 'DiceBear';
const pageDescription =
ctx.pageData.frontmatter.description ||
ctx.pageData.description ||
'DiceBear is a free, open source avatar library and Avatar API with 35+ avatar styles.';
result.push(
['meta', { property: 'og:title', content: pageTitle }],
['meta', { property: 'og:description', content: pageDescription }],
['meta', { property: 'og:url', content: canonicalUrl }],
['meta', { name: 'twitter:title', content: pageTitle }],
['meta', { name: 'twitter:description', content: pageDescription }],
);
}
return result;
},
vite: {
ssr: {
noExternal: ['vue-countup-v3', 'vue-chartjs', 'globe.gl', 'three'],
},
resolve: {
alias: {
'@playground': path.resolve(__dirname, 'theme/components/playground'),
'@theme': path.resolve(__dirname, 'theme'),
'./components/VPLocalNav.vue': path.resolve(
__dirname,
'theme/components/layout/LayoutVPLocalNav.vue',
),
},
},
},
themeConfig: {
avatarStyles,
avatarUniqueCounts,
avatarStyleSizes,
githubStars,
siteTitle: '',
// Intrinsic SVG dimensions as explicit attributes so the browser can
// reserve the aspect ratio before the file loads (the displayed size
// comes from the theme's CSS height). VPImage spreads nested src
// objects onto the <img> at runtime, but ThemeableImage only types the
// light/dark form with plain strings, hence the double cast.
logo: {
dark: { src: '/logo-dark.svg', width: 183, height: 32 },
light: { src: '/logo.svg', width: 183, height: 32 },
} as unknown as DefaultTheme.ThemeableImage,
externalLinkIcon: true,
search: {
provider: 'local',
},
nav: [
{ text: 'Playground', link: '/playground/', activeMatch: '^/playground' },
{
text: 'Docs',
link: '/introduction/',
activeMatch: '^/(introduction|how-to-use|guides|specification)',
},
{
text: 'Styles',
link: '/styles/',
activeMatch: '^/styles',
},
{ text: 'Editor', link: 'https://editor.dicebear.com' },
{
text: '10.x',
items: [{ text: '9.x', link: 'https://v9.dicebear.com' }],
},
],
outline: [2, 2],
socialLinks: [],
editLink: {
pattern:
'https://github.com/dicebear/dicebear/edit/10.x/apps/docs/pages/:path',
},
sidebar: {
'/introduction/': sidebarDocs,
'/styles/': sidebarStyles,
'/how-to-use/': sidebarDocs,
'/guides/': sidebarDocs,
'/specification/': sidebarDocs,
'/tools/': sidebarTools,
},
},
sitemap: {
hostname: 'https://www.dicebear.com',
},
markdown: {},
});
@@ -0,0 +1,67 @@
import { createRequire } from 'node:module';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { gzipSync } from 'node:zlib';
import type { AvatarStyleSizeBundle } from '@theme/types';
const require = createRequire(import.meta.url);
function sizeFor(file: string): { raw: number; gzip: number } {
const buf = fs.readFileSync(file);
return { raw: buf.byteLength, gzip: gzipSync(buf).byteLength };
}
function walkJsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...walkJsFiles(full));
} else if (entry.isFile() && entry.name.endsWith('.js')) {
out.push(full);
}
}
return out;
}
const stylesDir = path.dirname(
require.resolve('@dicebear/styles/initials.json'),
);
const coreLibDir = path.dirname(require.resolve('@dicebear/core'));
// @dicebear/converter `main` points to lib/node/index.js; step up to lib/
const converterLibDir = path.dirname(
path.dirname(require.resolve('@dicebear/converter')),
);
const styles: Record<string, { raw: number; gzip: number }> = {};
for (const file of fs.readdirSync(stylesDir)) {
if (!file.endsWith('.min.json')) continue;
const name = file.replace('.min.json', '');
styles[name] = sizeFor(path.join(stylesDir, file));
}
function bundleSize(
dir: string,
exclude: (file: string) => boolean = () => false,
) {
let raw = 0;
let gzip = 0;
for (const file of walkJsFiles(dir)) {
if (exclude(file)) continue;
const s = sizeFor(file);
raw += s.raw;
gzip += s.gzip;
}
return { raw, gzip };
}
const avatarStyleSizes: AvatarStyleSizeBundle = {
core: bundleSize(coreLibDir),
converter: bundleSize(converterLibDir, (file) =>
file.includes(`${path.sep}node${path.sep}`),
),
styles,
};
export default avatarStyleSizes;
@@ -0,0 +1,43 @@
import { Style } from '@dicebear/core';
import { AvatarStyles } from '@theme/types';
import { createRequire } from 'node:module';
import * as path from 'node:path';
import * as fs from 'node:fs';
const require = createRequire(import.meta.url);
const definitionsDir = path.dirname(
require.resolve('@dicebear/styles/initials.json'),
);
const avatarStyles: AvatarStyles = {};
for (const file of fs.readdirSync(definitionsDir)) {
if (!file.endsWith('.min.json')) {
continue;
}
const name = file.replace('.min.json', '');
const definition = JSON.parse(
fs.readFileSync(path.join(definitionsDir, file), 'utf-8'),
);
const style = new Style(definition);
const meta = style.meta();
avatarStyles[name] = {
definitionUrl: style.id(),
meta: {
title: meta.source().name(),
creator: meta.creator().name(),
homepage: meta.creator().url(),
source: meta.source().url(),
license: {
name: meta.license().name(),
url: meta.license().url(),
text: meta.license().text(),
},
},
};
}
export default avatarStyles;
@@ -0,0 +1,30 @@
import type { StyleDefinition } from '@dicebear/core';
import { createRequire } from 'node:module';
import * as path from 'node:path';
import * as fs from 'node:fs';
import type { AvatarUniqueCount } from '@theme/types';
import { computeCount } from '../theme/utils/avatar/combinationCount';
const require = createRequire(import.meta.url);
const definitionsDir = path.dirname(
require.resolve('@dicebear/styles/initials.json'),
);
const avatarUniqueCounts: Record<string, AvatarUniqueCount> = {};
for (const file of fs.readdirSync(definitionsDir)) {
if (!file.endsWith('.min.json')) {
continue;
}
const name = file.replace('.min.json', '');
const definition: StyleDefinition = JSON.parse(
fs.readFileSync(path.join(definitionsDir, file), 'utf-8'),
);
avatarUniqueCounts[name] = computeCount(definition);
}
export default avatarUniqueCounts;
+182
View File
@@ -0,0 +1,182 @@
import { DefaultTheme } from 'vitepress';
// NOTE: VitePress renders sidebar item `text` with `v-html`, so inline markup is
// allowed — we use it to attach small status badges (styled via
// `.vp-sidebar-badge` in theme/styles/main.scss). Groups are intentionally NOT
// collapsible (no `collapsed` key), so every section stays expanded.
const sidebar: DefaultTheme.SidebarItem[] = [
{
text: 'Introduction',
items: [
{ text: 'What is DiceBear?', link: '/introduction/' },
{
text: 'DiceBear vs. Alternatives',
link: '/guides/avatar-library-comparison/',
},
],
},
{
text: 'How to use',
items: [
{
text: 'JS Library',
link: '/how-to-use/js-library/',
items: [
{ text: 'Core', link: '/how-to-use/js-library/' },
{ text: 'Converter', link: '/how-to-use/js-library/converter/' },
],
},
{
text: 'PHP Library <span class="vp-sidebar-badge is-new">New</span>',
link: '/how-to-use/php-library/',
},
{
text: 'Python Library <span class="vp-sidebar-badge is-new">New</span>',
link: '/how-to-use/python-library/',
},
{
text: 'Rust Library <span class="vp-sidebar-badge is-new">New</span>',
link: '/how-to-use/rust-library/',
},
{
text: 'Go Library <span class="vp-sidebar-badge is-new">New</span>',
link: '/how-to-use/go-library/',
},
{
text: 'Dart Library <span class="vp-sidebar-badge is-new">New</span>',
link: '/how-to-use/dart-library/',
},
{ text: 'HTTP-API', link: '/how-to-use/http-api/' },
{ text: 'CLI', link: '/how-to-use/cli/' },
],
},
{
text: 'Frameworks',
items: [
{
text: 'Angular',
link: '/guides/use-the-library-with-angular/',
},
{
text: 'Flutter',
link: '/guides/use-the-library-with-flutter/',
},
{
text: 'Next.js',
link: '/guides/use-the-library-with-next-js/',
},
{
text: 'Nuxt',
link: '/guides/use-the-library-with-nuxt/',
},
{
text: 'React',
link: '/guides/use-the-library-with-react/',
},
{
text: 'React Native',
link: '/guides/use-the-library-with-react-native/',
},
{
text: 'Svelte',
link: '/guides/use-the-library-with-svelte/',
},
{
text: 'Vue',
link: '/guides/use-the-library-with-vue/',
},
],
},
{
text: 'Custom Styles',
items: [
{
text: 'With Figma',
link: '/guides/create-an-avatar-style-with-figma/',
},
{
text: 'From Scratch',
link: '/guides/create-an-avatar-style-from-scratch/',
},
],
},
{
text: 'Specification',
items: [
{
text: 'Definition Schema',
link: '/specification/definition-schema/',
},
{
text: 'Implement DiceBear Core',
link: '/specification/implement-dicebear-core/',
},
],
},
{
text: 'Concepts',
items: [
{
text: 'Gender',
link: '/guides/how-do-i-set-a-gender/',
},
],
},
{
text: 'Use Cases',
items: [
{
text: 'Avatar Placeholder',
link: '/guides/use-as-avatar-placeholder/',
},
{
text: 'Gravatar Default Image',
link: '/guides/use-the-http-api-as-gravatar-default-image/',
},
{
text: 'Self-host the HTTP-API',
link: '/guides/host-the-http-api-yourself/',
},
],
},
{
text: 'Advanced',
items: [
{
text: 'Access Style Options',
link: '/guides/access-all-available-options/',
},
{
text: 'Load All Avatar Styles',
link: '/guides/load-all-avatar-styles/',
},
{
text: 'Unique Avatar Count',
link: '/guides/how-many-unique-avatars/',
},
],
},
{
text: 'Contributing',
items: [
{
text: 'Documentation',
link: '/guides/contribute-to-the-documentation/',
},
{
text: 'Editor',
link: '/guides/contribute-to-the-editor/',
},
{
text: 'API',
link: '/guides/contribute-to-the-api/',
},
{
text: 'Library',
link: '/guides/contribute-to-the-library/',
},
],
},
];
export default sidebar;
@@ -0,0 +1,10 @@
import { DefaultTheme } from 'vitepress';
const sidebar: DefaultTheme.SidebarItem[] = [
{
text: 'Playground',
items: [{ text: 'Playground', link: '/playground/' }],
},
];
export default sidebar;
@@ -0,0 +1,36 @@
import { capitalCase } from 'change-case';
import { DefaultTheme } from 'vitepress';
import avatarStyles from './avatarStyles';
// Avatar styles to flag with a "New" badge in the sidebar. These are the styles
// added in the most recent @dicebear/styles release that introduced new styles
// (v10.0.0). Update this set when new styles ship. The badge markup is rendered
// via v-html (see `.vp-sidebar-badge` in theme/styles/main.scss).
const NEW_STYLES = new Set<string>([
'disco',
'glyphs',
'initial-face',
'shape-grid',
'stripes',
'triangles',
]);
const sidebar: DefaultTheme.SidebarItem[] = [
{
text: 'Styles',
items: Object.keys(avatarStyles)
.sort((a, b) => a.localeCompare(b))
.map((styleName) => {
const label = capitalCase(styleName);
return {
text: NEW_STYLES.has(styleName)
? `${label} <span class="vp-sidebar-badge is-new">New</span>`
: label,
link: `/styles/${styleName}/`,
};
}),
},
];
export default sidebar;
@@ -0,0 +1,13 @@
import { DefaultTheme } from 'vitepress';
const sidebar: DefaultTheme.SidebarItem[] = [
{
text: 'Tools',
items: [
{ text: 'WCAG Contrast Picker', link: '/tools/contrast/' },
{ text: 'Bundle Size Estimator', link: '/tools/bundle-size/' },
],
},
];
export default sidebar;
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import DefaultTheme from 'vitepress/theme-without-fonts';
import { VPDocAsideSponsors } from 'vitepress/theme-without-fonts';
import { withBase } from 'vitepress';
import LayoutFooter from './components/layout/LayoutFooter.vue';
import LayoutNavActions from './components/layout/LayoutNavActions.vue';
import './styles/main.scss';
const { Layout } = DefaultTheme;
const sponsors = [
{
tier: 'CDN sponsored by',
size: 'medium' as const,
items: [
{
name: 'bunny.net',
img: withBase('/sponsors/bunny-dark.svg'),
url: 'https://bunny.net/',
},
],
},
];
</script>
<template>
<Layout>
<template #nav-bar-content-after>
<LayoutNavActions />
</template>
<template #aside-outline-after>
<div class="layout-aside-sponsors">
<VPDocAsideSponsors :data="sponsors" />
<span class="layout-aside-sponsors-ad">Advertisement</span>
</div>
</template>
<template #layout-bottom>
<LayoutFooter />
</template>
</Layout>
</template>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Globe, Zap, Server } from '@lucide/vue';
import { UiContainer, UiSection, UiSectionHeader, UiCard } from '../ui';
import { useVisibility } from '../../composables/useVisibility';
import { useApiStats } from '../../composables/useApiStats';
import { formatNumber, formatBytes } from '../../utils/format';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
const apiStats = useApiStats();
const monthLabel = computed(() => apiStats.value?.monthLabel ?? 'Month');
const stats = computed(() => [
{
value: apiStats.value
? formatNumber(apiStats.value.monthlyRequests)
: '1B+',
label: `Requests in ${monthLabel.value}`,
icon: Globe,
},
{
value: apiStats.value ? formatBytes(apiStats.value.monthlyTraffic) : '3TB+',
label: `Data Served in ${monthLabel.value}`,
icon: Server,
},
{ value: '85%+', label: 'Cache Hit Rate', icon: Zap },
]);
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-cdn-gradient"></div>
</template>
<UiContainer>
<UiSectionHeader
class="app-cdn-header"
description="Our HTTP-API is powered by a global CDN that delivers avatars with low latency and high reliability, completely free of charge."
>
<template #headline
>Lightning fast, <strong>globally delivered</strong></template
>
</UiSectionHeader>
<div class="app-cdn-content">
<UiCard padding="2xl" class="app-cdn-card">
<div class="app-cdn-card-layout">
<div class="app-cdn-card-info">
<div class="app-cdn-sponsor">
<a
href="https://bunny.net/"
class="app-cdn-logo-link"
target="_blank"
rel="noopener sponsored"
>
<img
src="/sponsors/bunny-dark.svg"
alt="bunny.net"
class="app-cdn-logo app-cdn-logo-light"
/>
<img
src="/sponsors/bunny-light.svg"
alt="bunny.net"
class="app-cdn-logo app-cdn-logo-dark"
/>
</a>
<p class="app-cdn-description">
bunny.net sponsors the CDN infrastructure for our HTTP-API.
This lets us serve avatars globally with low latency,
completely free of charge for you.
</p>
<a
href="https://bunny.net/"
class="app-cdn-link"
target="_blank"
rel="noopener sponsored"
>
Visit bunny.net &rarr;
</a>
<span class="app-cdn-ad">Advertisement</span>
</div>
</div>
<div class="app-cdn-stats">
<div
v-for="(stat, index) in stats"
:key="stat.label"
class="app-cdn-stat-item"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<component :is="stat.icon" class="app-cdn-stat-icon" />
<span class="app-cdn-stat-value">{{ stat.value }}</span>
<span class="app-cdn-stat-label">{{ stat.label }}</span>
</div>
</div>
</div>
</UiCard>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss">
:root {
--app-cdn-logo-light-display: inline;
--app-cdn-logo-dark-display: none;
}
.dark {
--app-cdn-logo-light-display: none;
--app-cdn-logo-dark-display: inline;
}
</style>
<style lang="scss" scoped>
.app-cdn {
&-gradient {
background:
radial-gradient(
ellipse 60% 70% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
),
radial-gradient(
ellipse 60% 70% at 50% 100%,
color-mix(in srgb, var(--vp-c-brand-1) 4%, transparent),
transparent
);
}
&-header {
--hl-from: var(--vp-c-brand-3);
--hl-to: var(--vp-c-brand-1);
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-content {
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth) 0.15s;
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-card-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 48px;
align-items: center;
}
&-sponsor {
display: flex;
flex-direction: column;
gap: 20px;
}
&-logo-link {
display: inline-block;
line-height: 0;
&::after {
display: none !important;
}
}
&-logo {
height: 44px;
width: auto;
&-light {
display: var(--app-cdn-logo-light-display);
}
&-dark {
display: var(--app-cdn-logo-dark-display);
}
}
&-description {
font-size: 15px;
color: var(--vp-c-text-2);
line-height: 1.7;
margin: 0;
}
&-link {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-brand-1);
text-decoration: none;
transition: color var(--duration-fast) ease;
&::after {
display: none !important;
}
&:hover {
color: var(--vp-c-brand-2);
}
}
&-ad {
font-size: 12px;
color: var(--vp-c-text-3);
}
&-stats {
display: flex;
flex-direction: column;
gap: 16px;
}
&-stat-item {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
background: var(--vp-c-bg);
border-radius: var(--vp-radius-sm);
opacity: 0;
transform: translateX(20px);
transition: all var(--duration-mid) ease;
.visible & {
animation: app-cdn-stat-reveal 0.5s var(--ease-spring) forwards;
}
}
&-stat-icon {
width: 20px;
height: 20px;
color: var(--vp-c-brand-1);
flex-shrink: 0;
}
&-stat-value {
font-size: 22px;
font-weight: 800;
color: var(--vp-c-text-1);
font-variant-numeric: tabular-nums;
min-width: 80px;
}
&-stat-label {
font-size: 14px;
color: var(--vp-c-text-2);
font-weight: 500;
}
}
@keyframes app-cdn-stat-reveal {
to {
opacity: 1;
transform: translateX(0);
}
}
@media (max-width: 768px) {
.app-cdn {
&-card {
--ui-card-padding: 32px 24px;
}
&-card-layout {
grid-template-columns: 1fr;
gap: 32px;
}
&-stats {
gap: 12px;
}
&-stat-item {
padding: 16px 20px;
}
&-stat-value {
font-size: 20px;
}
}
}
</style>
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Check, X } from '@lucide/vue';
import { useData } from 'vitepress';
import type { ThemeOptions } from '@theme/types';
import { UiContainer, UiSection, UiSectionHeader, UiCard } from '../ui';
import { useVisibility } from '../../composables/useVisibility';
import {
buildComparisonRows,
comparisonServices,
} from '@theme/config/comparison';
const { theme } = useData<ThemeOptions>();
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.1 });
const rows = buildComparisonRows({
stars: theme.value.githubStars ?? {},
styleCount: Object.keys(theme.value.avatarStyles ?? {}).length,
});
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-comparison-gradient"></div>
</template>
<UiContainer>
<UiSectionHeader
class="app-comparison-header"
description="Every tool has its strengths. Choose what works best for your project."
>
<template #headline>How DiceBear <strong>Compares</strong></template>
</UiSectionHeader>
<UiCard flush class="app-comparison-table-card">
<div class="app-comparison-table-wrapper">
<table class="app-comparison-table">
<thead>
<tr>
<th class="app-comparison-feature-col">Feature</th>
<th
v-for="(service, index) in comparisonServices"
:key="service.key"
:class="{ 'app-comparison-highlight-col': index === 0 }"
>
<a
:href="service.url"
target="_blank"
rel="noopener"
class="app-comparison-service-link"
>{{ service.name }}</a
>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="rowIndex">
<td class="app-comparison-feature-col">{{ row.feature }}</td>
<td
v-for="(service, colIndex) in comparisonServices"
:key="service.key"
:class="{ 'app-comparison-highlight-col': colIndex === 0 }"
>
<span
v-if="row.values[service.key] === 'yes'"
class="app-comparison-cell-yes"
>
<Check :size="18" />
</span>
<span
v-else-if="row.values[service.key] === 'free'"
class="app-comparison-cell-free"
>
Free
</span>
<span
v-else-if="row.values[service.key] === 'paid'"
class="app-comparison-cell-paid"
>
Paid
</span>
<span
v-else-if="row.values[service.key] === 'no'"
class="app-comparison-cell-no"
>
<X :size="18" />
</span>
<span v-else class="app-comparison-cell-text">{{
row.values[service.key]
}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</UiCard>
<p class="app-comparison-note">
This comparison is based on publicly available information and may not
reflect the latest updates. Each tool has its own strengths, so choose
what works best for your project.
</p>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-comparison {
&-gradient {
background:
radial-gradient(
ellipse 50% 50% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
),
radial-gradient(
ellipse 50% 50% at 50% 100%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
);
}
&-header {
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-table-card {
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth) 0.2s;
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
&-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
min-width: 700px;
th,
td {
padding: 14px 16px;
text-align: center;
border-bottom: 1px solid var(--vp-c-border);
white-space: nowrap;
}
thead th {
font-weight: 700;
font-size: 13px;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.05em;
padding-bottom: 16px;
}
tbody tr:last-child td {
border-bottom: none;
}
thead .app-comparison-highlight-col {
color: var(--vp-c-brand-1);
}
}
&-feature-col {
text-align: left !important;
font-weight: 600;
color: var(--vp-c-text-1);
min-width: 160px;
position: sticky;
left: 0;
z-index: 1;
background: var(--vp-c-bg);
}
&-highlight-col {
background: color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent);
font-weight: 600;
}
&-service-link {
color: inherit;
text-decoration: none;
transition: color var(--duration-fast) ease;
&::after {
display: none !important;
}
&:hover {
color: var(--vp-c-brand-1);
}
}
&-cell-yes {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--vp-c-green-soft);
color: var(--vp-c-green-1);
}
&-cell-free {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border-radius: var(--vp-radius-sm);
background: var(--vp-c-green-soft);
color: var(--vp-c-green-1);
font-size: 13px;
font-weight: 600;
}
&-cell-paid {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border-radius: var(--vp-radius-sm);
background: color-mix(in srgb, var(--vp-c-text-3) 10%, transparent);
color: var(--vp-c-text-2);
font-size: 13px;
font-weight: 600;
}
&-cell-no {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: color-mix(in srgb, var(--vp-c-text-3) 15%, transparent);
color: var(--vp-c-text-3);
}
&-cell-text {
font-size: 13px;
color: var(--vp-c-text-2);
}
&-note {
text-align: center;
font-size: 13px;
color: var(--vp-c-text-3);
margin: 24px auto 0;
max-width: 600px;
line-height: 1.6;
}
}
@media (max-width: 640px) {
.app-comparison {
&-table-card {
--card-padding: 16px;
}
}
}
</style>
@@ -0,0 +1,475 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ArrowRight, BookOpen } from '@lucide/vue';
import { siFigma } from 'simple-icons';
import Button from 'primevue/button';
import { UiContainer, UiSection, UiSectionHeader, UiCard, UiIcon } from '../ui';
import { useVisibility } from '../../composables/useVisibility';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef);
const steps = [
{
title: 'Design in Figma',
description:
'Create your avatar components visually. Group colors and parts using our simple naming conventions.',
},
{
title: 'Export with Plugin',
description:
'Use the DiceBear Figma plugin to configure options and export your style as a ready-to-use package.',
},
{
title: 'Build & Use',
description:
'Run npm install and npm run build. Your custom style is ready to generate avatars.',
},
];
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-create-style-bg-grid"></div>
<div class="app-create-style-gradient"></div>
</template>
<UiContainer class="app-create-style-container">
<UiSectionHeader
description="Design your avatar style visually in Figma. Our plugin handles the technical export, with no coding required."
>
<template #headline
>Create Your Own Style<br /><strong>with Figma</strong></template
>
</UiSectionHeader>
<div class="app-create-style-grid">
<div class="app-create-style-content">
<div class="app-create-style-steps">
<UiCard
v-for="(step, index) in steps"
:key="index"
class="app-create-style-step-card"
:style="{ animationDelay: `${index * 0.15}s` }"
>
<div class="app-create-style-step-number">{{ index + 1 }}</div>
<div class="app-create-style-step-content">
<h3 class="app-create-style-step-title">{{ step.title }}</h3>
<p class="app-create-style-step-description">
{{ step.description }}
</p>
</div>
</UiCard>
</div>
<div class="app-create-style-actions">
<Button
as="a"
href="/guides/create-an-avatar-style-with-figma/"
size="large"
severity="contrast"
>
<BookOpen :size="20" />
Read the Guide
<ArrowRight :size="20" />
</Button>
<Button
as="a"
href="https://www.figma.com/community/plugin/1005765655729342787"
target="_blank"
rel="noopener"
size="large"
severity="secondary"
variant="outlined"
>
<UiIcon :path="siFigma.path" :size="20" />
Get the Plugin
<ArrowRight :size="20" />
</Button>
</div>
</div>
<div class="app-create-style-visual">
<div class="app-create-style-figma-mockup">
<div class="app-create-style-figma-sidebar">
<div class="app-create-style-figma-layers">
<div class="app-create-style-layer-group">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-folder"
></span>
<span>face</span>
</div>
<div class="app-create-style-layer-item">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-component"
></span>
<span>face/round</span>
</div>
<div class="app-create-style-layer-item">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-component"
></span>
<span>face/oval</span>
</div>
<div class="app-create-style-layer-group">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-folder"
></span>
<span>eyes</span>
</div>
<div class="app-create-style-layer-item">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-component"
></span>
<span>eyes/happy</span>
</div>
<div class="app-create-style-layer-item active">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-component"
></span>
<span>eyes/wink</span>
</div>
<div class="app-create-style-layer-group">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-folder"
></span>
<span>mouth</span>
</div>
<div class="app-create-style-layer-item">
<span
class="app-create-style-layer-icon app-create-style-layer-icon-component"
></span>
<span>mouth/smile</span>
</div>
</div>
</div>
<div class="app-create-style-figma-canvas">
<div class="app-create-style-canvas-avatar">
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="32" cy="32" r="28" fill="#FFD93D" />
<circle cx="22" cy="28" r="4" fill="#1a1a2e" />
<path
d="M38 26 L42 30 L38 34"
stroke="#1a1a2e"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M20 42 Q32 52 44 42"
stroke="#1a1a2e"
stroke-width="3"
stroke-linecap="round"
fill="none"
/>
</svg>
</div>
<div class="app-create-style-canvas-guides">
<div
class="app-create-style-guide app-create-style-guide-h"
></div>
<div
class="app-create-style-guide app-create-style-guide-v"
></div>
</div>
</div>
</div>
</div>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-create-style {
&-bg-grid {
background-image:
linear-gradient(var(--vp-c-border) 1px, transparent 1px),
linear-gradient(90deg, var(--vp-c-border) 1px, transparent 1px);
background-size: 60px 60px;
background-repeat: repeat !important;
opacity: 0.3;
mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black, transparent);
-webkit-mask-image: radial-gradient(
ellipse 80% 60% at 50% 50%,
black,
transparent
);
}
&-gradient {
background: radial-gradient(
ellipse 80% 50% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
transparent
);
}
&-container {
opacity: 0;
transform: translateY(40px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
align-items: start;
}
&-content {
display: flex;
flex-direction: column;
}
&-steps {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
&-step-card {
opacity: 0;
transform: translateX(-20px);
.visible & {
animation: reveal-up 0.6s var(--ease-smooth) forwards;
}
:deep(.ui-card-body) {
display: flex;
align-items: flex-start;
gap: 16px;
}
}
&-step-number {
flex-shrink: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-2);
font-size: 13px;
font-weight: 700;
border-radius: 50%;
border: 1px solid var(--vp-c-border);
}
&-step-content {
flex: 1;
min-width: 0;
}
&-step-title {
font-size: 16px;
font-weight: 700;
color: var(--vp-c-text-1);
margin: 0 0 8px;
}
&-step-description {
font-size: 14px;
color: var(--vp-c-text-2);
margin: 0;
line-height: 1.6;
}
&-actions {
display: flex;
gap: 12px;
}
/* Figma Mockup Visual */
&-visual {
display: flex;
justify-content: flex-end;
}
&-figma-mockup {
display: flex;
width: 100%;
max-width: 520px;
height: 420px;
background: #2c2c2c;
border-radius: var(--vp-radius-sm);
overflow: hidden;
box-shadow: var(--vp-shadow-5);
}
&-figma-sidebar {
width: 180px;
background: #1e1e1e;
border-right: 1px solid #3c3c3c;
padding: 16px 0;
overflow: hidden;
}
&-figma-layers {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
font-family: var(--vp-font-family-mono);
}
&-layer-group,
&-layer-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: #b3b3b3;
white-space: nowrap;
}
&-layer-group {
color: #e0e0e0;
font-weight: 500;
}
&-layer-item {
padding-left: 28px;
&.active {
background: rgba(24, 160, 251, 0.2);
color: #18a0fb;
}
}
&-layer-icon {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
&-folder {
background: #f5a623;
}
&-component {
background: #a259ff;
}
}
&-figma-canvas {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background:
linear-gradient(90deg, #3c3c3c 1px, transparent 1px),
linear-gradient(#3c3c3c 1px, transparent 1px);
background-size: 20px 20px;
background-position: center center;
}
&-canvas-avatar {
width: 160px;
height: 160px;
background: white;
border-radius: var(--vp-radius-sm);
padding: 20px;
box-shadow: var(--vp-shadow-4);
position: relative;
z-index: 1;
svg {
width: 100%;
height: 100%;
}
}
&-canvas-guides {
position: absolute;
inset: 0;
pointer-events: none;
}
&-guide {
position: absolute;
background: rgba(255, 0, 85, 0.5);
&-h {
left: 0;
right: 0;
top: 50%;
height: 1px;
}
&-v {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
}
}
}
@media (max-width: 960px) {
.app-create-style {
&-grid {
grid-template-columns: 1fr;
gap: 48px;
}
&-actions {
justify-content: center;
}
&-visual {
justify-content: center;
}
&-figma-mockup {
max-width: 480px;
}
}
}
@media (max-width: 640px) {
.app-create-style {
&-actions {
flex-direction: column;
align-items: stretch;
}
&-figma-mockup {
height: 320px;
}
&-figma-sidebar {
width: 140px;
}
&-canvas-avatar {
width: 120px;
height: 120px;
}
&-layer-group,
&-layer-item {
font-size: 10px;
padding: 6px 8px;
}
&-layer-item {
padding-left: 20px;
}
}
}
</style>
@@ -0,0 +1,555 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
MousePointerClick,
Palette,
Download,
ArrowRight,
Brush,
} from '@lucide/vue';
import Button from 'primevue/button';
import {
UiAvatar,
UiHeadline,
UiDescription,
UiContainer,
UiSection,
UiWindow,
UiIconBox,
} from '../ui';
import { useVisibility } from '../../composables/useVisibility';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef);
// Tab configuration
const tabs = ['Background', 'Hair', 'Eyes', 'Nose', 'Mouth'];
const activeTab = ref(0);
// Current avatar options
const currentOptions = ref({
backgroundColor: 'ffd5dc',
hair: '',
eyes: '',
nose: '',
mouth: '',
});
// Background colors
const bgColors = [
'ffd5dc',
'f8bbd9',
'ffccbc',
'ffe0b2',
'fff9c4',
'c8e6c9',
'dcedc8',
'b2dfdb',
'b2ebf2',
'bbdefb',
];
// Variants for each category
const variants = {
hair: [
'variant01',
'variant02',
'variant03',
'variant04',
'variant05',
'variant06',
'variant07',
'variant08',
'variant09',
'variant10',
],
eyes: [
'variant01',
'variant02',
'variant03',
'variant04',
'variant05',
'variant06',
'variant07',
'variant08',
'variant09',
'variant10',
],
nose: [
'variant01',
'variant02',
'variant03',
'variant04',
'variant05',
'variant06',
],
mouth: [
'happy01',
'happy02',
'happy03',
'happy04',
'happy05',
'happy06',
'happy07',
'happy08',
'happy09',
'happy10',
],
};
// Darken a hex color by a percentage
function darkenColor(hex: string, percent: number): string {
const num = parseInt(hex, 16);
const r = Math.max(0, Math.floor((num >> 16) * (1 - percent)));
const g = Math.max(0, Math.floor(((num >> 8) & 0x00ff) * (1 - percent)));
const b = Math.max(0, Math.floor((num & 0x0000ff) * (1 - percent)));
return ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
}
// Canvas background (slightly darker than avatar background)
const canvasBackground = computed(
() => `#${darkenColor(currentOptions.value.backgroundColor, 0.15)}`,
);
// Build style options from current options
function buildStyleOptions(options: typeof currentOptions.value) {
const result: Record<string, unknown> = {
seed: 'editorUser',
glassesProbability: 0,
};
if (options.backgroundColor) result.backgroundColor = options.backgroundColor;
if (options.hair) result.hairVariant = options.hair;
if (options.eyes) result.eyesVariant = options.eyes;
if (options.nose) result.noseVariant = options.nose;
if (options.mouth) result.mouthVariant = options.mouth;
return result;
}
// Main avatar style options
const mainStyleOptions = computed(() =>
buildStyleOptions(currentOptions.value),
);
// Panel items based on active tab
const panelItems = computed(() => {
const tab = tabs[activeTab.value].toLowerCase();
if (tab === 'background') {
return bgColors.map((color) => ({
id: color,
options: buildStyleOptions({
...currentOptions.value,
backgroundColor: color,
}),
selected: currentOptions.value.backgroundColor === color,
}));
}
const key = tab as keyof typeof variants;
const variantList = variants[key] || [];
return variantList.map((variant) => ({
id: variant,
options: buildStyleOptions({ ...currentOptions.value, [key]: variant }),
selected: currentOptions.value[key] === variant,
}));
});
// Handle item selection
function selectItem(id: string) {
const tab = tabs[activeTab.value].toLowerCase();
if (tab === 'background') {
currentOptions.value.backgroundColor = id;
} else {
const key = tab as keyof typeof currentOptions.value;
currentOptions.value[key] = currentOptions.value[key] === id ? '' : id;
}
}
const features = [
{
icon: MousePointerClick,
title: 'No Coding Required',
description: 'Click to customize hair, eyes, accessories, and more.',
},
{
icon: Palette,
title: 'Endless Combinations',
description: 'Mix and match colors and styles to create your unique look.',
},
{
icon: Download,
title: 'Download as PNG',
description: 'Save your avatar and use it anywhere you like.',
},
];
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-editor-gradient"></div>
<div class="app-editor-dots"></div>
</template>
<UiContainer class="app-editor-container">
<div class="app-editor-preview">
<UiWindow title="DiceBear Editor">
<!-- Main avatar preview -->
<div
class="app-editor-canvas"
:style="{ backgroundColor: canvasBackground }"
>
<div
class="app-editor-avatar-preview"
:style="{ backgroundColor: `#${currentOptions.backgroundColor}` }"
>
<UiAvatar
style-name="lorelei"
:style-options="mainStyleOptions"
alt="Avatar Preview"
/>
</div>
</div>
<!-- Bottom panel with tabs and options -->
<div class="app-editor-panel">
<div class="app-editor-panel-tabs">
<button
v-for="(tab, index) in tabs"
:key="tab"
:class="[
'app-editor-panel-tab',
{ active: index === activeTab },
]"
@click="activeTab = index"
>
{{ tab }}
</button>
</div>
<div class="app-editor-panel-grid">
<button
v-for="(item, index) in panelItems"
:key="`${activeTab}-${item.id}`"
:class="[
'app-editor-panel-avatar',
{ selected: item.selected },
]"
:style="{ animationDelay: `${index * 0.05}s` }"
@click="selectItem(item.id)"
>
<UiAvatar
style-name="lorelei"
:style-options="item.options"
:alt="`Variant ${index + 1}`"
/>
</button>
</div>
</div>
</UiWindow>
</div>
<div class="app-editor-content">
<UiHeadline class="app-editor-title-text"
>Avatar Maker <strong>Without Code</strong></UiHeadline
>
<UiDescription class="app-editor-description">
No developer? No problem! Our avatar maker lets anyone create custom
profile pictures without writing a single line of code. Just pick,
click, and download.
</UiDescription>
<div class="app-editor-features">
<div
v-for="(feature, index) in features"
:key="index"
class="app-editor-feature"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<UiIconBox size="md">
<component :is="feature.icon" />
</UiIconBox>
<div class="app-editor-feature-text">
<h4 class="app-editor-feature-title">{{ feature.title }}</h4>
<p class="app-editor-feature-description">
{{ feature.description }}
</p>
</div>
</div>
</div>
<div class="app-editor-actions">
<Button
as="a"
href="https://editor.dicebear.com"
target="_blank"
rel="noopener"
size="large"
severity="contrast"
>
<Brush :size="20" />
Open Editor
<ArrowRight :size="20" />
</Button>
</div>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-editor {
&-gradient {
background: radial-gradient(
ellipse 60% 60% at 100% 50%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
);
}
&-dots {
background-image: radial-gradient(
circle,
var(--vp-c-text-3) 1px,
transparent 1px
);
background-size: 24px 24px;
background-repeat: repeat !important;
opacity: 0.12;
mask-image: radial-gradient(ellipse 50% 50% at 0% 50%, black, transparent);
-webkit-mask-image: radial-gradient(
ellipse 50% 50% at 0% 50%,
black,
transparent
);
}
&-container {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 80px;
align-items: center;
opacity: 0;
transform: translateY(40px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-preview {
min-width: 0;
}
/* Canvas */
&-canvas {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
min-height: 200px;
transition: background-color var(--duration-mid) ease;
}
&-avatar-preview {
width: 140px;
height: 140px;
background: #ffd5dc;
border-radius: var(--vp-radius-xl);
overflow: hidden;
box-shadow: var(--vp-shadow-3);
transition: background-color var(--duration-mid) ease;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
/* Bottom panel */
&-panel {
background: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-border);
padding: 16px;
&-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
padding-bottom: 4px;
}
&-tab {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--vp-c-text-2);
white-space: nowrap;
border-radius: var(--vp-radius-xs);
border: none;
background: transparent;
cursor: pointer;
transition: all var(--duration-fast) ease;
&:hover {
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
}
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
font-weight: 600;
}
}
&-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
}
&-avatar {
aspect-ratio: 1;
border-radius: var(--vp-radius-sm);
overflow: hidden;
opacity: 0;
transform: scale(0.9);
border: none;
padding: 0;
cursor: pointer;
background: transparent;
transition:
transform var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
&:hover {
transform: scale(1.05);
}
&.selected {
box-shadow: 0 0 0 3px var(--vp-c-brand-1);
&:hover {
transform: scale(1);
}
}
.visible & {
animation: app-editor-avatar-pop 0.4s var(--ease-smooth) forwards;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
/* Content */
&-content {
min-width: 0;
}
&-description {
margin: 0 0 36px 0;
max-width: none;
}
&-features {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 36px;
}
&-feature {
display: flex;
align-items: flex-start;
gap: 16px;
opacity: 0;
transform: translateX(20px);
.visible & {
animation: app-editor-feature-slide 0.5s var(--ease-smooth) forwards;
}
&-text {
min-width: 0;
}
&-title {
font-size: 16px;
font-weight: 600;
color: var(--vp-c-text-1);
margin: 0 0 4px;
}
&-description {
font-size: 14px;
color: var(--vp-c-text-2);
margin: 0;
line-height: 1.5;
}
}
&-actions {
display: flex;
gap: 16px;
}
}
@keyframes app-editor-avatar-pop {
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes app-editor-feature-slide {
to {
opacity: 1;
transform: translateX(0);
}
}
@media (max-width: 1000px) {
.app-editor {
&-container {
grid-template-columns: 1fr;
gap: 48px;
}
&-preview {
order: 2;
}
&-content {
order: 1;
}
}
}
@media (max-width: 640px) {
.app-editor {
&-panel-grid {
grid-template-columns: repeat(5, 1fr);
}
&-panel-tabs {
gap: 4px;
}
&-panel-tab {
padding: 6px 10px;
font-size: 12px;
}
}
}
</style>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { ref } from 'vue';
import { siReact, siVuedotjs, siAngular, siSvelte } from 'simple-icons';
import {
UiCard,
UiContainer,
UiSection,
UiSectionHeader,
UiIconBox,
UiIcon,
} from '../ui';
import { useVisibility } from '../../composables/useVisibility';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
const frameworks = [
{
name: 'React',
icon: siReact.path,
href: '/guides/use-the-library-with-react/',
color: `#${siReact.hex}`,
},
{
name: 'Vue',
icon: siVuedotjs.path,
href: '/guides/use-the-library-with-vue/',
color: `#${siVuedotjs.hex}`,
},
{
name: 'Angular',
icon: siAngular.path,
href: '/guides/use-the-library-with-angular/',
// Angular forbids its former (red) logo; black/white are its default
// variations. simple-icons already ships the current shield shape.
color: 'var(--logo-monochrome)',
},
{
name: 'Svelte',
icon: siSvelte.path,
href: '/guides/use-the-library-with-svelte/',
color: `#${siSvelte.hex}`,
},
];
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-frameworks-dots"></div>
<div class="app-frameworks-gradient"></div>
</template>
<UiContainer class="app-frameworks-container">
<UiSectionHeader
description="Use DiceBear with React, Vue, Svelte, Angular and more. Simply use our HTTP-API as image source or install the JS-library."
>
<template #headline
>Works with Your <strong>Favorite</strong> Framework</template
>
</UiSectionHeader>
<div class="app-frameworks-grid">
<UiCard
v-for="(framework, index) in frameworks"
:key="framework.name"
:href="framework.href"
padding="lg"
class="app-frameworks-item"
:style="{
'--framework-color': framework.color,
'--ui-card-hover-border-color': framework.color,
animationDelay: `${index * 0.1}s`,
}"
>
<UiIconBox size="lg" :color="framework.color">
<UiIcon :path="framework.icon" />
</UiIconBox>
<span class="app-frameworks-name">{{ framework.name }}</span>
</UiCard>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-frameworks {
&-dots {
background-image: radial-gradient(
circle,
var(--vp-c-text-3) 1px,
transparent 1px
);
background-size: 32px 32px;
background-repeat: repeat !important;
opacity: 0.2;
mask-image: radial-gradient(ellipse 70% 50% at 50% 50%, black, transparent);
-webkit-mask-image: radial-gradient(
ellipse 70% 50% at 50% 50%,
black,
transparent
);
}
&-gradient {
background: radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
transparent
);
}
&-container {
text-align: center;
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-grid {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
}
&-item {
min-width: 150px;
opacity: 0;
transform: translateY(20px);
:deep(.ui-card-body) {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.visible & {
animation: reveal-up var(--duration-mid) var(--ease-spring) forwards;
}
}
&-name {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
}
}
</style>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import AppHeroContent from './AppHeroContent.vue';
import AppHeroSwarm from './AppHeroSwarm.vue';
import { useVisibility } from '../../composables/useVisibility';
// Only used to pause the avatar-cluster bob when the hero scrolls out of view.
const isVisible = useVisibility('.app-hero', { once: false, threshold: 0.1 });
</script>
<template>
<section class="app-hero" :class="{ 'app-hero--paused': !isVisible }">
<div class="app-hero-bg ui-bg-grid"></div>
<div class="app-hero-glow"></div>
<div class="app-hero-grid">
<AppHeroContent />
<AppHeroSwarm />
</div>
</section>
</template>
<style lang="scss">
:root {
--app-hero-glow: radial-gradient(
ellipse 60% 50% at 50% -10%,
var(--vp-c-brand-soft),
transparent 70%
);
}
.app-hero {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - var(--vp-nav-height, 64px));
padding: 80px 24px;
overflow: hidden;
&--paused {
.app-hero-swarm-tile {
/* Pause only the bob (second animation). SSR renders --paused until the
* IntersectionObserver fires after hydration. If the fade were paused
* too, the tiles would stay at opacity 0 until all JS has loaded, which
* delays LCP by seconds on slow connections. */
animation-play-state: running, paused;
}
}
&-bg {
z-index: 0;
}
&-glow {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: var(--app-hero-glow);
}
&-grid {
position: relative;
z-index: 1;
width: 100%;
max-width: 1180px;
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 64px;
align-items: center;
}
&-content {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
animation: fade-up var(--duration-reveal) var(--ease-smooth);
}
&-title {
margin: 0 0 24px;
line-height: 1.02;
letter-spacing: -0.04em;
text-wrap: balance;
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.1s both;
&-line {
display: block;
font-size: clamp(42px, 4.8vw, 66px);
font-weight: 800;
color: var(--vp-c-text-1);
}
/* The single brand-blue accent word (no gradient, no animation). */
&-accent {
color: var(--vp-c-brand-2);
}
}
&-description {
font-size: 20px;
color: var(--vp-c-text-2);
line-height: 1.6;
margin: 0 0 40px;
max-width: 540px;
text-wrap: pretty;
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.2s both;
}
/* Accent underline: solid brand colour, matching the headline accent word. */
&-underline {
background-image: linear-gradient(var(--vp-c-brand-2), var(--vp-c-brand-2));
background-repeat: no-repeat;
background-position: 0 100%;
background-size: 100% 2px;
padding-bottom: 1px;
white-space: nowrap;
}
&-actions {
display: flex;
justify-content: flex-start;
gap: 16px;
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.3s both;
}
}
@media (max-width: 960px) {
.app-hero {
min-height: auto;
padding: 96px 24px 80px;
&-grid {
grid-template-columns: 1fr;
gap: 48px;
}
&-content {
align-items: center;
text-align: center;
}
&-description {
margin-left: auto;
margin-right: auto;
}
&-actions {
justify-content: center;
}
}
}
@media (max-width: 768px) {
.app-hero {
padding: 80px 16px 64px;
&-title-line {
font-size: clamp(34px, 9vw, 50px);
}
&-description {
font-size: 17px;
}
&-actions {
flex-direction: column;
align-items: center;
.app-hero-action-btn {
width: 100%;
max-width: 280px;
}
}
}
}
</style>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { UiAvatar } from '../ui';
import { useVisibility } from '../../composables/useVisibility';
const listRef = ref<HTMLElement>();
const isVisible = useVisibility(listRef, { once: false, threshold: 0.1 });
const avatarStyles = ['thumbs', 'initials', 'shapes', 'lorelei', 'pixel-art'];
const styleA = ref(avatarStyles[0]);
const styleB = ref(avatarStyles[1]);
const activeLayer = ref<'a' | 'b'>('a');
let styleCounter = 0;
let interval: ReturnType<typeof setInterval> | null = null;
function tick() {
styleCounter++;
const nextStyle = avatarStyles[styleCounter % avatarStyles.length];
if (activeLayer.value === 'a') {
styleB.value = nextStyle;
activeLayer.value = 'b';
} else {
styleA.value = nextStyle;
activeLayer.value = 'a';
}
}
watch(isVisible, (visible) => {
if (visible && !interval) {
interval = setInterval(tick, 3000);
} else if (!visible && interval) {
clearInterval(interval);
interval = null;
}
});
onMounted(() => {
if (isVisible.value) {
interval = setInterval(tick, 3000);
}
});
onUnmounted(() => {
if (interval) clearInterval(interval);
});
const users = [
{ name: 'Iris W.', position: 'Product Designer', online: true },
{ name: 'Leon B.', position: 'Frontend Dev', online: true },
{ name: 'Cara M.', position: 'Team Lead', online: true },
{ name: 'Finn H.', position: 'Backend Dev', online: false },
{ name: 'Yuki S.', position: 'UX Researcher', online: false },
];
</script>
<template>
<div ref="listRef" class="app-hero-aside-user-list">
<div
v-for="(user, index) in users"
:key="user.name"
class="app-hero-aside-user-list-card"
:style="{ animationDelay: `${index * 0.12}s` }"
>
<div class="app-hero-aside-user-list-avatar">
<UiAvatar
class="app-hero-aside-user-list-layer"
:class="{
'app-hero-aside-user-list-layer-active': activeLayer === 'a',
}"
:style-name="styleA"
:style-options="{ seed: user.name, size: 80 }"
:alt="user.name"
/>
<UiAvatar
class="app-hero-aside-user-list-layer"
:class="{
'app-hero-aside-user-list-layer-active': activeLayer === 'b',
}"
:style-name="styleB"
:style-options="{ seed: user.name, size: 80 }"
:alt="user.name"
/>
</div>
<div class="app-hero-aside-user-list-info">
<span class="app-hero-aside-user-list-name">{{ user.name }}</span>
<span class="app-hero-aside-user-list-position">{{
user.position
}}</span>
</div>
<span
class="app-hero-aside-user-list-status"
:class="
user.online
? 'app-hero-aside-user-list-status-online'
: 'app-hero-aside-user-list-status-offline'
"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.app-hero-aside-user-list {
position: relative;
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 8px;
transform: perspective(800px) rotateY(-8deg) rotateX(4deg);
transform-style: preserve-3d;
&-card {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border-radius: var(--vp-radius-md);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.85) 0%,
rgba(255, 255, 255, 0.7) 100%
);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.06),
0 1px 3px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
opacity: 0;
.visible & {
animation: reveal-up 0.5s var(--ease-smooth) forwards;
}
}
&-avatar {
position: relative;
width: 48px;
height: 48px;
border-radius: var(--vp-radius-sm);
overflow: hidden;
flex-shrink: 0;
}
&-layer {
position: absolute;
inset: 0;
width: 48px !important;
height: 48px !important;
border-radius: var(--vp-radius-sm);
opacity: 0;
transition: opacity var(--duration-slow) ease;
&-active {
opacity: 1;
}
}
&-info {
display: flex;
flex-direction: column;
gap: 1px;
flex: 1;
min-width: 0;
}
&-name {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-position {
font-size: 12px;
line-height: 1.4;
color: var(--vp-c-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&-online {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
}
&-offline {
background: var(--vp-c-text-3);
}
}
}
.dark .app-hero-aside-user-list-card {
background: linear-gradient(
135deg,
rgba(40, 40, 45, 0.92) 0%,
rgba(35, 35, 40, 0.88) 100%
);
border-color: rgba(255, 255, 255, 0.1);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.25),
0 1px 3px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
@media (max-width: 768px) {
.app-hero-aside-user-list {
max-width: 320px;
margin: 0 auto;
}
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { PawPrint, ArrowRight } from '@lucide/vue';
import Button from 'primevue/button';
</script>
<template>
<div class="app-hero-content">
<h1 class="app-hero-title">
<span class="app-hero-title-line">Pick a seed.</span>
<span class="app-hero-title-line"
>Get an <span class="app-hero-title-accent">avatar</span>.</span
>
</h1>
<p class="app-hero-description">
DiceBear is a privacy-focused, open source avatar library with
<span class="app-hero-underline">35+&nbsp;avatar&nbsp;styles</span>
crafted by talented artists. Generate deterministic profile pictures via
API, JS&nbsp;library, PHP&nbsp;library, Python&nbsp;library,
Rust&nbsp;library, Go&nbsp;library, Dart&nbsp;library &amp; CLI.
</p>
<div class="app-hero-actions">
<Button
as="a"
href="/playground/"
size="large"
severity="contrast"
class="app-hero-action-btn"
>
<PawPrint :size="20" />
Try Playground
</Button>
<Button
as="a"
href="/introduction/"
size="large"
severity="secondary"
variant="outlined"
class="app-hero-action-btn"
>
Get Started
<ArrowRight :size="20" />
</Button>
</div>
</div>
</template>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { withBase } from 'vitepress';
import { PALETTE, type Pastel } from '@theme/utils/palette';
interface Tile {
styleName: string;
seed: string;
background: Pastel;
size: number;
delay: number;
}
const tiles: Tile[] = [
{
styleName: 'lorelei',
seed: 'Felix',
background: PALETTE.rose,
size: 256,
delay: 0,
},
{
styleName: 'lorelei',
seed: 'Aneka',
background: PALETTE.amber,
size: 160,
delay: -1.2,
},
{
styleName: 'bottts',
seed: 'Pixel',
background: PALETTE.cyan,
size: 128,
delay: -2.1,
},
{
styleName: 'adventurer',
seed: 'Milo',
background: PALETTE.blue,
size: 160,
delay: -0.6,
},
{
styleName: 'notionists',
seed: 'Luna',
background: PALETTE.green,
size: 128,
delay: -3.0,
},
{
styleName: 'thumbs',
seed: 'Sage',
background: PALETTE.lime,
size: 128,
delay: -1.6,
},
];
// The swarm sits in the LCP area, so its avatars are pre-generated as
// same-origin static files (scripts/generate-hero-avatars.mjs) instead of
// being fetched from api.dicebear.com, since an external origin costs extra
// DNS/TLS round trips before the largest above-the-fold images can paint.
function url(tile: Tile): string {
return withBase(
`/avatars/hero/${tile.styleName}-${tile.seed.toLowerCase()}.svg`,
);
}
</script>
<template>
<div class="app-hero-swarm">
<div
v-for="(tile, index) in tiles"
:key="`${tile.styleName}-${tile.seed}`"
:class="['app-hero-swarm-tile', `app-hero-swarm-tile--${index}`]"
:style="{
'--tile-bg': `#${tile.background}`,
'--tile-delay': `${tile.delay}s`,
}"
>
<img
:src="url(tile)"
alt=""
:width="tile.size"
:height="tile.size"
:fetchpriority="index === 0 ? 'high' : 'auto'"
decoding="async"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.app-hero-swarm {
position: relative;
width: 100%;
height: 460px;
display: grid;
place-items: center;
pointer-events: none;
}
.app-hero-swarm-tile {
position: absolute;
width: var(--tile-w);
height: var(--tile-w);
top: var(--tile-top, auto);
bottom: var(--tile-bottom, auto);
left: var(--tile-left, auto);
right: var(--tile-right, auto);
transform: var(--tile-translate, none) rotate(var(--tile-rotate, 0deg));
border-radius: 28px;
overflow: hidden;
background: var(--tile-bg);
box-shadow:
var(--vp-shadow-3),
0 0 0 1px var(--vp-c-divider);
animation:
app-hero-swarm-fade var(--duration-reveal) var(--ease-smooth)
var(--tile-fade-delay, 0s) both,
app-hero-swarm-bob 6s ease-in-out var(--tile-delay, 0s) infinite;
will-change: translate;
contain: layout paint;
img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
&--0 {
--tile-w: 220px;
--tile-left: 50%;
--tile-top: 50%;
--tile-translate: translate(-50%, -50%);
--tile-rotate: -3deg;
--tile-fade-delay: 0.2s;
z-index: 3;
}
&--1 {
--tile-w: 130px;
--tile-left: 4%;
--tile-top: 8%;
--tile-rotate: -8deg;
--tile-fade-delay: 0.35s;
}
&--2 {
--tile-w: 110px;
--tile-right: 8%;
--tile-top: 4%;
--tile-rotate: 6deg;
--tile-fade-delay: 0.45s;
}
&--3 {
--tile-w: 140px;
--tile-right: 0;
--tile-bottom: 8%;
--tile-rotate: 4deg;
--tile-fade-delay: 0.55s;
}
&--4 {
--tile-w: 100px;
--tile-left: 2%;
--tile-bottom: 14%;
--tile-rotate: -5deg;
--tile-fade-delay: 0.65s;
}
&--5 {
--tile-w: 92px;
--tile-left: 24%;
--tile-bottom: 0;
--tile-rotate: 8deg;
--tile-fade-delay: 0.75s;
}
}
@keyframes app-hero-swarm-bob {
0%,
100% {
translate: 0 0;
}
50% {
translate: 0 -5px;
}
}
@keyframes app-hero-swarm-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 960px) {
.app-hero-swarm {
height: 360px;
max-width: 460px;
margin: 0 auto;
}
.app-hero-swarm-tile {
&--0 {
--tile-w: 180px;
}
&--1 {
--tile-w: 104px;
}
&--2 {
--tile-w: 88px;
}
&--3 {
--tile-w: 112px;
}
&--4 {
--tile-w: 80px;
}
&--5 {
--tile-w: 76px;
}
}
}
@media (max-width: 540px) {
.app-hero-swarm {
height: 300px;
}
.app-hero-swarm-tile {
&--0 {
--tile-w: 150px;
}
&--1 {
--tile-w: 86px;
}
&--2 {
--tile-w: 74px;
}
&--3 {
--tile-w: 94px;
}
&--4 {
--tile-w: 68px;
}
&--5 {
--tile-w: 64px;
}
}
}
</style>
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref } from 'vue';
import {
UiContainer,
UiSection,
UiSectionHeader,
UiIconBox,
UiCard,
UiIcon,
} from '../ui';
import {
Target,
Palette,
Shapes,
Globe,
Library,
Terminal,
SlidersHorizontal,
} from '@lucide/vue';
import { siGithub, siFigma } from 'simple-icons';
import { useVisibility } from '../../composables/useVisibility';
withDefaults(
defineProps<{
headline?: string;
description?: string;
}>(),
{
headline: 'Built for Developers, Loved by Users',
description:
'Everything you need to create beautiful, unique avatars for your applications.',
},
);
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
const highlights = [
// Row 1: the avatars themselves
{
icon: Target,
title: 'Deterministic Avatars',
description:
'Same seed always generates the same avatar. Perfect for user profiles and consistent identities.',
color: '#1689cc',
},
{
icon: Palette,
title: '35+ Avatar Styles',
description:
'Carefully crafted styles from talented artists. Characters, abstract, pixel art, and more.',
color: '#a855f7',
},
{
icon: Shapes,
title: 'Scalable SVG',
description:
'Pure SVG output stays razor-sharp at any size, from tiny favicons to full-screen, and weighs just a few kilobytes.',
color: '#06b6d4',
},
// Row 2: how you generate them
{
icon: Globe,
title: 'Free Avatar API',
description:
'Our profile picture API handles millions of daily requests. Global CDN delivers random user avatars in milliseconds.',
color: '#22c55e',
},
{
// One box for the language libraries (JS / PHP / Python / Rust / Go / Dart).
// Generic Library icon with no language logos, so the named languages stay
// pure nominative use with no trademark/logo-modification questions.
icon: Library,
title: 'Official Libraries',
description:
'JavaScript, PHP, Python, Rust, Go, and Dart share one identical API across every language. The same seed gives the same result, and no data leaves your servers.',
color: '#f59e0b',
},
{
icon: Terminal,
title: 'CLI',
description:
'Generate avatars directly from the command line. Perfect for batch processing and build pipelines.',
color: '#64748b',
},
// Row 3: design & trust
{
iconPath: siFigma.path,
title: 'Figma Plugin',
description:
'Design custom avatar styles in Figma and export them as ready-to-use DiceBear definitions, with no code required.',
color: 'var(--logo-monochrome)',
},
{
icon: SlidersHorizontal,
title: 'Fully Customizable',
description:
'Colors, accessories, backgrounds, and more. Fine-tune every detail to match your brand.',
color: '#ec4899',
},
{
iconPath: siGithub.path,
title: '100% Open Source',
description:
'MIT licensed core, transparent development. Contribute, fork, or self-host with confidence.',
color: 'var(--logo-monochrome)',
},
];
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-highlights-gradient"></div>
</template>
<UiContainer>
<UiSectionHeader class="app-highlights-header" :description="description">
<template #headline>
<slot name="headline">{{ headline }}</slot>
</template>
</UiSectionHeader>
<div class="app-highlights-grid">
<UiCard
v-for="(highlight, index) in highlights"
:key="index"
padding="xl"
class="app-highlights-card"
:style="{
'--accent-color': highlight.color,
animationDelay: `${index * 0.1}s`,
}"
>
<UiIconBox
size="lg"
:color="highlight.color"
class="app-highlights-icon-wrapper"
>
<UiIcon v-if="highlight.iconPath" :path="highlight.iconPath" />
<component v-else :is="highlight.icon" />
</UiIconBox>
<h3 class="app-highlights-title">{{ highlight.title }}</h3>
<p class="app-highlights-description">{{ highlight.description }}</p>
</UiCard>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-highlights {
&-gradient {
background:
radial-gradient(
ellipse 50% 50% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
),
radial-gradient(
ellipse 50% 50% at 50% 100%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
);
}
&-header {
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-card {
opacity: 0;
transform: translateY(30px);
.visible & {
animation: reveal-up var(--duration-mid) var(--ease-spring) forwards;
}
}
&-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
&-icon-wrapper {
margin-bottom: 24px;
}
&-title {
font-size: 18px;
font-weight: 700;
color: var(--vp-c-text-1);
margin: 0 0 8px;
}
&-description {
font-size: 14px;
color: var(--vp-c-text-2);
margin: 0;
line-height: 1.6;
}
}
@media (max-width: 1000px) {
.app-highlights {
&-grid {
grid-template-columns: repeat(2, 1fr);
}
}
}
@media (max-width: 640px) {
.app-highlights {
&-grid {
grid-template-columns: 1fr;
gap: 16px;
}
}
}
</style>
@@ -0,0 +1,424 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Globe, Library, Terminal, ArrowRight } from '@lucide/vue';
import Button from 'primevue/button';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import {
UiContainer,
UiSection,
UiSectionHeader,
UiCard,
UiIconBox,
UiCode,
} from '../ui';
import { useVisibility } from '../../composables/useVisibility';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
const libraryTab = ref('js');
const plainCode = {
js: `import { Style, Avatar } from '@dicebear/core';
import lorelei from '@dicebear/styles/lorelei.json' with { type: 'json' };
const style = new Style(lorelei);
const svg = new Avatar(style, {
seed: 'Mia',
}).toString();`,
php: `<?php
use Composer\\InstalledVersions;
use DiceBear\\Style;
use DiceBear\\Avatar;
$basePath = InstalledVersions::getInstallPath('dicebear/styles');
$style = Style::fromJson(
file_get_contents($basePath . '/src/lorelei.json')
);
$svg = (string) new Avatar($style, [
'seed' => 'Mia',
]);`,
python: `from importlib.resources import files
from dicebear import Avatar, Style
style = Style.from_json(
files("dicebear_styles").joinpath("lorelei.json").read_text("utf-8")
)
svg = Avatar(style, {"seed": "Mia"}).to_string()`,
rust: `use dicebear_core::{Avatar, Style};
use serde_json::json;
let style = Style::from_str(dicebear_styles::LORELEI)?;
let svg = Avatar::new(&style, json!({ "seed": "Mia" }))?.to_svg();`,
go: `import (
dicebear "github.com/dicebear/dicebear-go/v10"
"github.com/dicebear/styles/v10"
)
style, _ := dicebear.NewStyle([]byte(styles.Lorelei))
avatar, _ := dicebear.NewAvatar(style, map[string]any{"seed": "Mia"})
svg := avatar.SVG()`,
dart: `import 'package:dicebear_core/dicebear_core.dart';
import 'package:dicebear_styles/lorelei.dart';
final style = Style.parse(lorelei);
final svg = Avatar(style, {'seed': 'Mia'}).svg;`,
api: `https://api.dicebear.com/10.x/lorelei/svg?seed=Mia`,
cli: `npx dicebear lorelei --seed "Mia" --format svg`,
};
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-integration-dots"></div>
<div class="app-integration-gradient"></div>
</template>
<UiContainer>
<UiSectionHeader
class="app-integration-header"
description="Choose the integration that works best for your project."
>
<template #headline>Integrate in <strong>Minutes</strong></template>
</UiSectionHeader>
<!-- Language libraries - combined into one tabbed card -->
<div class="app-integration-libraries">
<div class="app-integration-item" :style="{ animationDelay: '0s' }">
<UiCard padding="xl" class="app-integration-card">
<div class="app-integration-card-header">
<UiIconBox size="lg" color="var(--vp-c-brand-1)">
<Library />
</UiIconBox>
<h3 class="app-integration-title">Libraries</h3>
<p class="app-integration-description">
Run DiceBear entirely in your own code, so no data leaves your
servers. JavaScript, PHP, Python, Rust, Go, and Dart share one
identical API.
</p>
</div>
<Tabs v-model:value="libraryTab" class="app-integration-tabs">
<TabList>
<Tab value="js">JavaScript</Tab>
<Tab value="php">PHP</Tab>
<Tab value="python">Python</Tab>
<Tab value="rust">Rust</Tab>
<Tab value="go">Go</Tab>
<Tab value="dart">Dart</Tab>
</TabList>
<TabPanels>
<TabPanel value="js" class="app-integration-tabpanel">
<UiCode
:code="plainCode.js"
lang="js"
scroll-to-bottom
class="app-integration-code-block"
/>
<Button
as="a"
href="/how-to-use/js-library/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
JS Documentation
<ArrowRight :size="18" />
</Button>
</TabPanel>
<TabPanel value="php" class="app-integration-tabpanel">
<UiCode
:code="plainCode.php"
lang="php"
scroll-to-bottom
class="app-integration-code-block"
/>
<Button
as="a"
href="/how-to-use/php-library/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
PHP Documentation
<ArrowRight :size="18" />
</Button>
</TabPanel>
<TabPanel value="python" class="app-integration-tabpanel">
<UiCode
:code="plainCode.python"
lang="python"
scroll-to-bottom
class="app-integration-code-block"
/>
<Button
as="a"
href="/how-to-use/python-library/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
Python Documentation
<ArrowRight :size="18" />
</Button>
</TabPanel>
<TabPanel value="rust" class="app-integration-tabpanel">
<UiCode
:code="plainCode.rust"
lang="rust"
scroll-to-bottom
class="app-integration-code-block"
/>
<Button
as="a"
href="/how-to-use/rust-library/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
Rust Documentation
<ArrowRight :size="18" />
</Button>
</TabPanel>
<TabPanel value="go" class="app-integration-tabpanel">
<UiCode
:code="plainCode.go"
lang="go"
scroll-to-bottom
class="app-integration-code-block"
/>
<Button
as="a"
href="/how-to-use/go-library/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
Go Documentation
<ArrowRight :size="18" />
</Button>
</TabPanel>
<TabPanel value="dart" class="app-integration-tabpanel">
<UiCode
:code="plainCode.dart"
lang="dart"
scroll-to-bottom
class="app-integration-code-block"
/>
<Button
as="a"
href="/how-to-use/dart-library/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
Dart Documentation
<ArrowRight :size="18" />
</Button>
</TabPanel>
</TabPanels>
</Tabs>
</UiCard>
</div>
</div>
<!-- HTTP API & CLI - Side by Side -->
<div class="app-integration-grid">
<div class="app-integration-item" :style="{ animationDelay: '0.3s' }">
<UiCard padding="xl" class="app-integration-card">
<div class="app-integration-card-header">
<UiIconBox size="lg" color="var(--vp-c-pink-2)">
<Globe />
</UiIconBox>
<h3 class="app-integration-title">Avatar API</h3>
<p class="app-integration-description">
Free avatar API for profile pictures. Handles millions of
requests daily via global CDN.
</p>
</div>
<UiCode :code="plainCode.api" class="app-integration-code-block" />
<Button
as="a"
href="/how-to-use/http-api/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
API Documentation
<ArrowRight :size="18" />
</Button>
</UiCard>
</div>
<div class="app-integration-item" :style="{ animationDelay: '0.45s' }">
<UiCard padding="xl" class="app-integration-card">
<div class="app-integration-card-header">
<UiIconBox size="lg" color="var(--vp-c-brand-1)">
<Terminal />
</UiIconBox>
<h3 class="app-integration-title">CLI</h3>
<p class="app-integration-description">
Generate avatars from the command line. Perfect for scripts and
automation.
</p>
</div>
<UiCode :code="plainCode.cli" class="app-integration-code-block" />
<Button
as="a"
href="/how-to-use/cli/"
severity="secondary"
variant="outlined"
class="app-integration-link"
>
CLI Documentation
<ArrowRight :size="18" />
</Button>
</UiCard>
</div>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-integration {
&-dots {
background-image: radial-gradient(
circle,
var(--vp-c-text-3) 1px,
transparent 1px
);
background-size: 32px 32px;
background-repeat: repeat !important;
opacity: 0.2;
mask-image: radial-gradient(ellipse 70% 50% at 50% 50%, black, transparent);
-webkit-mask-image: radial-gradient(
ellipse 70% 50% at 50% 50%,
black,
transparent
);
}
&-gradient {
background: radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
transparent
);
}
&-header {
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-item {
opacity: 0;
transform: translateY(30px);
.visible & {
animation: reveal-up 0.6s var(--ease-smooth) forwards;
}
}
/* Combined libraries card (language libraries via tabs) */
&-libraries {
margin-bottom: 24px;
}
&-tabs {
// The card already supplies xl padding; drop the panel's own padding so
// the code block and doc link sit flush with the card edges, with a little
// breathing room under the tab bar.
--p-tabs-tabpanel-padding: 16px 0 0;
}
&-tabpanel {
display: flex;
flex-direction: column;
}
/* Grid for HTTP API & CLI */
&-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
> * {
min-width: 0;
}
}
&-card {
height: 100%;
:deep(.ui-card-body) {
display: flex;
flex-direction: column;
height: 100%;
}
&-header {
margin-bottom: 24px;
}
}
&-title {
font-size: 20px;
font-weight: 700;
color: var(--vp-c-text-1);
margin: 16px 0 8px;
}
&-description {
font-size: 15px;
color: var(--vp-c-text-2);
margin: 0;
line-height: 1.6;
}
&-code-block {
flex: 1;
margin-bottom: 20px;
min-height: 48px;
}
&-link {
align-self: flex-start;
}
}
@media (max-width: 1000px) {
.app-integration {
&-grid {
grid-template-columns: 1fr;
max-width: 500px;
margin: 0 auto;
}
}
}
@media (max-width: 640px) {
.app-integration {
&-card {
--ui-card-padding: 24px;
}
}
}
</style>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Star, Heart } from '@lucide/vue';
import { siGithub } from 'simple-icons';
import { kebabCase } from 'change-case';
import Prando from 'prando';
import Button from 'primevue/button';
import {
UiAvatar,
UiHeadline,
UiDescription,
UiContainer,
UiSection,
UiIcon,
} from '../ui';
import { useVisibility } from '../../composables/useVisibility';
import { useAvatarStyleList } from '../../composables/avatar';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef);
const avatarStyleList = useAvatarStyleList();
// Generate scattered avatars around the star CTA
const scatteredAvatars = computed(() => {
const prng = new Prando(555);
const avatars = [];
const seeds = [
'OpenSource',
'Community',
'Stars',
'Love',
'Code',
'Free',
'MIT',
'GitHub',
];
for (let i = 0; i < 8; i++) {
const style = kebabCase(
avatarStyleList.value[i % avatarStyleList.value.length],
);
const angle = (i / 8) * Math.PI * 2;
const radius = 38 + prng.next() * 10;
avatars.push({
style,
seed: seeds[i],
x: 50 + Math.cos(angle) * radius,
y: 50 + Math.sin(angle) * radius,
size: 40 + prng.next() * 24,
delay: i * 0.15,
rotation: prng.next() * 20 - 10,
});
}
return avatars;
});
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider center>
<template #background>
<div class="app-open-source-gradient"></div>
<!-- Scattered avatar decorations -->
<div
v-for="(avatar, index) in scatteredAvatars"
:key="index"
class="app-open-source-bg-avatar"
:style="{
left: `${avatar.x}%`,
top: `${avatar.y}%`,
width: `${avatar.size}px`,
height: `${avatar.size}px`,
'--rotation': `${avatar.rotation}deg`,
animationDelay: `${avatar.delay}s`,
}"
>
<UiAvatar
:style-name="avatar.style"
:style-options="{ seed: avatar.seed, size: 80 }"
:alt="avatar.style"
/>
</div>
</template>
<UiContainer class="app-open-source-container">
<!-- Animated star icon -->
<div class="app-open-source-star-wrapper">
<div class="app-open-source-star-ring"></div>
<div
class="app-open-source-star-ring app-open-source-star-ring-2"
></div>
<div class="app-open-source-star-icon">
<Star :size="36" />
</div>
</div>
<UiHeadline
>Free and <strong>Open Source</strong>.<br />Forever.</UiHeadline
>
<UiDescription class="app-open-source-description">
DiceBear is built in the open. Our core library is MIT licensed, and we
believe in transparent development. Join thousands of developers who
already love DiceBear.
</UiDescription>
<div class="app-open-source-actions">
<Button
as="a"
href="https://github.com/dicebear/dicebear"
target="_blank"
rel="noopener"
size="large"
severity="contrast"
class="app-open-source-star-btn"
>
<UiIcon :path="siGithub.path" :size="20" />
Star on GitHub
</Button>
<Button
as="a"
href="/guides/contribute-to-the-library/"
size="large"
severity="secondary"
variant="outlined"
class="app-open-source-contribute"
>
<Heart :size="20" />
Contribute
</Button>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss">
:root {
--app-open-source-star-color: #f59e0b;
--app-open-source-ring-color: rgba(245, 158, 11, 0.15);
}
.dark {
--app-open-source-star-color: #fbbf24;
--app-open-source-ring-color: rgba(251, 191, 36, 0.12);
}
</style>
<style lang="scss" scoped>
.app-open-source {
&-gradient {
background:
radial-gradient(
ellipse 60% 60% at 50% 50%,
color-mix(in srgb, var(--app-open-source-star-color) 6%, transparent),
transparent
),
radial-gradient(
ellipse 50% 80% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 4%, transparent),
transparent
);
}
&-bg-avatar {
position: absolute;
border-radius: var(--vp-radius-sm);
overflow: hidden;
opacity: 0;
pointer-events: none;
.visible & {
animation: app-open-source-avatar-float 0.6s var(--ease-spring) forwards;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&-container {
opacity: 0;
transform: translateY(40px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
/* Star wrapper with animated rings */
&-star-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 32px;
}
&-star-ring {
position: absolute;
inset: 0;
border: 2px solid var(--app-open-source-ring-color);
border-radius: 50%;
animation: app-open-source-ring-pulse 3s ease-in-out infinite;
&-2 {
inset: -12px;
border-width: 1px;
animation-delay: -1.5s;
}
}
&-star-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--app-open-source-star-color),
#f97316
);
color: white;
box-shadow: 0 8px 24px
color-mix(in srgb, var(--app-open-source-star-color) 40%, transparent);
z-index: 1;
animation: app-open-source-star-bounce 2s ease-in-out infinite;
svg {
fill: currentColor;
}
}
&-description {
margin-bottom: 40px;
max-width: 600px;
}
&-actions {
display: flex;
justify-content: center;
gap: 16px;
}
&-heart-icon {
color: #f43f5e;
}
}
@keyframes app-open-source-ring-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.4;
}
}
@keyframes app-open-source-star-bounce {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-4px) rotate(-3deg);
}
75% {
transform: translateY(2px) rotate(2deg);
}
}
@keyframes app-open-source-avatar-float {
from {
opacity: 0;
transform: scale(0.5) rotate(var(--rotation, 0deg));
}
to {
opacity: 0.08;
transform: scale(1) rotate(var(--rotation, 0deg));
}
}
@media (max-width: 768px) {
.app-open-source {
&-bg-avatar {
display: none;
}
&-actions {
flex-direction: column;
}
}
}
</style>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Star, Heart, Scale, ArrowRight } from '@lucide/vue';
import { siGithub } from 'simple-icons';
import Button from 'primevue/button';
import { UiContainer, UiSection, UiCard, UiIconBox, UiIcon } from '../ui';
import { useVisibility } from '../../composables/useVisibility';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-open-source-cards-gradient"></div>
</template>
<UiContainer>
<div class="app-open-source-cards-grid">
<UiCard padding="2xl" class="app-open-source-cards-opensource-card">
<UiIconBox size="lg" color="var(--vp-c-brand-1)">
<Star />
</UiIconBox>
<h3 class="app-open-source-cards-title">Open Source</h3>
<p class="app-open-source-cards-text">
We believe in open source. All our code is available on GitHub. Feel
free to contribute, fork, or simply use it with confidence.
</p>
<div class="app-open-source-cards-actions">
<Button
as="a"
href="https://github.com/dicebear/dicebear"
target="_blank"
rel="noopener"
severity="contrast"
class="app-open-source-cards-action-btn"
>
<UiIcon :path="siGithub.path" :size="20" />
Star on GitHub
</Button>
<Button
as="a"
href="/guides/contribute-to-the-library/"
severity="secondary"
variant="outlined"
class="app-open-source-cards-action-btn"
>
<Heart />
Contribute
</Button>
</div>
</UiCard>
<UiCard padding="2xl" class="app-open-source-cards-license-card">
<UiIconBox size="lg" color="var(--vp-c-pink-2)">
<Scale />
</UiIconBox>
<h3 class="app-open-source-cards-title">License</h3>
<p class="app-open-source-cards-text">
Our code is MIT licensed. The avatar styles are licensed under
different licenses chosen by the artists. Check out the overview for
details.
</p>
<div class="app-open-source-cards-actions">
<Button
as="a"
href="/licenses/"
severity="secondary"
variant="outlined"
class="app-open-source-cards-action-btn app-open-source-cards-license-btn"
>
License Overview
<ArrowRight :size="20" />
</Button>
</div>
</UiCard>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-open-source-cards {
&-gradient {
background:
radial-gradient(
ellipse 50% 80% at 50% 0%,
color-mix(in srgb, var(--vp-c-yellow-1) 5%, transparent),
transparent
),
radial-gradient(
ellipse 50% 80% at 50% 100%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
);
}
&-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-opensource-card,
&-license-card {
:deep(.ui-card-body) {
display: flex;
flex-direction: column;
height: 100%;
}
}
&-title {
font-size: 24px;
font-weight: 700;
color: var(--vp-c-text-1);
margin: 20px 0 12px;
}
&-text {
font-size: 15px;
color: var(--vp-c-text-2);
line-height: 1.7;
margin: 0 0 32px;
flex: 1;
}
&-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
.app-open-source-cards {
&-grid {
grid-template-columns: 1fr;
}
&-opensource-card,
&-license-card {
--ui-card-padding: 32px 24px;
}
&-actions {
flex-direction: column;
}
&-action-btn {
width: 100%;
}
}
}
</style>
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
import { kebabCase, camelCase, capitalCase } from 'change-case';
import { track } from '@theme/utils/track';
import {
UiHeadline,
UiDescription,
UiContainer,
UiSection,
UiWindow,
} from '../ui';
import { useVisibility } from '../../composables/useVisibility';
import {
useAvatarStyleList,
useAvatarStyleMeta,
} from '../../composables/avatar';
import { formatLicenseName } from '../../utils/format';
import { safeHttpUrl } from '../../utils/url';
import AppSeedDemoCode from './AppSeedDemoCode.vue';
import AppSeedDemoPreview from './AppSeedDemoPreview.vue';
const DEFAULT_STYLE = 'lorelei';
const RANDOM_SEEDS: readonly string[] = [
'Felix',
'Aneka',
'Milo',
'Luna',
'Sophie',
];
const sectionRef = ref();
const isVisible = useVisibility(sectionRef);
const avatarStyleList = useAvatarStyleList();
const availableStyles = computed(() =>
avatarStyleList.value.map((s) => kebabCase(s)),
);
const currentStyle = ref<string>(DEFAULT_STYLE);
const activeStyleName = computed(
() =>
avatarStyleList.value.find((s) => kebabCase(s) === currentStyle.value) ??
DEFAULT_STYLE,
);
const currentStyleCamel = computed(() => camelCase(activeStyleName.value));
const currentStyleDisplay = computed(() => capitalCase(activeStyleName.value));
const currentStyleLink = computed(() => `/styles/${currentStyle.value}/`);
const avatarStyleMeta = useAvatarStyleMeta(activeStyleName);
const seed = ref<string>(RANDOM_SEEDS[0]);
// Never send the seed text itself, only that the user edited it. Debounced so
// typing counts as one interaction.
const trackSeedEdited = useDebounceFn(() => {
track('Home Demo: Seed Edited', { style: currentStyle.value });
}, 700);
function onSeedUpdate(value: string) {
seed.value = value;
trackSeedEdited();
}
function randomizeSeed() {
const currentIndex = RANDOM_SEEDS.indexOf(seed.value);
const nextIndex = (currentIndex + 1) % RANDOM_SEEDS.length;
seed.value = RANDOM_SEEDS[nextIndex];
track('Home Demo: Seed Randomized', { style: currentStyle.value });
}
watch(currentStyle, (style) => {
track('Home Demo: Style Selected', { style });
});
const sourceUrl = computed(() => safeHttpUrl(avatarStyleMeta.value?.source));
const homepageUrl = computed(() =>
safeHttpUrl(avatarStyleMeta.value?.homepage),
);
const licenseUrl = computed(() =>
safeHttpUrl(avatarStyleMeta.value?.license?.url),
);
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-seed-demo-glow"></div>
</template>
<UiContainer class="app-seed-demo-container">
<div class="app-seed-demo-header">
<UiHeadline class="app-seed-demo-title">
Same Seed, Same Avatar.<br />
<strong>Every Time.</strong>
</UiHeadline>
<UiDescription class="app-seed-demo-subtitle">
Use any string as a seed, such as usernames, emails, or IDs, and
DiceBear generates the identical avatar consistently across all
platforms.
</UiDescription>
</div>
<div class="app-seed-demo-window-wrapper">
<UiWindow title="Seed Explorer">
<div class="app-seed-demo-body">
<AppSeedDemoPreview
v-model:style-name="currentStyle"
:styles="availableStyles"
:model-value="seed"
@update:model-value="onSeedUpdate"
@randomize-seed="randomizeSeed"
/>
<AppSeedDemoCode
:seed="seed"
:style="currentStyle"
:style-camel="currentStyleCamel"
/>
</div>
</UiWindow>
<p class="app-seed-demo-license">
<a :href="currentStyleLink">{{ currentStyleDisplay }}</a>
<template v-if="avatarStyleMeta?.creator !== 'DiceBear'">
<template
v-if="
avatarStyleMeta?.license?.name !== 'MIT' &&
avatarStyleMeta?.title
"
>
is a remix of
</template>
<template v-else> is based on </template>
<a
v-if="sourceUrl"
:href="sourceUrl"
target="_blank"
rel="noopener noreferrer"
>{{ avatarStyleMeta?.title ?? 'Design' }}</a
>
<template v-else>{{ avatarStyleMeta?.title ?? 'Design' }}</template>
</template>
by
<a
v-if="homepageUrl"
:href="homepageUrl"
target="_blank"
rel="noopener noreferrer"
>{{ avatarStyleMeta?.creator }}</a
>
<template v-else>{{ avatarStyleMeta?.creator }}</template
>, licensed under
<a
v-if="licenseUrl"
:href="licenseUrl"
target="_blank"
rel="noopener noreferrer"
>{{ formatLicenseName(avatarStyleMeta?.license?.name) }}</a
>
<template v-else>{{
formatLicenseName(avatarStyleMeta?.license?.name)
}}</template
>.
</p>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss">
:root {
--app-seed-demo-avatar-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
.dark {
--app-seed-demo-avatar-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.app-seed-demo {
&-glow {
background:
radial-gradient(
ellipse 60% 60% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 10%, transparent),
transparent
),
radial-gradient(
ellipse 50% 40% at 20% 80%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
),
radial-gradient(
ellipse 50% 40% at 80% 60%,
color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent),
transparent
);
}
&-container {
opacity: 0;
transform: translateY(40px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-header {
text-align: center;
margin-bottom: 48px;
}
&-title {
--hl-display: block;
}
&-subtitle {
max-width: 540px;
}
&-license {
margin-top: 20px;
font-size: 13px;
color: var(--vp-c-text-3);
text-align: center;
a {
color: color-mix(in srgb, var(--vp-c-text-2) 50%, var(--vp-c-text-3));
font-weight: 400;
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: var(--vp-c-border);
transition: color var(--duration-fast) ease;
&:hover {
color: var(--vp-c-brand-1);
}
}
}
&-control {
width: 220px;
}
&-seed-field {
.p-inputtext {
width: 100%;
}
}
&-dice-icon {
cursor: pointer;
color: var(--vp-c-text-3);
transition: color var(--duration-fast) ease;
outline: none;
user-select: none;
-webkit-user-select: none;
svg {
transition: transform var(--duration-mid) var(--ease-spring);
}
&:hover {
color: var(--vp-c-brand-1);
svg {
transform: rotate(90deg);
}
}
&:active svg {
transform: rotate(180deg) scale(0.9);
}
&:focus-visible {
color: var(--vp-c-brand-1);
}
}
&-body {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 420px;
}
&-showcase {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px 40px;
gap: 10px;
background: radial-gradient(
ellipse 70% 60% at 50% 40%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
);
}
&-avatar-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
&-avatar-glow {
position: absolute;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--vp-c-brand-1) 15%, transparent),
transparent 70%
);
animation: app-seed-demo-glow-pulse 3s ease-in-out infinite;
}
&-avatar-main {
width: 148px;
height: 148px;
border-radius: var(--vp-radius-xl);
margin-bottom: 24px;
position: relative;
z-index: 1;
box-shadow: var(--app-seed-demo-avatar-shadow);
transition: transform var(--duration-mid) var(--ease-smooth);
&:hover {
transform: scale(1.05);
}
}
}
@keyframes app-seed-demo-glow-pulse {
0%,
100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.15);
opacity: 1;
}
}
@media (max-width: 768px) {
.app-seed-demo {
&-body {
grid-template-columns: 1fr;
}
&-showcase {
padding: 32px 16px 24px;
}
&-avatar-main {
width: 88px;
height: 88px;
border-radius: var(--vp-radius-md);
}
&-avatar-glow {
width: 110px;
height: 110px;
}
}
}
@media (max-width: 480px) {
.app-seed-demo {
&-showcase {
padding: 24px 12px 20px;
gap: 16px;
}
&-avatar-main {
width: 80px;
height: 80px;
border-radius: var(--vp-radius-md);
}
&-avatar-glow {
width: 100px;
height: 100px;
}
}
}
</style>
@@ -0,0 +1,281 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { PawPrint } from '@lucide/vue';
import Button from 'primevue/button';
import Select from 'primevue/select';
import { UiCode } from '../ui';
import { escapeJsString, escapeShellArg } from '../../utils/escape';
import { formatDartValue } from '@theme/utils/code-examples';
type CodeExample =
| 'api'
| 'js'
| 'php'
| 'python'
| 'rust'
| 'go'
| 'dart'
| 'cli';
const props = defineProps<{
seed: string;
style: string;
styleCamel: string;
}>();
const activeExample = ref<CodeExample>('api');
const exampleOptions: { label: string; value: CodeExample }[] = [
{ label: 'HTTP API', value: 'api' },
{ label: 'JS Library', value: 'js' },
{ label: 'PHP Library', value: 'php' },
{ label: 'Python Library', value: 'python' },
{ label: 'Rust Library', value: 'rust' },
{ label: 'Go Library', value: 'go' },
{ label: 'Dart Library', value: 'dart' },
{ label: 'CLI', value: 'cli' },
];
const apiExample = computed(
() =>
`https://api.dicebear.com/10.x/${props.style}/svg?seed=${encodeURIComponent(props.seed)}`,
);
const jsExample = computed(
() =>
`import { Style, Avatar } from '@dicebear/core';
import definition from '@dicebear/styles/${props.style}.json' with { type: 'json' };
const style = new Style(definition);
const avatar = new Avatar(style, {
seed: '${escapeJsString(props.seed)}'
});`,
);
const phpExample = computed(
() =>
`<?php
use Composer\\InstalledVersions;
use DiceBear\\Style;
use DiceBear\\Avatar;
$basePath = InstalledVersions::getInstallPath('dicebear/styles');
$style = Style::fromJson(file_get_contents($basePath . '/src/${props.style}.json'));
$avatar = new Avatar($style, [
'seed' => '${escapeJsString(props.seed)}'
]);`,
);
const pythonExample = computed(
() =>
`from importlib.resources import files
from dicebear import Avatar, Style
style = Style.from_json(
files("dicebear_styles").joinpath("${props.style}.json").read_text("utf-8")
)
avatar = Avatar(style, {
"seed": "${escapeJsString(props.seed)}"
})`,
);
const rustExample = computed(
() =>
`use dicebear_core::{Avatar, Style};
use serde_json::json;
let style = Style::from_str(dicebear_styles::${props.style.toUpperCase().replace(/-/g, '_')})?;
let avatar = Avatar::new(&style, json!({
"seed": "${escapeJsString(props.seed)}"
}))?;`,
);
const goExample = computed(() => {
// The Go styles module exports each style as a PascalCase variable
// (e.g. "big-ears" → BigEars).
const styleConst = props.style
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
return `import (
dicebear "github.com/dicebear/dicebear-go/v10"
"github.com/dicebear/styles/v10"
)
style, _ := dicebear.NewStyle([]byte(styles.${styleConst}))
avatar, _ := dicebear.NewAvatar(style, map[string]any{
"seed": "${escapeJsString(props.seed)}",
})`;
});
const dartExample = computed(
() =>
// formatDartValue quotes the seed and escapes $, which escapeJsString
// leaves alone but Dart would treat as string interpolation.
`import 'package:dicebear_core/dicebear_core.dart';
import 'package:dicebear_styles/${props.style.replace(/-/g, '_')}.dart';
final style = Style.parse(${props.styleCamel});
final avatar = Avatar(style, {
'seed': ${formatDartValue(props.seed)},
});`,
);
const cliExample = computed(
() => `npx dicebear ${props.style} --seed '${escapeShellArg(props.seed)}'`,
);
const playgroundLink = '/playground/';
</script>
<template>
<div class="app-seed-demo-code">
<div class="app-seed-demo-code-wrapper">
<Select
v-model="activeExample"
:options="exampleOptions"
option-label="label"
option-value="value"
size="large"
fluid
aria-label="Code example"
class="app-seed-demo-code-select"
/>
<div class="app-seed-demo-code-body">
<UiCode
:code="apiExample"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'api' }"
/>
<UiCode
:code="jsExample"
lang="js"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'js' }"
/>
<UiCode
:code="phpExample"
lang="php"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'php' }"
/>
<UiCode
:code="pythonExample"
lang="python"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'python' }"
/>
<UiCode
:code="rustExample"
lang="rust"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'rust' }"
/>
<UiCode
:code="goExample"
lang="go"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'go' }"
/>
<UiCode
:code="dartExample"
lang="dart"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'dart' }"
/>
<UiCode
:code="cliExample"
scroll-to-bottom
class="app-seed-demo-code-block"
:class="{ active: activeExample === 'cli' }"
/>
</div>
</div>
<div class="app-seed-demo-code-cta">
<Button
as="a"
:href="playgroundLink"
size="large"
severity="contrast"
class="app-seed-demo-code-cta-btn"
>
<PawPrint :size="20" />
Open Playground
</Button>
</div>
</div>
</template>
<style lang="scss" scoped>
.app-seed-demo-code {
display: flex;
flex-direction: column;
border-left: 1px solid var(--ui-window-divider-color);
min-width: 0;
// Align the code block surface with the example select above it
// (both sit on --vp-c-bg inside this editor window mockup).
--vp-code-block-bg: var(--vp-c-bg);
&-wrapper {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
&-select {
// size="large" renders the label at 18px; keep the large, full-width
// control but bring its text in line with the other demo controls (16px).
:deep(.p-select-label) {
font-size: 16px;
}
}
&-body {
flex: 1;
display: grid;
min-width: 0;
}
&-block {
grid-area: 1 / 1;
visibility: hidden;
&.active {
visibility: visible;
}
}
&-cta {
padding: 0 16px 16px;
&-btn {
width: 100%;
justify-content: center;
}
}
}
@media (max-width: 768px) {
.app-seed-demo-code {
border-left: none;
border-top: 1px solid var(--ui-window-divider-color);
}
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Dice5 } from '@lucide/vue';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import InputText from 'primevue/inputtext';
import { UiAvatar, UiStyleSelect } from '../ui';
defineProps<{
styles: string[];
}>();
const seed = defineModel<string>({ required: true });
const styleName = defineModel<string>('styleName', { required: true });
const emit = defineEmits<{
randomizeSeed: [];
}>();
const mainAvatarLink = computed(
() =>
`https://api.dicebear.com/10.x/${styleName.value}/svg?seed=${encodeURIComponent(seed.value)}`,
);
</script>
<template>
<div class="app-seed-demo-showcase">
<div class="app-seed-demo-avatar-stage">
<div class="app-seed-demo-avatar-glow"></div>
<a :href="mainAvatarLink" target="_blank" rel="noopener">
<UiAvatar
:style-name="styleName"
:style-options="{ seed, size: 256 }"
alt="Avatar preview"
class="app-seed-demo-avatar-main"
mode="library"
/>
</a>
</div>
<UiStyleSelect
v-model="styleName"
:styles="styles"
:seed="seed"
:value-avatar-size="20"
aria-label="Avatar style"
class="app-seed-demo-control"
/>
<IconField class="app-seed-demo-control app-seed-demo-seed-field">
<InputText
id="app-seed-demo-seed"
v-model="seed"
placeholder="Seed"
aria-label="Seed"
spellcheck="false"
autocomplete="off"
/>
<InputIcon
class="app-seed-demo-dice-icon"
role="button"
tabindex="0"
aria-label="Next seed"
title="Next seed"
@click="emit('randomizeSeed')"
@keydown.enter.prevent="emit('randomizeSeed')"
@keydown.space.prevent="emit('randomizeSeed')"
>
<Dice5 :size="16" />
</InputIcon>
</IconField>
</div>
</template>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { ref, useSlots, computed } from 'vue';
import { PawPrint, ArrowRight } from '@lucide/vue';
import Button from 'primevue/button';
import { UiHeadline, UiDescription, UiContainer, UiSection } from '../ui';
import { useVisibility } from '../../composables/useVisibility';
withDefaults(
defineProps<{
headline?: string;
description?: string;
}>(),
{
headline: 'Avatars That Stand Out',
description:
'DiceBear is an open source avatar library that lets you generate unique, deterministic profile pictures in no time. Whether you need geometric shapes, cute characters, or pixel art, our privacy-focused SVG avatar library gives you 35+ styles to choose from.',
},
);
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.1 });
const slots = useSlots();
const hasAside = computed(() => !!slots.aside);
const hasActions = computed(() => !!slots.actions);
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }">
<template #background>
<div class="app-small-hero-gradient"></div>
</template>
<UiContainer
class="app-small-hero-container"
:class="{ 'app-small-hero-container--has-aside': hasAside }"
>
<div :class="{ 'app-small-hero-layout': hasAside }">
<div>
<UiHeadline tag="h1">
<slot name="headline">{{ headline }}</slot>
</UiHeadline>
<UiDescription class="app-small-hero-description">
<slot name="description">{{ description }}</slot>
</UiDescription>
<div v-if="hasActions" class="app-small-hero-actions">
<slot name="actions" />
</div>
<div v-else class="app-small-hero-actions">
<Button
as="a"
href="/playground/"
size="large"
severity="contrast"
class="app-small-hero-action-btn"
>
<PawPrint :size="20" />
Try Playground
</Button>
<Button
as="a"
href="/styles/"
size="large"
severity="secondary"
variant="outlined"
class="app-small-hero-action-btn"
>
Browse Styles
<ArrowRight :size="20" />
</Button>
</div>
<slot name="below-actions" />
</div>
<div v-if="hasAside" class="app-small-hero-aside">
<slot name="aside" />
</div>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-small-hero {
&-gradient {
background:
radial-gradient(
ellipse 80% 50% at 50% -20%,
rgba(14, 165, 233, 0.12),
transparent
),
radial-gradient(
ellipse 60% 40% at 80% 50%,
rgba(124, 58, 237, 0.08),
transparent
),
radial-gradient(
ellipse 60% 40% at 20% 80%,
rgba(20, 184, 166, 0.06),
transparent
);
&::before,
&::after {
content: '';
position: absolute;
border-radius: 50%;
pointer-events: none;
}
&::before {
width: 400px;
height: 400px;
top: -10%;
right: -5%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent) 0%,
transparent 70%
);
animation: shape-float 18s ease-in-out infinite;
}
&::after {
width: 300px;
height: 300px;
bottom: -5%;
left: -3%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent) 0%,
transparent 70%
);
animation: shape-float 22s ease-in-out infinite reverse;
}
}
&-container {
text-align: center;
opacity: 0;
transform: translateY(30px);
animation: fade-up var(--duration-reveal) var(--ease-smooth) both;
.visible & {
opacity: 1;
transform: translateY(0);
}
&--has-aside {
text-align: left;
.app-small-hero-description {
margin-inline: 0;
}
.app-small-hero-actions {
justify-content: flex-start;
}
.app-small-hero-avatars {
justify-content: flex-start;
}
}
}
&-layout {
display: grid;
grid-template-columns: repeat(12, 1fr);
align-items: center;
> :first-child {
grid-column: 1 / 7;
}
> :last-child {
grid-column: 7 / 13;
}
}
&-aside {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
&-description {
margin-bottom: 40px;
max-width: 550px;
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.1s both;
}
&-actions {
display: flex;
justify-content: center;
gap: 16px;
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.2s both;
}
&-avatars {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.3s both;
}
&-avatar {
width: 72px;
height: 72px;
border-radius: var(--vp-radius-md);
overflow: hidden;
opacity: 0;
transform: translateY(20px) scale(0.9);
transition: all var(--duration-mid) var(--ease-spring);
&::after {
display: none !important;
}
.visible & {
animation: app-small-hero-avatar-reveal 0.5s var(--ease-spring) forwards;
}
&:hover {
transform: translateY(-8px) scale(1.1);
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.2);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
@keyframes app-small-hero-avatar-reveal {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 768px) {
.app-small-hero {
&-gradient {
&::before {
width: 200px;
height: 200px;
}
&::after {
width: 150px;
height: 150px;
}
}
&-layout {
grid-template-columns: 1fr;
gap: 48px;
> :first-child,
> :last-child {
grid-column: auto;
}
}
&-container--has-aside {
text-align: center;
.app-small-hero-description {
margin-inline: auto;
}
.app-small-hero-actions {
justify-content: center;
}
.app-small-hero-avatars {
justify-content: center;
}
}
&-actions {
flex-direction: column;
align-items: center;
.app-small-hero-action-btn {
width: 100%;
max-width: 280px;
}
}
&-avatar {
width: 56px;
height: 56px;
border-radius: var(--vp-radius-sm);
}
}
}
</style>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ArrowRight, ChartNoAxesCombined } from '@lucide/vue';
import Button from 'primevue/button';
import { UiContainer, UiSection, UiSectionHeader } from '../ui';
import AppStatsBannerCard from './AppStatsBannerCard.vue';
import { useVisibility } from '../../composables/useVisibility';
import { useApiStats, useApiStatsRaw } from '../../composables/useApiStats';
import { formatNumberParts, formatBytesParts } from '../../utils/format';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
const apiStats = useApiStats();
const apiStatsRaw = useApiStatsRaw();
const monthLabel = computed(
() => apiStats.value?.monthLabel ?? 'the past month',
);
const requests = computed(() =>
apiStats.value
? formatNumberParts(apiStats.value.monthlyRequests)
: { value: '1', unit: 'B' },
);
const traffic = computed(() =>
apiStats.value
? formatBytesParts(apiStats.value.monthlyTraffic)
: { value: '3', unit: 'TB' },
);
const downloads = computed(() =>
apiStats.value
? formatNumberParts(apiStats.value.monthlyNpmDownloads)
: { value: '500', unit: 'K' },
);
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-stats-banner-gradient"></div>
</template>
<UiContainer>
<UiSectionHeader
class="app-stats-banner-header"
description="Usage data from our HTTP-API and npm packages, updated weekly."
>
<template #headline
>Billions of avatars. <strong>One API.</strong></template
>
</UiSectionHeader>
<div class="app-stats-banner-grid">
<AppStatsBannerCard
href="/stats/"
title="API Requests"
:description="`Avatars generated via the HTTP-API in ${monthLabel}.`"
:value="requests.value"
:unit="requests.unit"
:daily="apiStatsRaw?.requests"
/>
<AppStatsBannerCard
href="/stats/"
feature
title="Data Served"
:description="`Total traffic delivered through our global CDN in ${monthLabel}.`"
:value="traffic.value"
:unit="traffic.unit"
:daily="apiStatsRaw?.traffic"
/>
<AppStatsBannerCard
href="/stats/"
title="npm Downloads"
:description="`Total downloads from the npm registry in ${monthLabel}.`"
:value="downloads.value"
:unit="downloads.unit"
:daily="apiStatsRaw?.downloads.npm"
/>
</div>
<div class="app-stats-banner-action">
<Button
as="a"
href="/stats/"
size="large"
severity="secondary"
variant="outlined"
>
<ChartNoAxesCombined :size="20" />
View Full Statistics
<ArrowRight :size="18" />
</Button>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-stats-banner {
&-gradient {
background:
radial-gradient(
ellipse 50% 50% at 50% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
),
radial-gradient(
ellipse 50% 50% at 50% 100%,
color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent),
transparent
);
}
&-header {
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
&-action {
display: flex;
justify-content: center;
margin-top: 40px;
opacity: 0;
transform: translateY(20px);
transition: all var(--duration-reveal) var(--ease-smooth) 0.3s;
.visible & {
opacity: 1;
transform: translateY(0);
}
}
}
@media (max-width: 1000px) {
.app-stats-banner-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.app-stats-banner-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, useId } from 'vue';
import { UiCard } from '../ui';
import {
aggregateMonthly,
formatMonthKey,
type MonthlyEntry,
} from '../../composables/useApiStats';
interface Chart {
line: string;
area: string;
points: Array<{ x: number; y: number }>;
labels: string[];
}
interface Trend {
pct: string;
prevMonth: string;
}
const HISTORY_MONTHS = 4;
const props = defineProps<{
href: string;
feature?: boolean;
value: string;
unit: string;
title: string;
description: string;
daily?: Record<string, number>;
}>();
const fillId = useId();
const history = computed<MonthlyEntry[]>(() => {
if (!props.daily) {
return [];
}
return aggregateMonthly(props.daily).slice(0, -1).slice(-HISTORY_MONTHS);
});
// Anchored at 0 so a low month reads as "fewer", not as a phantom drop to zero.
const chart = computed<Chart>(() => {
const empty: Chart = { line: '', area: '', points: [], labels: [] };
const h = history.value;
if (h.length < 2) {
return empty;
}
const max = Math.max(...h.map((m) => m.total)) || 1;
const stepX = 200 / (h.length - 1);
const points = h.map((m, i) => ({
x: i * stepX,
y: 50 - (m.total / max) * 36,
}));
const line = points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
.join(' ');
const last = points[points.length - 1];
const area = `${line} L${last.x.toFixed(1)},60 L0,60 Z`;
return {
line,
area,
points,
labels: h.map((m) =>
formatMonthKey(m.key, { month: 'short' }).toUpperCase(),
),
};
});
// Positive-only by design: the pill hides on missing data or non-positive change.
const trend = computed<Trend | null>(() => {
const h = history.value;
if (h.length < 2) {
return null;
}
const last = h[h.length - 1];
const prev = h[h.length - 2];
if (prev.total === 0 || last.total <= prev.total) {
return null;
}
return {
pct: (((last.total - prev.total) / prev.total) * 100).toFixed(1),
prevMonth: formatMonthKey(prev.key, { month: 'long' }),
};
});
</script>
<template>
<UiCard
padding="xl"
:href="href"
:class="['st-card', feature && 'st-card-feature']"
>
<div class="st-stat">
{{ value }}<span class="st-unit">{{ unit }}</span>
</div>
<div class="st-meta">
<strong>{{ title }}</strong>
<span>{{ description }}</span>
</div>
<div class="st-trend">
<span v-if="trend" class="st-trend-pill">
{{ trend.pct }}% vs {{ trend.prevMonth }}
</span>
<div v-if="chart.points.length" class="st-chart">
<svg viewBox="0 0 200 60" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient :id="fillId" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0"
stop-color="var(--vp-c-brand-1)"
stop-opacity=".25"
/>
<stop
offset="1"
stop-color="var(--vp-c-brand-1)"
stop-opacity="0"
/>
</linearGradient>
</defs>
<path :d="chart.area" :fill="`url(#${fillId})`" />
<path
:d="chart.line"
fill="none"
stroke="var(--vp-c-brand-1)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="st-chart-dots">
<span
v-for="(p, i) in chart.points"
:key="i"
:style="{
left: `${(p.x / 200) * 100}%`,
top: `${(p.y / 60) * 100}%`,
}"
/>
</div>
<div class="st-chart-labels">
<span v-for="label in chart.labels" :key="label">{{ label }}</span>
</div>
</div>
</div>
</UiCard>
</template>
<style lang="scss" scoped>
.st-card {
min-height: 320px;
:deep(.ui-card-body) {
height: 100%;
display: flex;
flex-direction: column;
gap: 24px;
}
// Use compound selector to beat UiCard's `.ui-card` rule in cascade
// (both selectors otherwise have identical specificity).
&.st-card-feature {
background: linear-gradient(
160deg,
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
var(--vp-c-bg-elv) 50%
);
border-color: color-mix(in srgb, var(--vp-c-brand-1) 40%, transparent);
}
}
.st-stat {
font-size: 84px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.045em;
color: var(--vp-c-text-1);
font-variant-numeric: tabular-nums;
}
.st-unit {
font-size: 44px;
font-weight: 700;
color: var(--vp-c-brand-1);
margin-left: 4px;
letter-spacing: -0.02em;
}
.st-meta {
display: flex;
flex-direction: column;
gap: 4px;
strong {
font-size: 17px;
font-weight: 800;
letter-spacing: -0.015em;
color: var(--vp-c-text-1);
}
span {
font-size: 13.5px;
line-height: 1.5;
color: var(--vp-c-text-2);
max-width: 260px;
}
}
.st-trend {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.st-trend-pill {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: color-mix(in srgb, var(--vp-c-green-1) 16%, transparent);
color: var(--vp-c-green-1);
}
.st-chart {
position: relative;
height: 80px;
svg {
width: 100%;
height: 60px;
display: block;
}
}
.st-chart-dots {
position: absolute;
inset: 0 0 20px 0;
pointer-events: none;
span {
position: absolute;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--vp-c-brand-1);
transform: translate(-50%, -50%);
box-shadow: 0 0 0 2px var(--vp-c-bg-elv);
}
}
.st-chart-labels {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-family: var(--vp-font-family-mono);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--vp-c-text-3);
}
@media (max-width: 640px) {
.st-stat {
font-size: 64px;
}
.st-unit {
font-size: 34px;
}
}
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
} from 'chart.js';
import { useChartTheme } from '../../composables/useChartTheme';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
);
const props = defineProps<{
labels: string[];
values: number[];
color: string;
formatValue: (value: number) => string;
}>();
const { chartKey, tooltipConfig, gridColor, tickColor } = useChartTheme();
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
data: props.values,
borderColor: props.color,
backgroundColor: props.color + '18',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHitRadius: 10,
pointHoverRadius: 4,
pointHoverBackgroundColor: props.color,
},
],
}));
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
},
plugins: {
legend: {
display: false,
},
tooltip: {
...tooltipConfig(),
displayColors: false,
callbacks: {
label: (ctx: any) => props.formatValue(ctx.parsed.y),
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: tickColor(),
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8,
font: { size: 12 },
},
border: {
display: false,
},
},
y: {
grid: {
color: gridColor(),
},
ticks: {
color: tickColor(),
callback: (value: any) => props.formatValue(value),
font: { size: 12 },
},
border: {
display: false,
},
},
},
}));
</script>
<template>
<div class="app-stats-chart">
<Line :key="chartKey" :data="chartData" :options="chartOptions" />
</div>
</template>
<style lang="scss" scoped>
.app-stats-chart {
height: 300px;
position: relative;
}
@media (max-width: 640px) {
.app-stats-chart {
height: 220px;
}
}
</style>
@@ -0,0 +1,569 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useData } from 'vitepress';
import { useVisibility } from '@theme/composables/useVisibility';
import {
buildLandPoints,
createPreloadBuffer,
type PreloadBuffer,
} from '@theme/utils/globeLandPoints';
const props = defineProps<{
rate: number;
}>();
const { isDark } = useData();
const containerRef = ref<HTMLDivElement>();
const globeRef = ref<HTMLDivElement>();
const isVisible = useVisibility(globeRef, { once: false, threshold: 0.1 });
let globe: any = null;
let eventInterval: ReturnType<typeof setInterval> | null = null;
let resizeObserver: ResizeObserver | null = null;
let rotationFrame: number | null = null;
const apiBase = 'https://api.dicebear.com/10.x';
const avatarStyles = [
'thumbs',
'shapes',
'lorelei',
'pixel-art',
'adventurer',
'bottts',
'avataaars',
'notionists',
];
interface GlobeEvent {
id: number;
lat: number;
lng: number;
url: string;
}
let countriesGeoJson: any = null;
let events: GlobeEvent[] = [];
let eventId = 0;
const elementMap = new Map<number, HTMLElement>();
const pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
let landPoints: [number, number][] = [];
let preloadBuffer: PreloadBuffer | null = null;
function generateAvatarUrl() {
const style = avatarStyles[Math.floor(Math.random() * avatarStyles.length)];
const seed = Math.random().toString(36).slice(2, 8);
return `${apiBase}/${style}/svg?seed=${seed}&size=64`;
}
const MIN_DISTANCE_DEG = 12;
function pickLandPoint(): [number, number] {
// Try to find a point far enough from active events
for (let attempt = 0; attempt < 30; attempt++) {
const pt = landPoints[Math.floor(Math.random() * landPoints.length)];
let tooClose = false;
for (const e of events) {
if (
Math.abs(pt[0] - e.lat) < MIN_DISTANCE_DEG &&
Math.abs(pt[1] - e.lng) < MIN_DISTANCE_DEG
) {
tooClose = true;
break;
}
}
if (!tooClose) return pt;
}
// Fallback: pick any random land point with slight offset
const base = landPoints[Math.floor(Math.random() * landPoints.length)];
return [
base[0] + (Math.random() - 0.5) * 10,
base[1] + (Math.random() - 0.5) * 10,
];
}
function syncGlobe() {
if (globe) globe.htmlElementsData([...events]);
}
function addEvent() {
const city = pickLandPoint();
const url = preloadBuffer
? preloadBuffer.getPreloadedUrl()
: generateAvatarUrl();
const ev: GlobeEvent = {
id: eventId++,
lat: city[0],
lng: city[1],
url,
};
events.push(ev);
syncGlobe();
const t1 = setTimeout(() => {
const el = elementMap.get(ev.id);
if (el) el.classList.add('app-stats-globe-bubble--leaving');
const t2 = setTimeout(() => {
events = events.filter((e) => e !== ev);
elementMap.delete(ev.id);
syncGlobe();
}, 600);
pendingTimeouts.push(t2);
}, 8000);
pendingTimeouts.push(t1);
}
function createAvatarElement(d: any) {
const outer = document.createElement('div');
outer.className = 'app-stats-globe-marker';
outer.dataset.lat = String(d.lat);
outer.dataset.lng = String(d.lng);
const inner = document.createElement('div');
inner.className = 'app-stats-globe-bubble';
const ring = document.createElement('span');
ring.className = 'app-stats-globe-bubble-ring';
const disc = document.createElement('span');
disc.className = 'app-stats-globe-bubble-disc';
const img = document.createElement('img');
img.src = d.url;
img.alt = '';
img.className = 'app-stats-globe-bubble-img';
disc.appendChild(img);
inner.appendChild(ring);
inner.appendChild(disc);
outer.appendChild(inner);
elementMap.set(d.id, inner);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
inner.classList.add('app-stats-globe-bubble--visible');
});
});
return outer;
}
// Three.js imports cached after first init
let threeModule: any = null;
async function initGlobe() {
if (!containerRef.value) return;
const { default: Globe } = await import('globe.gl');
threeModule = await import('three');
const container = containerRef.value;
const width = container.clientWidth;
const height = container.clientHeight;
const dark = isDark.value;
globe = new Globe(container, {
rendererConfig: { antialias: true, alpha: true },
})
.width(width)
.height(height)
.backgroundColor('rgba(0,0,0,0)')
.showAtmosphere(true)
.atmosphereColor(
dark ? 'rgba(56, 189, 248, 0.2)' : 'rgba(2, 132, 199, 0.1)',
)
.atmosphereAltitude(0.14)
.globeImageUrl(null as any)
.htmlElementsData([...events])
.htmlElement((d: any) => createAvatarElement(d))
.htmlLat((d: any) => d.lat)
.htmlLng((d: any) => d.lng)
.htmlAltitude(0.015)
.htmlTransitionDuration(0)
.htmlElementVisibilityModifier((el: HTMLElement, isVisible: boolean) => {
const inner = el.firstElementChild as HTMLElement;
if (!inner) return;
if (isVisible) {
inner.classList.remove('app-stats-globe-bubble--hidden');
// Scale based on angular distance to camera center
const lat = Number(el.dataset.lat);
const lng = Number(el.dataset.lng);
const pov = globe.pointOfView();
const toRad = Math.PI / 180;
const dLat = (lat - pov.lat) * toRad;
const dLng = (lng - pov.lng) * toRad;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat * toRad) *
Math.cos(pov.lat * toRad) *
Math.sin(dLng / 2) ** 2;
const angDist = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
// 0 = center → scale 1.0, PI/2 = edge → scale 0.55
const scale = 0.55 + 0.45 * Math.max(0, 1 - angDist / (Math.PI / 2));
inner.style.setProperty('--bubble-scale', scale.toFixed(2));
} else {
inner.classList.add('app-stats-globe-bubble--hidden');
}
})
.enablePointerInteraction(false);
// Land polygons
if (!countriesGeoJson) {
try {
const res = await fetch('/ne_110m_land.geojson');
countriesGeoJson = await res.json();
} catch {
/* ignore */
}
}
if (landPoints.length === 0) {
landPoints = buildLandPoints(countriesGeoJson);
}
if (countriesGeoJson) {
globe
.hexPolygonsData(countriesGeoJson.features)
.hexPolygonResolution(3)
.hexPolygonMargin(0.35)
.hexPolygonUseDots(true)
.hexPolygonColor(
dark ? 'rgba(56, 189, 248, 0.65)' : 'rgba(2, 132, 199, 0.28)',
)
.hexPolygonAltitude(0.006);
}
// Apply material + lights for current theme
applyTheme();
// Camera
globe.pointOfView({ lat: 30, lng: 20, altitude: 1.8 });
const controls = globe.controls();
controls.autoRotate = false;
controls.enableZoom = false;
controls.enablePan = false;
controls.enableRotate = false;
globe.renderer().domElement.style.cursor = 'default';
const scene = globe.scene();
if (scene) scene.background = null;
resizeObserver = new ResizeObserver(() => {
if (!globe || !containerRef.value) return;
const w = containerRef.value.clientWidth;
const h = containerRef.value.clientHeight;
globe.width(w).height(h);
});
resizeObserver.observe(container);
}
function applyTheme() {
if (!globe || !threeModule) return;
const dark = isDark.value;
const { MeshPhongMaterial, Color, AmbientLight, DirectionalLight } =
threeModule;
// Update atmosphere
globe.atmosphereColor(
dark ? 'rgba(56, 189, 248, 0.2)' : 'rgba(2, 132, 199, 0.1)',
);
// Update hex polygon colors: pass string directly, no per-polygon closure needed
globe.hexPolygonColor(
dark ? 'rgba(56, 189, 248, 0.65)' : 'rgba(2, 132, 199, 0.28)',
);
// Dispose old material before creating a new one to free GPU resources
const oldMat = globe.globeMaterial();
if (oldMat && typeof oldMat.dispose === 'function') oldMat.dispose();
const globeMat = new MeshPhongMaterial({
color: new Color(dark ? '#2a3050' : '#e4ecf6'),
shininess: dark ? 20 : 25,
specular: new Color(dark ? '#446688' : '#d0e0f0'),
emissive: new Color(dark ? '#283248' : '#d4e2f0'),
emissiveIntensity: dark ? 0.6 : 0.35,
transparent: true,
opacity: dark ? 0.88 : 0.95,
});
globe.globeMaterial(globeMat);
// Dispose old lights before creating new ones
const oldLights = globe.lights();
if (oldLights) oldLights.forEach((l: any) => l.dispose?.());
const ambient = new AmbientLight(
dark ? 0xffffff : 0xf0f4fa,
dark ? 1.6 : 1.4,
);
const dir = new DirectionalLight(
dark ? 0x6699cc : 0xf4f8ff,
dark ? 1.0 : 0.5,
);
dir.position.set(5, 3, 5);
globe.lights([ambient, dir]);
}
function startEvents() {
if (eventInterval) return;
if (landPoints.length === 0) {
console.warn(
'[AppStatsGlobe] No land points available, skipping avatar events.',
);
return;
}
for (let i = 0; i < 8; i++) {
pendingTimeouts.push(setTimeout(addEvent, i * 250));
}
eventInterval = setInterval(addEvent, 800);
}
function stopEvents() {
if (eventInterval) {
clearInterval(eventInterval);
eventInterval = null;
}
pendingTimeouts.forEach(clearTimeout);
pendingTimeouts.length = 0;
}
function startRotation() {
if (rotationFrame !== null || !globe) return;
const pov = globe.pointOfView();
let rotLng = pov.lng;
const ROT_SPEED = 0.05;
const TILT = 15;
const BASE_LAT = 35;
const TILT_FREQ = 1.3;
function animateRotation() {
rotLng += ROT_SPEED;
const lat =
BASE_LAT + TILT * Math.sin((rotLng * TILT_FREQ * Math.PI) / 180);
globe.pointOfView({ lat, lng: rotLng % 360, altitude: 1.8 }, 0);
rotationFrame = requestAnimationFrame(animateRotation);
}
rotationFrame = requestAnimationFrame(animateRotation);
}
function stopRotation() {
if (rotationFrame !== null) {
cancelAnimationFrame(rotationFrame);
rotationFrame = null;
}
}
onMounted(async () => {
preloadBuffer = createPreloadBuffer(generateAvatarUrl);
await initGlobe();
if (isVisible.value) {
startEvents();
startRotation();
}
});
watch(isVisible, (visible) => {
if (visible) {
startEvents();
startRotation();
} else {
stopEvents();
stopRotation();
}
});
watch(isDark, () => {
applyTheme();
});
onUnmounted(() => {
stopEvents();
stopRotation();
events = [];
eventId = 0;
elementMap.clear();
preloadBuffer = null;
if (globe) {
globe._destructor();
globe = null;
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
const formattedRate = (r: number) => {
if (r >= 1000) return `~${(r / 1000).toFixed(1)}k`;
return `~${r.toFixed(0)}`;
};
</script>
<template>
<div ref="globeRef" class="app-stats-globe">
<div ref="containerRef" class="app-stats-globe-wrap">
<div class="app-stats-globe-ring" />
</div>
<p class="app-stats-globe-caption">
{{ formattedRate(props.rate) }} avatars generated every second, worldwide
</p>
</div>
</template>
<style lang="scss" scoped>
.app-stats-globe {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
&-wrap {
position: relative;
width: 100%;
aspect-ratio: 1;
pointer-events: none;
overflow: hidden;
:deep(canvas) {
width: 100% !important;
height: 100% !important;
}
}
&-ring {
position: absolute;
top: 50%;
left: 50%;
width: 54.5%;
aspect-ratio: 1;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1.5px solid rgba(2, 132, 199, 0.2);
pointer-events: none;
z-index: 1;
.dark & {
border-color: rgba(56, 189, 248, 0.3);
}
}
&-caption {
font-size: 13px;
color: var(--vp-c-text-3);
text-align: center;
line-height: 1.4;
margin: 0;
}
}
@media (max-width: 768px) {
.app-stats-globe {
&-wrap {
max-width: 320px;
margin-inline: auto;
}
}
}
</style>
<style lang="scss">
.app-stats-globe-bubble {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0);
transition:
opacity var(--duration-slow) var(--ease-spring),
transform var(--duration-slow) var(--ease-spring);
&--visible {
opacity: 1;
transform: scale(var(--bubble-scale, 1));
}
&--hidden {
opacity: 0 !important;
transition: opacity 0.15s ease !important;
}
&--leaving {
opacity: 0 !important;
transform: scale(0.3) !important;
transition:
opacity var(--duration-slow) ease,
transform var(--duration-slow) ease !important;
}
&-ring {
position: absolute;
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid var(--vp-c-brand-1);
opacity: 0;
animation: app-stats-globe-ping 1.4s cubic-bezier(0, 0, 0.2, 1) forwards;
}
&-disc {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: #ffffff;
border: 2px solid rgba(255, 255, 255, 0.95);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
&-img {
width: 100%;
height: 100%;
border-radius: 50%;
display: block;
}
}
.dark .app-stats-globe-bubble-disc {
background: #1a1c2a;
border-color: rgba(56, 189, 248, 0.2);
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(56, 189, 248, 0.1);
}
@keyframes app-stats-globe-ping {
0% {
opacity: 0.6;
transform: scale(0.5);
}
100% {
opacity: 0;
transform: scale(2.2);
}
}
@media (max-width: 768px) {
.app-stats-globe-bubble {
&-disc {
width: 30px;
height: 30px;
}
&-ring {
width: 36px;
height: 36px;
}
}
}
</style>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
} from 'chart.js';
import { useChartTheme } from '../../composables/useChartTheme';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
);
const props = defineProps<{
labels: string[];
series: Array<{ name: string; values: number[] }>;
formatValue: (value: number) => string;
}>();
// Generate one distinct colour per series so the palette never repeats, even
// with all 36+ styles. Golden-angle hue spacing (137.5°) keeps adjacent lines
// far apart on the colour wheel; starting near the brand-blue hue keeps the
// first (top) series on-brand. Alternating lightness adds extra separation.
const palette = computed(() =>
props.series.map((_, i) => {
const hue = (i * 137.508 + 205) % 360;
const lightness = i % 2 === 0 ? 58 : 46;
return `hsl(${hue.toFixed(1)} 65% ${lightness}%)`;
}),
);
const { chartKey, tooltipConfig, gridColor, tickColor } = useChartTheme();
const chartData = computed(() => ({
labels: props.labels,
datasets: props.series.map((s, i) => {
const color = palette.value[i];
return {
label: s.name,
data: s.values,
borderColor: color,
backgroundColor: color,
borderWidth: 2,
fill: false,
tension: 0.3,
pointRadius: 0,
pointHitRadius: 10,
pointHoverRadius: 4,
pointHoverBackgroundColor: color,
};
}),
}));
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
},
plugins: {
legend: {
display: true,
position: 'bottom' as const,
labels: {
color: tickColor(),
boxWidth: 12,
boxHeight: 12,
padding: 12,
font: { size: 12 },
},
},
tooltip: {
...tooltipConfig(),
callbacks: {
label: (ctx: any) =>
`${ctx.dataset.label}: ${props.formatValue(ctx.parsed.y)}`,
},
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: tickColor(),
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 8,
font: { size: 12 },
},
border: {
display: false,
},
},
y: {
grid: {
color: gridColor(),
},
ticks: {
color: tickColor(),
callback: (value: any) => props.formatValue(value),
font: { size: 12 },
},
border: {
display: false,
},
},
},
}));
</script>
<template>
<div class="app-stats-multi-line-chart">
<Line :key="chartKey" :data="chartData" :options="chartOptions" />
</div>
</template>
<style lang="scss" scoped>
.app-stats-multi-line-chart {
height: 360px;
position: relative;
}
@media (max-width: 640px) {
.app-stats-multi-line-chart {
height: 280px;
}
}
</style>
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { kebabCase } from 'change-case';
import Prando from 'prando';
import { ArrowRight, ArrowLeft, Shapes } from '@lucide/vue';
import Button from 'primevue/button';
import { UiAvatar, UiContainer, UiSection, UiSectionHeader } from '../ui';
import { useVisibility } from '../../composables/useVisibility';
import { useAvatarStyleList } from '../../composables/avatar';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { once: false, threshold: 0.1 });
const avatarStyleList = useAvatarStyleList();
const SHOWCASE_SEEDS = [
'Felix',
'Aneka',
'Milo',
'Luna',
'Max',
'Sophie',
'Leo',
'Emma',
'Noah',
'Aria',
'Zoe',
'Oscar',
];
const showcaseAvatars = computed(() => {
const prng = new Prando(42);
return avatarStyleList.value.map((style) => ({
style: kebabCase(style),
seed: SHOWCASE_SEEDS[prng.nextInt(0, SHOWCASE_SEEDS.length - 1)],
}));
});
const doubledAvatars = computed(() => [
...showcaseAvatars.value,
...showcaseAvatars.value,
]);
const ITEM_WIDTH = 140;
const SCROLL_DURATION_MS = 400;
const totalWidth = computed(() => showcaseAvatars.value.length * ITEM_WIDTH);
const trackRef = ref<HTMLElement>();
let scrollPosition = 0;
let animationFrame: number | null = null;
let isPaused = false;
// Smooth scroll animation state
let isManualScrolling = false;
let scrollStartPosition = 0;
let scrollTargetPosition = 0;
let scrollStartTime = 0;
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function updateTrackTransform() {
if (trackRef.value) {
trackRef.value.style.transform = `translateX(-${scrollPosition}px)`;
}
}
function animate(timestamp: number) {
if (!isVisible.value) {
animationFrame = null;
return;
}
if (isManualScrolling) {
const elapsed = timestamp - scrollStartTime;
const progress = Math.min(elapsed / SCROLL_DURATION_MS, 1);
const easedProgress = easeOutCubic(progress);
scrollPosition =
scrollStartPosition +
(scrollTargetPosition - scrollStartPosition) * easedProgress;
if (progress >= 1) {
isManualScrolling = false;
// Normalize position after manual scroll
if (scrollPosition >= totalWidth.value) {
scrollPosition = scrollPosition % totalWidth.value;
}
if (scrollPosition < 0) {
scrollPosition = totalWidth.value + (scrollPosition % totalWidth.value);
}
}
} else if (!isPaused) {
scrollPosition += 0.5;
if (scrollPosition >= totalWidth.value) {
scrollPosition = 0;
}
}
updateTrackTransform();
animationFrame = requestAnimationFrame(animate);
}
function startAnimation() {
if (animationFrame === null && isVisible.value) {
animationFrame = requestAnimationFrame(animate);
}
}
function pauseAnimation() {
isPaused = true;
}
function resumeAnimation() {
isPaused = false;
}
function smoothScroll(delta: number) {
scrollStartPosition = scrollPosition;
scrollTargetPosition = scrollPosition + delta;
scrollStartTime = performance.now();
isManualScrolling = true;
}
function scrollLeft() {
smoothScroll(-300);
}
function scrollRight() {
smoothScroll(300);
}
watch(isVisible, (visible) => {
if (visible) {
startAnimation();
}
});
onMounted(() => {
if (isVisible.value) {
startAnimation();
}
});
onUnmounted(() => {
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
}
});
</script>
<template>
<UiSection
ref="sectionRef"
:class="{ visible: isVisible }"
no-padding
divider
>
<template #background>
<div class="app-style-showcase-gradient"></div>
</template>
<UiContainer class="app-style-showcase-header">
<UiSectionHeader
description="From cute characters to abstract patterns, pixel art to professional illustrations. Our avatar library features styles crafted by talented artists and designers."
>
<template #headline><strong>35+</strong> Unique Avatar Styles</template>
</UiSectionHeader>
</UiContainer>
<div class="app-style-showcase-outer">
<button
class="app-style-showcase-scroll-btn app-style-showcase-scroll-btn-left"
@click="scrollLeft"
aria-label="Scroll left"
>
<ArrowLeft />
</button>
<div class="app-style-showcase-wrapper">
<div
class="app-style-showcase-scroll"
@mouseenter="pauseAnimation"
@mouseleave="resumeAnimation"
@touchstart="pauseAnimation"
@touchend="resumeAnimation"
>
<div ref="trackRef" class="app-style-showcase-track">
<a
v-for="(avatar, index) in doubledAvatars"
:key="`${avatar.style}-${index}`"
:href="`/styles/${avatar.style}/`"
class="app-style-showcase-item"
>
<div class="app-style-showcase-avatar">
<UiAvatar
:style-name="avatar.style"
:style-options="{ seed: avatar.seed, size: 120 }"
:alt="`${avatar.style} style`"
/>
<div class="app-style-showcase-avatar-glow"></div>
</div>
<span class="app-style-showcase-label">{{ avatar.style }}</span>
</a>
</div>
</div>
</div>
<button
class="app-style-showcase-scroll-btn app-style-showcase-scroll-btn-right"
@click="scrollRight"
aria-label="Scroll right"
>
<ArrowRight />
</button>
</div>
<UiContainer class="app-style-showcase-cta">
<Button as="a" href="/styles/" size="large" severity="contrast">
<Shapes :size="20" />
Browse All Styles
<ArrowRight :size="20" />
</Button>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-style-showcase {
&-gradient {
background:
radial-gradient(
ellipse 60% 80% at 0% 50%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
),
radial-gradient(
ellipse 60% 80% at 100% 50%,
color-mix(in srgb, var(--vp-c-indigo-1) 5%, transparent),
transparent
);
}
&-header {
padding: 0 24px;
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-outer {
position: relative;
padding: 0 60px;
}
&-wrapper {
overflow-x: clip;
overflow-y: visible;
mask-image: linear-gradient(
90deg,
transparent,
black 8%,
black 92%,
transparent
);
-webkit-mask-image: linear-gradient(
90deg,
transparent,
black 8%,
black 92%,
transparent
);
}
&-scroll-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-border);
border-radius: 50%;
cursor: pointer;
z-index: 10;
transition: all var(--duration-fast) var(--ease-smooth);
box-shadow: var(--vp-shadow-2);
&:hover {
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
svg {
color: var(--vp-c-brand-1);
}
}
svg {
width: 20px;
height: 20px;
color: var(--vp-c-text-2);
transition: color var(--duration-fast) ease;
}
&-left {
left: 24px;
}
&-right {
right: 24px;
}
}
&-scroll {
overflow: visible;
padding: 30px 0;
margin: -30px 0;
}
&-track {
display: flex;
gap: 24px;
width: max-content;
will-change: transform;
transform: translateZ(0);
}
&-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-decoration: none;
transition: transform var(--duration-mid) var(--ease-spring);
&:hover {
transform: translateY(-4px) scale(1.02);
.app-style-showcase-avatar {
box-shadow:
var(--vp-shadow-5),
0 0 40px -10px var(--vp-c-brand-1);
}
.app-style-showcase-avatar-glow {
opacity: 1;
}
.app-style-showcase-label {
color: var(--vp-c-brand-1);
}
}
&::after {
display: none !important;
}
}
&-avatar {
width: 116px;
height: 116px;
border-radius: var(--vp-radius-xl);
overflow: hidden;
background: var(--vp-c-bg-soft);
box-shadow: var(--vp-shadow-3);
transition: all var(--duration-mid) var(--ease-smooth);
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&-glow {
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
transparent 40%,
rgba(2, 132, 199, 0.2)
);
opacity: 0;
transition: opacity var(--duration-mid) ease;
}
}
&-label {
font-size: 13px;
color: var(--vp-c-text-3);
font-weight: 500;
max-width: 116px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color var(--duration-mid) ease;
}
&-cta {
text-align: center;
margin-top: 40px;
}
}
@media (max-width: 768px) {
.app-style-showcase {
&-outer {
padding: 0;
}
&-scroll-btn {
display: none;
}
&-avatar {
width: 88px;
height: 88px;
border-radius: var(--vp-radius-md);
}
&-track {
gap: 16px;
}
&-label {
max-width: 88px;
font-size: 11px;
}
}
}
</style>
@@ -0,0 +1,599 @@
<script setup lang="ts">
import { ref } from 'vue';
import { UiContainer, UiSection, UiSectionHeader } from '../ui';
import AppUseCasesCard from './AppUseCasesCard.vue';
import { getAvatarApiUrl } from '@theme/utils/avatar/api';
import { PALETTE, type Pastel } from '@theme/utils/palette';
import { useVisibility } from '../../composables/useVisibility';
const sectionRef = ref();
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
function avatar(
styleName: string,
seed: string,
size: number,
background?: Pastel,
): string {
return getAvatarApiUrl(styleName, {
seed,
size,
...(background ? { backgroundColor: background } : {}),
});
}
</script>
<template>
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
<template #background>
<div class="app-use-cases-gradient-top"></div>
<div class="app-use-cases-gradient-bottom"></div>
</template>
<UiContainer>
<UiSectionHeader
class="app-use-cases-header"
description="From two-person side projects to global platforms, DiceBear powers the avatar layer wherever identity needs a face."
>
<template #headline
>Built for <strong>every</strong> application.</template
>
</UiSectionHeader>
<div class="app-use-cases-grid">
<AppUseCasesCard
title="User Profiles"
description="Give every user a unique avatar from day one. No upload, no Gravatar fallback, no awkward grey silhouette."
>
<div class="uc-stack">
<img
:src="avatar('lorelei', 'Mia', 96, PALETTE.rose)"
alt=""
width="80"
height="80"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('notionists', 'Bee', 96, PALETTE.amber)"
alt=""
width="80"
height="80"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('adventurer', 'Theo', 96, PALETTE.cyan)"
alt=""
width="80"
height="80"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('personas', 'Sage', 96, PALETTE.peach)"
alt=""
width="80"
height="80"
loading="lazy"
decoding="async"
/>
</div>
</AppUseCasesCard>
<AppUseCasesCard
title="Chat Applications"
description="Instantly recognisable participants. Deterministic from a user ID, so the same person looks the same on every device."
>
<div class="uc-bubbles">
<div class="uc-bubble uc-bubble-light uc-bubble-pink">
<img
:src="avatar('fun-emoji', 'Hi', 48, PALETTE.pink)"
alt=""
width="24"
height="24"
loading="lazy"
decoding="async"
/>
<span>Hey 👋</span>
</div>
<div class="uc-bubble uc-bubble-dark">
<span>How's the launch?</span>
<img
:src="avatar('lorelei', 'Otis', 48, PALETTE.amber)"
alt=""
width="24"
height="24"
loading="lazy"
decoding="async"
/>
</div>
<div class="uc-bubble uc-bubble-light uc-bubble-green">
<img
:src="avatar('big-smile', 'Ari', 48, PALETTE.green)"
alt=""
width="24"
height="24"
loading="lazy"
decoding="async"
/>
<span>Going great!</span>
</div>
</div>
</AppUseCasesCard>
<AppUseCasesCard
title="Gaming"
description="Generate millions of unique player identities, NPCs and bots. The same seed gives the same character every session."
>
<div class="uc-roster">
<div class="uc-player">
<img
:src="avatar('bottts', 'Zap', 160)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<span class="uc-rank">LV 47</span>
</div>
<div class="uc-player">
<img
:src="avatar('pixel-art', 'Nyx', 160)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<span class="uc-rank">LV 32</span>
</div>
<div class="uc-player">
<img
:src="avatar('bottts-neutral', 'Rex', 160)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<span class="uc-rank">LV 88</span>
</div>
</div>
</AppUseCasesCard>
<AppUseCasesCard
title="Forums &amp; Communities"
description="Distinct identities help build community trust. New users feel welcome with a face from the very first post."
>
<div class="uc-thread">
<div class="uc-post">
<img
:src="avatar('notionists', 'Posy', 56, PALETTE.lime)"
alt=""
width="28"
height="28"
loading="lazy"
decoding="async"
/>
<div>
<strong>posy</strong>
<em>Anyone tried the new beta?</em>
</div>
</div>
<div class="uc-post">
<img
:src="avatar('lorelei', 'Ren', 56, PALETTE.cyan)"
alt=""
width="28"
height="28"
loading="lazy"
decoding="async"
/>
<div>
<strong>ren</strong>
<em>Yep, runs 2× faster.</em>
</div>
</div>
<div class="uc-post">
<img
:src="avatar('open-peeps', 'Ivo', 56, PALETTE.amber)"
alt=""
width="28"
height="28"
loading="lazy"
decoding="async"
/>
<div>
<strong>ivo</strong>
<em>Submitted a PR for the bug.</em>
</div>
</div>
</div>
</AppUseCasesCard>
<AppUseCasesCard
title="Team Tools"
description="Visual distinction for team members in collaborative apps. In cursors, mentions, and sidebars, every face stays consistent."
>
<div class="uc-team">
<div class="uc-pill">
<img
:src="avatar('personas', 'Lex', 52)"
alt=""
width="26"
height="26"
loading="lazy"
decoding="async"
/>
<span>Lex Hart</span>
<em>Design</em>
</div>
<div class="uc-pill">
<img
:src="avatar('personas', 'Mae', 52)"
alt=""
width="26"
height="26"
loading="lazy"
decoding="async"
/>
<span>Mae Park</span>
<em>Engineering</em>
</div>
<div class="uc-pill">
<img
:src="avatar('personas', 'Sam', 52)"
alt=""
width="26"
height="26"
loading="lazy"
decoding="async"
/>
<span>Sam Ito</span>
<em>Product</em>
</div>
<div class="uc-pill">
<img
:src="avatar('personas', 'Avery', 52)"
alt=""
width="26"
height="26"
loading="lazy"
decoding="async"
/>
<span>Avery Lin</span>
<em>Marketing</em>
</div>
</div>
</AppUseCasesCard>
<AppUseCasesCard
title="Placeholder Images"
description="Beautiful default profile pictures while users set up their account, and a graceful fallback when uploads fail."
>
<div class="uc-grid-mini">
<img
:src="avatar('shapes', 'A', 160, PALETTE.rose)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('shapes', 'B', 160, PALETTE.amber)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('shapes', 'C', 160, PALETTE.green)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('shapes', 'D', 160, PALETTE.cyan)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('shapes', 'E', 160, PALETTE.blue)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
<img
:src="avatar('shapes', 'F', 160, PALETTE.peach)"
alt=""
width="160"
height="160"
loading="lazy"
decoding="async"
/>
</div>
</AppUseCasesCard>
</div>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.app-use-cases {
&-gradient-top {
top: 0;
bottom: auto;
height: 50%;
background: radial-gradient(
ellipse 100% 100% at 20% 0%,
color-mix(in srgb, var(--vp-c-brand-1) 7%, transparent),
transparent
);
}
&-gradient-bottom {
top: auto;
bottom: 0;
height: 50%;
background: radial-gradient(
ellipse 100% 100% at 80% 100%,
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
transparent
);
}
&-header {
opacity: 0;
transform: translateY(30px);
transition: all var(--duration-reveal) var(--ease-smooth);
.visible & {
opacity: 1;
transform: translateY(0);
}
}
&-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
}
.uc-stack {
position: relative;
height: 100px;
margin-top: 4px;
img {
position: absolute;
width: 80px;
height: 80px;
border-radius: 22px;
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.1);
transition: transform var(--duration-mid) ease;
&:nth-child(1) {
left: 0;
top: 6px;
transform: rotate(-7deg);
}
&:nth-child(2) {
left: 60px;
top: 0;
transform: rotate(3deg);
}
&:nth-child(3) {
left: 120px;
top: 8px;
transform: rotate(-3deg);
}
&:nth-child(4) {
left: 180px;
top: 2px;
transform: rotate(6deg);
}
}
.uc-card:hover & img:nth-child(1) {
transform: rotate(-12deg) translateY(-4px);
}
.uc-card:hover & img:nth-child(4) {
transform: rotate(11deg) translateY(-4px);
}
}
.uc-bubbles {
display: flex;
flex-direction: column;
gap: 8px;
}
.uc-bubble {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 8px;
border-radius: 18px;
max-width: 80%;
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.06);
&-pink,
&-green {
align-self: flex-start;
color: #18181b;
}
&-pink {
background: #ffe4f1;
}
&-green {
background: #dcfce7;
}
&-dark {
align-self: flex-end;
padding: 8px 8px 8px 12px;
background: #18181b;
color: #fafafa;
}
:global(.dark .uc-bubble-dark) {
background: #fafafa;
color: #18181b;
}
img {
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
}
span {
font-size: 12.5px;
font-weight: 600;
}
}
.uc-roster {
display: flex;
gap: 10px;
}
.uc-player {
position: relative;
flex: 1;
aspect-ratio: 1;
border-radius: 18px;
overflow: hidden;
background: linear-gradient(180deg, #1e293b, #0f172a);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.uc-rank {
position: absolute;
left: 6px;
bottom: 6px;
background: #facc15;
color: #422006;
font-family: var(--vp-font-family-mono);
font-size: 10px;
font-weight: 800;
padding: 3px 6px;
border-radius: 5px;
letter-spacing: 0.3px;
}
.uc-thread {
display: flex;
flex-direction: column;
gap: 8px;
}
.uc-post {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
img {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
strong {
display: block;
font-size: 12.5px;
font-weight: 700;
color: var(--vp-c-text-1);
}
em {
font-size: 11.5px;
font-style: normal;
color: var(--vp-c-text-2);
}
}
.uc-team {
display: flex;
flex-direction: column;
gap: 6px;
}
.uc-pill {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px 7px 7px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 999px;
img {
width: 26px;
height: 26px;
border-radius: 50%;
}
span {
font-size: 13px;
font-weight: 700;
color: var(--vp-c-text-1);
}
em {
margin-left: auto;
font-style: normal;
font-size: 11px;
color: var(--vp-c-text-2);
font-family: var(--vp-font-family-mono);
}
}
.uc-grid-mini {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
img {
width: 100%;
aspect-ratio: 1;
border-radius: 10px;
}
}
@media (max-width: 900px) {
.app-use-cases-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.app-use-cases-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { UiCard } from '../ui';
defineProps<{
title: string;
description: string;
}>();
</script>
<template>
<UiCard padding="xl" class="uc-card">
<div class="uc-card-visual">
<slot />
</div>
<h3 class="uc-card-title">{{ title }}</h3>
<p class="uc-card-text">{{ description }}</p>
</UiCard>
</template>
<style lang="scss" scoped>
.uc-card {
min-height: 340px;
:deep(.ui-card-body) {
height: 100%;
display: flex;
flex-direction: column;
}
&-visual {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
margin-bottom: 24px;
> * {
width: 100%;
}
}
&-title {
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
margin: 0 0 8px;
}
&-text {
font-size: 14.5px;
line-height: 1.5;
color: var(--vp-c-text-2);
margin: 0;
}
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { useData } from 'vitepress';
import { Check, X } from '@lucide/vue';
import type { ThemeOptions } from '@theme/types';
import {
buildComparisonRows,
comparisonServices,
} from '@theme/config/comparison';
const { theme } = useData<ThemeOptions>();
const rows = buildComparisonRows({
stars: theme.value.githubStars ?? {},
styleCount: Object.keys(theme.value.avatarStyles ?? {}).length,
});
</script>
<template>
<div class="docs-comparison">
<div class="docs-comparison-wrapper">
<table class="docs-comparison-table">
<thead>
<tr>
<th class="docs-comparison-feature-col">Feature</th>
<th
v-for="(service, index) in comparisonServices"
:key="service.key"
:class="{ 'docs-comparison-highlight-col': index === 0 }"
>
<a
:href="service.url"
target="_blank"
rel="noopener"
class="docs-comparison-service-link"
>{{ service.name }}</a
>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="rowIndex">
<td class="docs-comparison-feature-col">{{ row.feature }}</td>
<td
v-for="(service, colIndex) in comparisonServices"
:key="service.key"
:class="{ 'docs-comparison-highlight-col': colIndex === 0 }"
>
<span
v-if="row.values[service.key] === 'yes'"
class="docs-comparison-cell-yes"
>
<Check :size="18" />
</span>
<span
v-else-if="row.values[service.key] === 'free'"
class="docs-comparison-cell-free"
>
Free
</span>
<span
v-else-if="row.values[service.key] === 'paid'"
class="docs-comparison-cell-paid"
>
Paid
</span>
<span
v-else-if="row.values[service.key] === 'no'"
class="docs-comparison-cell-no"
>
<X :size="18" />
</span>
<span v-else class="docs-comparison-cell-text">{{
row.values[service.key]
}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style lang="scss" scoped>
.docs-comparison {
margin: 24px 0;
border: 1px solid var(--vp-c-border);
border-radius: var(--vp-radius-md, 12px);
overflow: hidden;
}
.docs-comparison-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
// This table renders inside `.vp-doc` content, which ships its own table
// styling (block display, full borders, zebra rows). The extra
// `.docs-comparison` prefix lifts specificity above those rules so the custom
// look survives without `!important`.
.docs-comparison .docs-comparison-table {
display: table;
width: 100%;
min-width: 700px;
margin: 0;
border-collapse: collapse;
font-size: 14px;
}
.docs-comparison .docs-comparison-table th,
.docs-comparison .docs-comparison-table td {
padding: 12px 16px;
text-align: center;
border: none;
border-bottom: 1px solid var(--vp-c-border);
white-space: nowrap;
}
.docs-comparison .docs-comparison-table tr {
background: transparent;
border-top: none;
}
.docs-comparison .docs-comparison-table thead th {
font-weight: 700;
font-size: 13px;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.docs-comparison .docs-comparison-table tbody tr:last-child td {
border-bottom: none;
}
.docs-comparison .docs-comparison-table thead .docs-comparison-highlight-col {
color: var(--vp-c-brand-1);
}
.docs-comparison .docs-comparison-table .docs-comparison-feature-col {
position: sticky;
left: 0;
z-index: 1;
min-width: 160px;
text-align: left;
font-weight: 600;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.docs-comparison .docs-comparison-table .docs-comparison-highlight-col {
background: color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent);
font-weight: 600;
}
.docs-comparison-service-link {
color: inherit;
text-decoration: none;
transition: color var(--duration-fast) ease;
}
.docs-comparison-service-link::after {
display: none !important;
}
.docs-comparison-service-link:hover {
color: var(--vp-c-brand-1);
}
.docs-comparison-cell-yes {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--vp-c-green-soft);
color: var(--vp-c-green-1);
}
.docs-comparison-cell-free {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border-radius: var(--vp-radius-sm);
background: var(--vp-c-green-soft);
color: var(--vp-c-green-1);
font-size: 13px;
font-weight: 600;
}
.docs-comparison-cell-paid {
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
padding: 0 12px;
border-radius: var(--vp-radius-sm);
background: color-mix(in srgb, var(--vp-c-text-3) 10%, transparent);
color: var(--vp-c-text-2);
font-size: 13px;
font-weight: 600;
}
.docs-comparison-cell-no {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: color-mix(in srgb, var(--vp-c-text-3) 15%, transparent);
color: var(--vp-c-text-3);
}
.docs-comparison-cell-text {
font-size: 13px;
color: var(--vp-c-text-2);
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { UiCard } from '../ui';
withDefaults(
defineProps<{
items: Array<{ title: string; description: string; badge?: string }>;
columns?: 2 | 3;
}>(),
{
columns: 3,
},
);
</script>
<template>
<div class="docs-grid" :class="`docs-grid-cols-${columns}`">
<UiCard v-for="item in items" :key="item.title" class="docs-grid-card">
<div class="docs-grid-header">
<span class="docs-grid-title">{{ item.title }}</span>
<Badge v-if="item.badge" type="tip">{{ item.badge }}</Badge>
</div>
<p class="docs-grid-desc">{{ item.description }}</p>
</UiCard>
</div>
</template>
<style lang="scss" scoped>
.docs-grid {
display: grid;
gap: 12px;
margin: 24px 0;
&-cols-2 {
grid-template-columns: repeat(2, 1fr);
}
&-cols-3 {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 768px) {
&-cols-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
&-cols-2,
&-cols-3 {
grid-template-columns: 1fr;
}
}
&-card {
:deep(.ui-card-body) {
display: flex;
flex-direction: column;
gap: 8px;
}
}
&-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
&-title {
font-size: 15px;
font-weight: 700;
color: var(--vp-c-text-1);
}
&-desc {
font-size: 14px;
color: var(--vp-c-text-2);
margin: 0;
line-height: 1.7;
}
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { Component } from 'vue';
import { UiCard, UiIconBox } from '../ui';
defineProps<{
highlights: Array<{
icon: Component;
title: string;
description: string;
color: string;
link?: string;
}>;
}>();
</script>
<template>
<div class="docs-highlights">
<UiCard
v-for="highlight in highlights"
:key="highlight.title"
:href="highlight.link"
padding="xl"
class="docs-highlights-card"
>
<UiIconBox
size="md"
:color="highlight.color"
class="docs-highlights-icon"
>
<component :is="highlight.icon" />
</UiIconBox>
<h3 class="docs-highlights-title">{{ highlight.title }}</h3>
<p class="docs-highlights-desc">{{ highlight.description }}</p>
</UiCard>
</div>
</template>
<style lang="scss" scoped>
.docs-highlights {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin: 24px 0;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
@media (max-width: 640px) {
&-card {
--ui-card-padding: 24px;
}
}
&-icon {
margin-bottom: 16px;
}
&-title {
font-size: 15px;
font-weight: 700;
color: var(--vp-c-text-1);
margin: 0 0 8px;
border: none;
padding: 0;
}
&-desc {
font-size: 14px;
color: var(--vp-c-text-2);
margin: 0;
line-height: 1.7;
}
}
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { UiAvatar } from '../ui';
import { ArrowRight } from '@lucide/vue';
withDefaults(
defineProps<{
styles: Array<{
name: string;
styleName: string;
link: string;
bestFor: string;
}>;
seeds?: string[];
avatarSize?: number;
allStylesLink?: string;
allStylesLabel?: string;
}>(),
{
seeds: () => ['Felix', 'Emma', 'Leo', 'Mia'],
avatarSize: 48,
allStylesLink: '/styles/',
allStylesLabel: 'Browse all 35+ avatar styles',
},
);
</script>
<template>
<div class="docs-style-grid">
<a
v-for="style in styles"
:key="style.styleName"
:href="style.link"
class="docs-style-grid-card"
>
<div class="docs-style-grid-avatars">
<UiAvatar
v-for="seed in seeds"
:key="seed"
:size="avatarSize"
:style-name="style.styleName"
:style-options="{ seed, size: avatarSize, borderRadius: 50 }"
:alt="style.name"
/>
</div>
<div class="docs-style-grid-info">
<span class="docs-style-grid-name">
{{ style.name }}
<ArrowRight :size="14" class="docs-style-grid-arrow" />
</span>
<span class="docs-style-grid-desc">{{ style.bestFor }}</span>
</div>
</a>
</div>
<a :href="allStylesLink" class="docs-style-grid-all">
<span>{{ allStylesLabel }}</span>
<ArrowRight :size="16" />
</a>
</template>
<style lang="scss" scoped>
.docs-style-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin: 24px 0;
&-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid var(--vp-c-divider);
border-radius: var(--vp-radius-sm);
text-decoration: none;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
border-color: var(--vp-c-brand-1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
.docs-style-grid-arrow {
transform: translateX(3px);
}
}
&::after {
display: none !important;
}
}
&-avatars {
display: flex;
gap: 8px;
}
&-info {
display: flex;
flex-direction: column;
gap: 4px;
}
&-name {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
display: flex;
align-items: center;
gap: 4px;
}
&-arrow {
color: var(--vp-c-brand-1);
transition: transform var(--duration-fast) ease;
}
&-desc {
font-size: 13px;
color: var(--vp-c-text-2);
line-height: 1.5;
}
&-all {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 14px;
font-weight: 500;
color: var(--vp-c-brand-1);
text-decoration: none;
transition: gap var(--duration-fast) ease;
&:hover {
gap: 10px;
}
&::after {
display: none !important;
}
}
}
</style>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useData } from 'vitepress';
import { capitalCase } from 'change-case';
import { ThemeOptions } from '@theme/types';
const { theme } = useData<ThemeOptions>();
type SortColumn = 'title' | 'value';
type SortDirection = 'asc' | 'desc';
const sortColumn = ref<SortColumn>('title');
const sortDirection = ref<SortDirection>('asc');
const rows = computed(() => {
const entries = Object.entries(theme.value.avatarUniqueCounts).map(
([key, entry]) => ({
key,
title: capitalCase(key),
display: entry.display,
log10: entry.log10,
}),
);
const direction = sortDirection.value === 'asc' ? 1 : -1;
return entries.toSorted((a, b) => {
if (sortColumn.value === 'value') {
return (a.log10 - b.log10) * direction;
}
return a.title.localeCompare(b.title) * direction;
});
});
function toggleSort(column: SortColumn): void {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
return;
}
sortColumn.value = column;
sortDirection.value = 'asc';
}
function sortIndicator(column: SortColumn): string {
if (sortColumn.value !== column) {
return '';
}
return sortDirection.value === 'asc' ? ' ↑' : ' ↓';
}
</script>
<template>
<table>
<thead>
<tr>
<th
:aria-sort="
sortColumn === 'title'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
"
class="sortable"
@click="toggleSort('title')"
>
Style{{ sortIndicator('title') }}
</th>
<th
:aria-sort="
sortColumn === 'value'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
"
class="sortable numeric"
@click="toggleSort('value')"
>
Unique Avatars{{ sortIndicator('value') }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.key">
<td>{{ row.title }}</td>
<td class="numeric">{{ row.display }}</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
.numeric {
text-align: right;
}
.sortable {
cursor: pointer;
user-select: none;
}
</style>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const show = ref(false);
onMounted(() => {
show.value = true;
});
</script>
<template>
<slot v-if="show" />
<slot v-else name="fallback" />
</template>
@@ -0,0 +1,317 @@
<script setup lang="ts">
import { useLayout } from 'vitepress/theme-without-fonts';
import LayoutFooterBrand from './LayoutFooterBrand.vue';
import LayoutFooterLinks from './LayoutFooterLinks.vue';
import LayoutFooterSponsor from './LayoutFooterSponsor.vue';
import LayoutFooterCredits from './LayoutFooterCredits.vue';
const { hasSidebar } = useLayout();
</script>
<template>
<footer
class="layout-footer"
:class="{ 'layout-footer-has-sidebar': hasSidebar }"
>
<div class="layout-footer-divider"></div>
<div class="layout-footer-main">
<div class="layout-footer-container">
<LayoutFooterBrand />
<LayoutFooterLinks />
</div>
</div>
<LayoutFooterSponsor />
<LayoutFooterCredits />
</footer>
</template>
<style lang="scss">
:root {
--layout-footer-logo-light-display: inline-block;
--layout-footer-logo-dark-display: none;
--layout-footer-sponsor-logo-light-display: none;
--layout-footer-sponsor-logo-dark-display: inline-block;
}
.dark {
--layout-footer-logo-light-display: none;
--layout-footer-logo-dark-display: inline-block;
--layout-footer-sponsor-logo-light-display: inline-block;
--layout-footer-sponsor-logo-dark-display: none;
}
.layout-footer {
position: relative;
background: var(--vp-c-bg);
&-divider {
height: 1px;
max-width: 800px;
margin: 0 auto;
background: linear-gradient(
90deg,
transparent,
var(--vp-c-divider),
transparent
);
}
&-container {
max-width: 1440px;
margin: 0 auto;
padding: 0 32px;
@media (min-width: 960px) {
.layout-footer-has-sidebar & {
padding-left: calc(var(--vp-sidebar-width) + 32px);
}
}
}
&-main {
padding: 64px 0 48px;
.layout-footer-container {
display: flex;
gap: 64px;
}
}
&-brand {
flex-shrink: 0;
max-width: 260px;
}
&-logo {
display: inline-block;
margin-bottom: 16px;
img {
/* The width/height attributes only provide the aspect ratio for
* pre-load layout; without width: auto they would also act as a
* presentational hint and fix the width at the intrinsic 183px. */
height: 24px;
width: auto;
}
&-light {
display: var(--layout-footer-logo-light-display);
}
&-dark {
display: var(--layout-footer-logo-dark-display);
}
}
&-tagline {
font-size: 14px;
line-height: 1.6;
color: var(--vp-c-text-2);
margin: 0 0 20px;
}
&-social {
display: flex;
gap: 4px;
&-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: var(--vp-radius-sm);
color: var(--vp-c-text-2);
transition: all var(--duration-mid) var(--ease-spring);
&:hover {
color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
transform: translateY(-2px);
}
svg {
width: 20px;
height: 20px;
}
}
}
&-links {
display: flex;
gap: 64px;
flex: 1;
justify-content: flex-end;
}
&-column {
&-title {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--vp-c-text-1);
margin: 0 0 16px;
}
&-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
}
&-link {
font-size: 14px;
color: var(--vp-c-text-2);
text-decoration: none;
transition: color var(--duration-fast) ease;
&:hover {
color: var(--vp-c-brand-1);
}
&::after {
display: none !important;
}
}
&-sponsor {
padding: 14px 0;
border-top: 1px solid var(--vp-c-divider);
.layout-footer-container {
display: flex;
justify-content: center;
}
&-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
&-logo {
display: inline-flex;
align-items: center;
line-height: 0;
&::after {
display: none !important;
}
img {
height: 52px;
width: auto;
}
&-light {
display: var(--layout-footer-sponsor-logo-light-display);
}
&-dark {
display: var(--layout-footer-sponsor-logo-dark-display);
}
}
&-meta {
font-size: 11px;
color: var(--vp-c-text-3);
}
}
&-bottom {
padding: 24px 0;
border-top: 1px solid var(--vp-c-divider);
&-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
}
&-attributions {
margin: 0;
font-size: 12px;
color: var(--vp-c-text-3);
line-height: 1.7;
}
&-attribution-link {
color: var(--vp-c-text-2);
text-decoration: none;
border-bottom: 1px dashed var(--vp-c-text-3);
transition: all var(--duration-fast) ease;
&:hover {
color: var(--vp-c-brand-1);
border-bottom-color: var(--vp-c-brand-1);
}
&::after {
display: none !important;
}
}
@media (min-width: 960px) {
&-has-sidebar .layout-footer-main .layout-footer-container {
flex-direction: column;
gap: 40px;
}
&-has-sidebar .layout-footer-brand {
max-width: 100%;
}
&-has-sidebar .layout-footer-links {
justify-content: flex-start;
gap: 40px;
}
}
@media (min-width: 1340px) {
&-has-sidebar .layout-footer-main .layout-footer-container {
flex-direction: row;
gap: 64px;
}
&-has-sidebar .layout-footer-brand {
max-width: 260px;
}
&-has-sidebar .layout-footer-links {
justify-content: flex-end;
gap: 64px;
}
}
@media (max-width: 768px) {
.layout-footer-main .layout-footer-container {
flex-direction: column;
gap: 40px;
}
.layout-footer-brand {
max-width: 100%;
}
.layout-footer-links {
justify-content: flex-start;
gap: 40px;
}
}
@media (max-width: 480px) {
.layout-footer-links {
flex-direction: column;
gap: 32px;
}
}
}
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { withBase } from 'vitepress';
import { siGithub, siFigma } from 'simple-icons';
import { UiIcon } from '../ui';
</script>
<template>
<div class="layout-footer-brand">
<a href="/" class="layout-footer-logo">
<img
class="layout-footer-logo-light"
:src="withBase('/logo.svg')"
alt="DiceBear"
width="183"
height="32"
/>
<img
class="layout-footer-logo-dark"
:src="withBase('/logo-dark.svg')"
alt="DiceBear"
width="183"
height="32"
/>
</a>
<p class="layout-footer-tagline">
Open source SVG avatar library and avatar API for designers and
developers.
</p>
<div class="layout-footer-social">
<a
href="https://github.com/dicebear/dicebear"
target="_blank"
rel="me noopener"
aria-label="GitHub"
class="layout-footer-social-link"
>
<UiIcon :path="siGithub.path" />
</a>
<a
href="https://www.figma.com/@dicebear_com"
target="_blank"
rel="me noopener"
aria-label="Figma"
class="layout-footer-social-link"
>
<UiIcon :path="siFigma.path" />
</a>
</div>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useData } from 'vitepress';
import { computed } from 'vue';
import type { AvatarStyleMeta, ThemeOptions } from '@theme/types';
import { safeHttpUrl } from '@theme/utils/url';
const { theme } = useData<ThemeOptions>();
const styles = computed(() => {
const result: AvatarStyleMeta[] = [];
const knownWork: string[] = [];
for (const val of Object.values(theme.value.avatarStyles)) {
if (
val.meta.creator === 'Florian Körner' ||
val.meta.creator === 'DiceBear'
) {
continue;
}
if (val.meta.source) {
if (knownWork.includes(val.meta.source)) {
continue;
}
knownWork.push(val.meta.source);
}
result.push(val.meta);
}
return result;
});
</script>
<template>
<div class="layout-footer-bottom">
<div class="layout-footer-container">
<div class="layout-footer-bottom-inner">
<p class="layout-footer-attributions">
<template v-for="style in styles" :key="style.source">
<a
v-if="safeHttpUrl(style.source)"
class="layout-footer-attribution-link"
:href="safeHttpUrl(style.source)"
target="_blank"
rel="noopener"
>{{ style.title }}</a
>
<template v-else>{{ style.title }}</template>
by {{ style.creator }} /
<a
v-if="safeHttpUrl(style.license?.url)"
class="layout-footer-attribution-link"
:href="safeHttpUrl(style.license?.url)"
target="_blank"
rel="noopener"
>{{ style.license?.name }}</a
>
<template v-else>{{ style.license?.name }}</template
>.
</template>
All avatars are remixes of the original works.
</p>
</div>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import {
productLinks,
resourceLinks,
legalLinks,
} from '../../config/footer-links';
const columns = [
{ title: 'Explore', links: productLinks },
{ title: 'Resources', links: resourceLinks },
{ title: 'Legal', links: legalLinks },
] as const;
</script>
<template>
<div class="layout-footer-links">
<div
v-for="column in columns"
:key="column.title"
class="layout-footer-column"
>
<h3 class="layout-footer-column-title">{{ column.title }}</h3>
<ul class="layout-footer-column-list">
<li v-for="link in column.links" :key="link.label">
<a
:href="link.href"
:target="link.external ? '_blank' : undefined"
:rel="link.external ? 'noopener noreferrer' : undefined"
class="layout-footer-link"
>
{{ link.label }}
</a>
</li>
</ul>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { withBase } from 'vitepress';
</script>
<template>
<div class="layout-footer-sponsor">
<div class="layout-footer-container">
<div class="layout-footer-sponsor-inner">
<a
href="https://bunny.net/"
target="_blank"
rel="noopener sponsored"
class="layout-footer-sponsor-logo"
>
<img
class="layout-footer-sponsor-logo-light"
:src="withBase('/sponsors/bunny-light.svg')"
alt="bunny.net"
width="149"
height="43"
/>
<img
class="layout-footer-sponsor-logo-dark"
:src="withBase('/sponsors/bunny-dark.svg')"
alt="bunny.net"
width="149"
height="43"
/>
</a>
<span class="layout-footer-sponsor-meta"
>CDN sponsored by bunny.net · Advertisement</span
>
</div>
</div>
</div>
</template>
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { useData } from 'vitepress';
import { computed } from 'vue';
import { Search, Sun, Moon } from '@lucide/vue';
import { siGithub } from 'simple-icons';
import UiIcon from '@theme/components/ui/UiIcon.vue';
import type { ThemeOptions } from '@theme/types';
const { isDark, theme } = useData<ThemeOptions>();
const starCount = computed(() => {
return theme.value.githubStars?.['dicebear/dicebear'] ?? '';
});
function toggleTheme() {
isDark.value = !isDark.value;
}
function openSearch() {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
}
</script>
<template>
<div class="layout-nav-actions">
<button
class="layout-nav-actions-search"
aria-label="Search"
@click="openSearch"
>
<Search :size="16" :stroke-width="2.5" />
</button>
<button
class="layout-nav-actions-theme"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<Sun v-show="isDark" :size="16" :stroke-width="2.5" />
<Moon v-show="!isDark" :size="16" :stroke-width="2.5" />
</button>
<a
href="https://github.com/dicebear/dicebear"
target="_blank"
rel="noopener noreferrer"
class="layout-nav-actions-star"
aria-label="Star DiceBear on GitHub"
>
<UiIcon :path="siGithub.path" :size="16" />
<span class="layout-nav-actions-star-label">Star</span>
<span v-if="starCount" class="layout-nav-actions-star-count">{{
starCount
}}</span>
</a>
</div>
</template>
<style lang="scss" scoped>
.layout-nav-actions {
--star-accent: #eab308;
--star-accent-soft: rgba(234, 179, 8, 0.12);
--star-accent-border: rgba(234, 179, 8, 0.4);
--star-accent-hover: rgba(234, 179, 8, 0.2);
--star-accent-text: #a16207;
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
}
:root.dark .layout-nav-actions {
--star-accent: #facc15;
--star-accent-soft: rgba(250, 204, 21, 0.1);
--star-accent-border: rgba(250, 204, 21, 0.35);
--star-accent-hover: rgba(250, 204, 21, 0.16);
--star-accent-text: #facc15;
}
.layout-nav-actions-star {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 12px;
border-radius: var(--vp-radius-xs);
border: 1px solid var(--star-accent-border);
background: var(--star-accent-soft);
color: var(--vp-c-text-1);
font-family: var(--vp-font-family-heading);
font-size: 13px;
font-weight: 600;
line-height: 1;
text-decoration: none;
white-space: nowrap;
transition:
border-color var(--duration-fast) var(--ease-smooth),
background var(--duration-fast) var(--ease-smooth),
box-shadow var(--duration-fast) var(--ease-smooth);
&:hover {
border-color: var(--star-accent);
background: var(--star-accent-hover);
box-shadow: 0 0 0 3px var(--star-accent-soft);
}
}
.layout-nav-actions-star-label {
display: inline-block;
}
.layout-nav-actions-star-count {
display: inline-flex;
align-items: center;
padding: 1px 6px;
margin-left: 1px;
border-radius: var(--vp-radius-xs);
background: var(--star-accent-hover);
color: var(--star-accent-text);
font-size: 12px;
font-weight: 700;
line-height: 1.4;
}
.layout-nav-actions-search,
.layout-nav-actions-theme {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--vp-radius-xs);
border: 1px solid var(--p-form-field-border-color, var(--vp-c-divider));
background: var(--vp-c-bg);
color: var(--ui-c-text-muted);
cursor: pointer;
transition:
border-color var(--duration-fast) var(--ease-smooth),
color var(--duration-fast) var(--ease-smooth),
background var(--duration-fast) var(--ease-smooth),
box-shadow var(--duration-fast) var(--ease-smooth);
:deep(svg) {
pointer-events: none;
}
&:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
}
}
/* Hide "Star" label on tighter desktop layouts where the full nav would overflow */
@media (max-width: 959px) {
.layout-nav-actions-star-label {
display: none;
}
}
/* Below VitePress' nav breakpoint (768px) the desktop menu collapses into the
hamburger — align our own mobile-only rules with that same boundary. */
@media (max-width: 767px) {
.layout-nav-actions-theme {
display: none;
}
.layout-nav-actions {
margin-left: 8px;
}
}
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { useData } from 'vitepress';
import { useLayout } from 'vitepress/theme-without-fonts';
import VPIconAlignLeft from 'vitepress/dist/client/theme-default/components/icons/VPIconAlignLeft.vue';
defineProps<{
open: boolean;
}>();
defineEmits<{
(e: 'open-menu'): void;
}>();
const { theme, frontmatter } = useData();
const { hasSidebar } = useLayout();
function scrollToTop() {
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
}
</script>
<template>
<div v-if="hasSidebar" class="layout-vp-local-nav">
<button
class="layout-vp-local-nav-menu"
:aria-expanded="open"
aria-controls="VPSidebarNav"
@click="$emit('open-menu')"
>
<VPIconAlignLeft class="layout-vp-local-nav-menu-icon" />
<span class="layout-vp-local-nav-menu-text">
{{ frontmatter.sidebarMenuLabel || theme.sidebarMenuLabel || 'Menu' }}
</span>
</button>
<a class="layout-vp-local-nav-top-link" href="#" @click="scrollToTop">
{{ theme.returnToTopLabel || 'Return to top' }}
</a>
</div>
</template>
<style lang="scss" scoped>
.layout-vp-local-nav {
position: sticky;
top: 0;
/*rtl:ignore*/
left: 0;
z-index: var(--vp-z-index-local-nav);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--vp-c-gutter);
padding-top: var(--vp-layout-top-height, 0px);
width: 100%;
background-color: var(--vp-local-nav-bg-color);
transition:
border-color 0.5s,
background-color 0.5s;
@media (min-width: 960px) {
display: none;
}
&-menu {
display: flex;
align-items: center;
padding: 12px 24px 11px;
line-height: 24px;
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color var(--duration-slow);
&:hover {
color: var(--vp-c-text-1);
transition: color var(--duration-fast);
}
@media (min-width: 768px) {
padding: 0 32px;
}
&-icon {
margin-right: 8px;
width: 16px;
height: 16px;
fill: currentColor;
}
}
&-top-link {
display: block;
padding: 12px 24px 11px;
line-height: 24px;
font-size: 12px;
font-weight: 500;
color: var(--vp-c-text-2);
transition: color var(--duration-slow);
&:hover {
color: var(--vp-c-text-1);
transition: color var(--duration-fast);
}
@media (min-width: 768px) {
padding: 12px 32px 11px;
}
}
}
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import AppHero from '../app/AppHero.vue';
import AppSeedDemo from '../app/AppSeedDemo.vue';
import AppStyleShowcase from '../app/AppStyleShowcase.vue';
import AppEditor from '../app/AppEditor.vue';
import AppCreateStyle from '../app/AppCreateStyle.vue';
import AppOpenSource from '../app/AppOpenSource.vue';
import AppUseCases from '../app/AppUseCases.vue';
import AppStatsBanner from '../app/AppStatsBanner.vue';
</script>
<template>
<AppHero />
<AppSeedDemo />
<AppStyleShowcase />
<AppUseCases />
<AppStatsBanner />
<AppEditor />
<AppCreateStyle />
<AppOpenSource />
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useData } from 'vitepress';
import type { ThemeOptions } from '@theme/types';
import { kebabCase } from 'change-case';
import { safeHttpUrl } from '@theme/utils/url';
const { theme } = useData<ThemeOptions>();
const styles = theme.value.avatarStyles;
</script>
<template>
<table v-for="(style, styleName) in styles" :key="styleName">
<colgroup>
<col width="20%" />
<col />
</colgroup>
<thead>
<tr>
<th colspan="2" align="left">
<a :href="`/styles/${kebabCase(styleName)}/`">{{ styleName }}</a>
</th>
</tr>
</thead>
<tbody>
<tr v-if="style.meta.title">
<td>Title</td>
<td>{{ style.meta.title }}</td>
</tr>
<tr v-if="style.meta.creator">
<td>Creator</td>
<td>{{ style.meta.creator }}</td>
</tr>
<tr v-if="style.meta.source">
<td>Source</td>
<td>
<a
v-if="safeHttpUrl(style.meta.source)"
:href="safeHttpUrl(style.meta.source)"
target="_blank"
rel="noopener noreferrer"
>{{ style.meta.source }}</a
>
<template v-else>{{ style.meta.source }}</template>
</td>
</tr>
<tr v-if="style.meta.homepage">
<td>Homepage</td>
<td>
<a
v-if="safeHttpUrl(style.meta.homepage)"
:href="safeHttpUrl(style.meta.homepage)"
target="_blank"
rel="noopener noreferrer"
>{{ style.meta.homepage }}</a
>
<template v-else>{{ style.meta.homepage }}</template>
</td>
</tr>
<tr v-if="style.meta.license?.name">
<td>License Name</td>
<td>{{ style.meta.license?.name }}</td>
</tr>
<tr v-if="style.meta.license?.url">
<td>License URL</td>
<td>
<a
v-if="safeHttpUrl(style.meta.license?.url)"
:href="safeHttpUrl(style.meta.license?.url)"
target="_blank"
rel="noopener noreferrer"
>{{ style.meta.license?.url }}</a
>
<template v-else>{{ style.meta.license?.url }}</template>
</td>
</tr>
</tbody>
</table>
</template>
@@ -0,0 +1,566 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UiSection, UiContainer, UiSectionHeader, UiCard } from '../ui';
import AppSmallHero from '../app/AppSmallHero.vue';
import AppStatsChart from '../app/AppStatsChart.vue';
import AppStatsMultiLineChart from '../app/AppStatsMultiLineChart.vue';
import AppStatsGlobe from '../app/AppStatsGlobe.vue';
import {
useApiStatsRaw,
lastCompleteMonth,
} from '../../composables/useApiStats';
import { formatNumber, formatBytes } from '../../utils/format';
const SECONDS_PER_DAY = 86400;
const ROLLING_WINDOW_DAYS = 7;
const TOP_STYLES = 10;
const stats = useApiStatsRaw();
function fmtDay(d: Date): string {
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
}
function weekStartKey(dateStr: string): string {
const d = new Date(`${dateStr}T00:00:00Z`);
const dow = d.getUTCDay();
const offset = (dow + 6) % 7;
d.setUTCDate(d.getUTCDate() - offset);
return d.toISOString().slice(0, 10);
}
function fmtWeekRange(weekStartStr: string): string {
const start = new Date(weekStartStr);
const end = new Date(start.getTime() + 6 * SECONDS_PER_DAY * 1000);
if (start.getMonth() === end.getMonth()) {
return `${fmtDay(start)}${end.getDate()}`;
}
return `${fmtDay(start)} ${fmtDay(end)}`;
}
function completeWeeks(dayKeys: string[], weekOrder: string[]): string[] {
if (weekOrder.length === 0) {
return weekOrder;
}
const firstDow = new Date(`${dayKeys[0]}T00:00:00Z`).getUTCDay();
const lastDow = new Date(
`${dayKeys[dayKeys.length - 1]}T00:00:00Z`,
).getUTCDay();
const start = firstDow !== 1 ? 1 : 0;
const end = lastDow !== 0 ? weekOrder.length - 1 : weekOrder.length;
return weekOrder.slice(start, end);
}
function aggregateWeekly(data: Record<string, number>): {
labels: string[];
values: number[];
} {
const dayKeys = Object.keys(data).sort();
if (dayKeys.length === 0) {
return { labels: [], values: [] };
}
const sums: Record<string, number> = {};
const weekOrder: string[] = [];
for (const dayKey of dayKeys) {
const wk = weekStartKey(dayKey);
if (sums[wk] === undefined) {
sums[wk] = 0;
weekOrder.push(wk);
}
sums[wk] += data[dayKey];
}
const weeks = completeWeeks(dayKeys, weekOrder);
return {
labels: weeks.map((k) => fmtWeekRange(k)),
values: weeks.map((week) => sums[week]),
};
}
const requestsData = computed(() => {
if (!stats.value) {
return null;
}
return aggregateWeekly(stats.value.requests);
});
const downloadsData = computed(() => {
if (!stats.value) {
return null;
}
return aggregateWeekly(stats.value.downloads.npm);
});
function buildSeries(source: Record<string, [string, number][]>): {
labels: string[];
series: Array<{ name: string; values: number[] }>;
} | null {
const dayKeys = Object.keys(source).sort();
if (dayKeys.length === 0) {
return null;
}
const sums: Record<string, Record<string, number>> = {};
const dayCount: Record<string, number> = {};
const weekOrder: string[] = [];
for (const dayKey of dayKeys) {
const wk = weekStartKey(dayKey);
if (!sums[wk]) {
sums[wk] = {};
dayCount[wk] = 0;
weekOrder.push(wk);
}
dayCount[wk] += 1;
for (const [name, value] of source[dayKey]) {
sums[wk][name] = (sums[wk][name] ?? 0) + value;
}
}
const weeks = completeWeeks(dayKeys, weekOrder);
if (weeks.length === 0) {
return null;
}
const totals: Record<string, number> = {};
for (const week of weeks) {
const count = dayCount[week] || 1;
for (const [name, sum] of Object.entries(sums[week])) {
totals[name] = (totals[name] ?? 0) + sum / count;
}
}
const ranked = Object.keys(totals).sort((a, b) => totals[b] - totals[a]);
if (ranked.length === 0) {
return null;
}
const labels = weeks.map((k) => fmtWeekRange(k));
const series = ranked.map((name) => ({
name,
values: weeks.map(
(week) => (sums[week][name] ?? 0) / (dayCount[week] || 1),
),
}));
return { labels, series };
}
function formatPercent(value: number): string {
if (value === 0) {
return '0%';
}
if (value < 0.1) {
return '<0.1%';
}
return `${value.toFixed(1)}%`;
}
const stylesData = computed(() => {
if (!stats.value) {
return null;
}
return buildSeries(stats.value.styles);
});
const versionsData = computed(() => {
if (!stats.value) {
return null;
}
return buildSeries(stats.value.versions);
});
const formatsData = computed(() => {
if (!stats.value) {
return null;
}
return buildSeries(stats.value.formats);
});
const showAllStyles = ref(false);
const activeTab = ref<'api' | 'npm'>('api');
const requestsPerSecond = computed(() => {
if (!stats.value) {
return 0;
}
const entries = Object.entries(stats.value.requests).sort(([a], [b]) =>
a.localeCompare(b),
);
if (entries.length < 2) {
return 0;
}
entries.pop();
const window = entries.slice(-ROLLING_WINDOW_DAYS);
const total = window.reduce((sum, [, v]) => sum + v, 0);
const avgDaily = total / window.length;
return avgDaily / SECONDS_PER_DAY;
});
const monthlyStats = computed(() => {
if (!stats.value) {
return null;
}
const requests = lastCompleteMonth(stats.value.requests);
const traffic = lastCompleteMonth(stats.value.traffic);
const downloads = lastCompleteMonth(stats.value.downloads.npm);
if (!requests) {
return null;
}
return {
label: requests.label,
requests: formatNumber(requests.total),
traffic: traffic ? formatBytes(traffic.total) : null,
downloads: downloads ? formatNumber(downloads.total) : null,
};
});
</script>
<template>
<AppSmallHero>
<template #headline
>Billions of Avatars.<br /><strong>One API.</strong></template
>
<template #description
>Every avatar generated through our HTTP-API is tracked anonymously. This
page gives you a transparent look at real usage data. It is updated weekly
and broken down by requests, traffic, styles, and more.</template
>
<template #actions><!-- no actions --></template>
<template #below-actions>
<div v-if="monthlyStats" class="page-stats-hero-kpis">
<p class="page-stats-hero-kpis-label">
Statistics from {{ monthlyStats.label }}
</p>
<div class="page-stats-hero-kpis-row">
<div class="page-stats-hero-kpi">
<span class="page-stats-hero-kpi-value">{{
monthlyStats.requests
}}</span>
<span class="page-stats-hero-kpi-label">API Requests</span>
</div>
<div class="page-stats-hero-kpi-divider"></div>
<div v-if="monthlyStats.traffic" class="page-stats-hero-kpi">
<span class="page-stats-hero-kpi-value">{{
monthlyStats.traffic
}}</span>
<span class="page-stats-hero-kpi-label">Data Served</span>
</div>
<div
v-if="monthlyStats.downloads"
class="page-stats-hero-kpi-divider"
></div>
<div v-if="monthlyStats.downloads" class="page-stats-hero-kpi">
<span class="page-stats-hero-kpi-value">{{
monthlyStats.downloads
}}</span>
<span class="page-stats-hero-kpi-label">npm Downloads</span>
</div>
</div>
</div>
</template>
<template #aside>
<ClientOnly>
<AppStatsGlobe :rate="requestsPerSecond" />
</ClientOnly>
</template>
</AppSmallHero>
<UiSection divider>
<UiContainer>
<UiSectionHeader
description="Weekly request and download volumes. Toggle between the HTTP API and npm packages."
>
<template #headline>Usage Over <strong>Time</strong></template>
</UiSectionHeader>
<div class="page-stats-tabs">
<button
:class="{ active: activeTab === 'api' }"
@click="activeTab = 'api'"
>
HTTP API
</button>
<button
:class="{ active: activeTab === 'npm' }"
@click="activeTab = 'npm'"
>
npm
</button>
</div>
<ClientOnly>
<UiCard
v-if="requestsData && activeTab === 'api'"
padding="xl"
class="page-stats-chart-card"
>
<h3 class="page-stats-chart-title">API Requests</h3>
<AppStatsChart
:labels="requestsData.labels"
:values="requestsData.values"
color="#0284c7"
:format-value="formatNumber"
/>
</UiCard>
<UiCard
v-if="downloadsData && activeTab === 'npm'"
padding="xl"
class="page-stats-chart-card"
>
<h3 class="page-stats-chart-title">Package Downloads</h3>
<AppStatsChart
:labels="downloadsData.labels"
:values="downloadsData.values"
color="#cb3837"
:format-value="formatNumber"
/>
</UiCard>
</ClientOnly>
</UiContainer>
</UiSection>
<UiSection divider>
<UiContainer>
<UiSectionHeader
description="Based on API request data. Shows which styles, versions, and output formats are used most."
>
<template #headline>Usage <strong>Details</strong></template>
</UiSectionHeader>
<ClientOnly>
<UiCard v-if="stylesData" padding="xl" class="page-stats-styles-card">
<h3 class="page-stats-chart-title">Popular Styles</h3>
<AppStatsMultiLineChart
:labels="stylesData.labels"
:series="
showAllStyles
? stylesData.series
: stylesData.series.slice(0, TOP_STYLES)
"
:format-value="formatPercent"
/>
<button
v-if="stylesData.series.length > TOP_STYLES"
class="page-stats-show-all"
@click="showAllStyles = !showAllStyles"
>
{{ showAllStyles ? `Show Top ${TOP_STYLES}` : 'Show All' }}
</button>
</UiCard>
<div class="page-stats-breakdown-grid">
<UiCard v-if="versionsData" padding="xl">
<h3 class="page-stats-chart-title">API Versions</h3>
<AppStatsMultiLineChart
:labels="versionsData.labels"
:series="versionsData.series"
:format-value="formatPercent"
/>
</UiCard>
<UiCard v-if="formatsData" padding="xl">
<h3 class="page-stats-chart-title">Output Formats</h3>
<AppStatsMultiLineChart
:labels="formatsData.labels"
:series="formatsData.series"
:format-value="formatPercent"
/>
</UiCard>
</div>
</ClientOnly>
</UiContainer>
</UiSection>
</template>
<style lang="scss" scoped>
.page-stats-hero-kpis {
margin-top: 32px;
&-label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--vp-c-text-3);
margin: 0 0 16px;
}
&-row {
display: flex;
align-items: center;
gap: 28px;
}
}
.page-stats-hero-kpi {
display: flex;
flex-direction: column;
gap: 4px;
&-value {
font-size: 28px;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--vp-c-text-1);
font-variant-numeric: tabular-nums;
}
&-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--vp-c-text-3);
}
&-divider {
width: 1px;
height: 40px;
background: linear-gradient(
180deg,
transparent,
var(--vp-c-border),
transparent
);
}
}
.page-stats-tabs {
display: flex;
justify-content: center;
gap: 4px;
margin-bottom: 32px;
background: var(--vp-c-bg-alt);
border-radius: var(--vp-radius-sm);
padding: 4px;
width: fit-content;
margin-inline: auto;
button {
padding: 8px 24px;
border: none;
background: transparent;
color: var(--vp-c-text-2);
font-size: 14px;
font-weight: 500;
border-radius: var(--vp-radius-xs);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-smooth);
&.active {
background: var(--vp-c-bg-elv);
color: var(--vp-c-text-1);
box-shadow: var(--vp-shadow-1);
}
&:hover:not(.active) {
color: var(--vp-c-text-1);
}
}
}
.page-stats-chart-card {
overflow: hidden;
}
.page-stats-chart-title {
font-size: 18px;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 24px;
}
.page-stats-show-all {
display: block;
margin: 16px auto 0;
padding: 6px 16px;
border: 1px solid var(--vp-c-border);
background: transparent;
color: var(--vp-c-text-2);
font-size: 13px;
font-weight: 500;
border-radius: var(--vp-radius-xs);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-smooth);
&:hover {
color: var(--vp-c-text-1);
border-color: var(--vp-c-text-2);
}
}
.page-stats-styles-card {
margin-bottom: 32px;
}
.page-stats-breakdown-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
> * {
min-width: 0;
overflow: hidden;
}
}
@media (max-width: 768px) {
.page-stats-hero-kpis {
text-align: center;
&-label {
text-align: center;
}
&-row {
justify-content: center;
flex-wrap: wrap;
}
}
.page-stats-hero-kpi {
align-items: center;
}
.page-stats-breakdown-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import AppSmallHero from '../app/AppSmallHero.vue';
import AppHeroAsideUserList from '../app/AppHeroAsideUserList.vue';
import AppHighlights from '../app/AppHighlights.vue';
import AppIntegration from '../app/AppIntegration.vue';
import AppCDN from '../app/AppCDN.vue';
import AppComparison from '../app/AppComparison.vue';
import AppOpenSourceCards from '../app/AppOpenSourceCards.vue';
import AppFrameworks from '../app/AppFrameworks.vue';
</script>
<template>
<AppSmallHero>
<template #aside>
<AppHeroAsideUserList />
</template>
</AppSmallHero>
<AppHighlights>
<template #headline>Everything You <strong>Need</strong></template>
</AppHighlights>
<AppIntegration />
<AppFrameworks />
<AppCDN />
<AppComparison />
<AppOpenSourceCards />
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import LayoutClientOnly from '@theme/components/layout/LayoutClientOnly.vue';
import PlaygroundLoader from './PlaygroundLoader.vue';
import PlaygroundContent from './PlaygroundContent.vue';
</script>
<template>
<LayoutClientOnly>
<template #default>
<PlaygroundContent />
</template>
<template #fallback>
<PlaygroundLoader />
</template>
</LayoutClientOnly>
</template>
@@ -0,0 +1,533 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import SelectButton from 'primevue/selectbutton';
import Textarea from 'primevue/textarea';
import { Download, Shuffle } from '@lucide/vue';
import JSZip from 'jszip';
import { Avatar } from '@dicebear/core';
import { UiAvatar, UiConfetti, UiLicenseAlert } from '@theme/components/ui';
import useStore from '@theme/stores/playground';
import { loadAvatarStyle } from '@theme/utils/avatar/style';
import { triggerDownload } from '@theme/utils/download';
const SEED_CAP = 500;
const PREVIEW_LIMIT = 12;
type Mode = 'paste' | 'random';
const store = useStore();
const mode = ref<Mode>('random');
const modeOptions: { label: string; value: Mode }[] = [
{ label: 'Random', value: 'random' },
{ label: 'Paste', value: 'paste' },
];
const seedsInput = ref('');
const randomCount = ref(12);
const randomSeeds = ref<string[]>([]);
function generateRandomSeeds(n: number): string[] {
const out: string[] = [];
for (let i = 0; i < n; i++) {
out.push(crypto.randomUUID().replaceAll('-', '').slice(0, 10));
}
return out;
}
function shuffleRandom() {
randomSeeds.value = generateRandomSeeds(randomCount.value);
}
shuffleRandom();
watch(mode, (m) => {
if (m === 'random' && randomSeeds.value.length === 0) {
shuffleRandom();
}
});
watch(randomCount, (n) => {
if (mode.value === 'random' && Number.isFinite(n) && n > 0) {
randomSeeds.value = generateRandomSeeds(Math.min(n, SEED_CAP));
}
});
const seeds = computed<string[]>(() => {
if (mode.value === 'paste') {
return seedsInput.value
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
return randomSeeds.value;
});
const seedCount = computed(() => seeds.value.length);
const overCap = computed(() => seedCount.value > SEED_CAP);
const previewSeeds = computed(() => seeds.value.slice(0, PREVIEW_LIMIT));
const isGenerating = ref(false);
const progress = ref({ done: 0, total: 0 });
const errorMessage = ref('');
const successState = ref(false);
const canGenerate = computed(
() =>
!isGenerating.value &&
seedCount.value > 0 &&
!overCap.value &&
!!store.avatarStyleName,
);
const generateLabel = computed(() => {
if (isGenerating.value) {
return `Bundling ${progress.value.done} / ${progress.value.total}`;
}
if (seedCount.value === 0) return 'Add seeds to begin';
return `Download ${seedCount.value} SVG${seedCount.value === 1 ? '' : 's'} as ZIP`;
});
const cleanStyleName = computed(() =>
store.avatarStyleName.replace(/^custom:/, ''),
);
const previewStyleOptions = computed(() => ({
...store.avatarStyleOptionsWithoutDefaults,
}));
// Some ancestor (likely VitePress local search or a PrimeVue listener) is
// suppressing the default Enter newline in capture phase. Manually insert
// the newline so the textarea behaves normally regardless of who calls
// preventDefault upstream.
async function onTextareaEnter(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
const ta = event.target as HTMLTextAreaElement;
const start = ta.selectionStart;
const end = ta.selectionEnd;
seedsInput.value = ta.value.slice(0, start) + '\n' + ta.value.slice(end);
await nextTick();
ta.selectionStart = ta.selectionEnd = start + 1;
ta.scrollTop = ta.scrollHeight;
}
function safeName(seed: string, used: Set<string>): string {
// eslint-disable-next-line no-control-regex
const cleaned = seed.replace(/[/\\:*?"<>|\x00-\x1f]/g, '-').slice(0, 200);
const base = cleaned || 'avatar';
let name = `${base}.svg`;
let i = 2;
while (used.has(name)) name = `${base}-${i++}.svg`;
used.add(name);
return name;
}
let aborted = false;
onBeforeUnmount(() => {
aborted = true;
});
async function generate() {
if (!canGenerate.value) return;
isGenerating.value = true;
errorMessage.value = '';
successState.value = false;
progress.value = { done: 0, total: seedCount.value };
try {
const style = await loadAvatarStyle(store.avatarStyleName);
if (aborted) return;
const zip = new JSZip();
const used = new Set<string>();
const baseOptions = { ...store.avatarStyleOptionsWithoutDefaults };
for (const seed of seeds.value) {
if (aborted) return;
const svg = new Avatar(style, { ...baseOptions, seed }).toString();
zip.file(safeName(seed, used), svg);
progress.value = {
done: progress.value.done + 1,
total: seedCount.value,
};
if (progress.value.done % 25 === 0) {
await new Promise((r) => setTimeout(r, 0));
}
}
const blob = await zip.generateAsync({ type: 'blob' });
if (aborted) return;
triggerDownload(blob, `${cleanStyleName.value}-avatars.zip`);
successState.value = true;
} catch (err) {
if (aborted) return;
errorMessage.value = err instanceof Error ? err.message : String(err);
} finally {
isGenerating.value = false;
}
}
function reset() {
successState.value = false;
errorMessage.value = '';
progress.value = { done: 0, total: 0 };
}
</script>
<template>
<div class="pg-batch">
<template v-if="!successState">
<header class="pg-batch-top">
<SelectButton
v-model="mode"
:options="modeOptions"
option-label="label"
option-value="value"
:allow-empty="false"
size="small"
aria-label="Seed source"
/>
</header>
<section class="pg-batch-section">
<div v-if="mode === 'random'" class="pg-batch-random">
<InputNumber
v-model="randomCount"
:min="1"
:max="SEED_CAP"
:step="1"
:show-buttons="true"
button-layout="horizontal"
:input-style="{ width: '5em', textAlign: 'center' }"
decrement-button-class="pg-batch-step-button"
increment-button-class="pg-batch-step-button"
aria-label="Number of random seeds"
>
<template #incrementicon>+</template>
<template #decrementicon></template>
</InputNumber>
<span class="pg-batch-random-suffix">
random seed{{ randomCount === 1 ? '' : 's' }}
</span>
<button
type="button"
class="pg-batch-shuffle"
@click="shuffleRandom"
aria-label="Regenerate random seeds"
>
<Shuffle :size="14" />
<span>Shuffle</span>
</button>
</div>
<div v-else class="pg-batch-paste">
<Textarea
id="pg-batch-seeds"
v-model="seedsInput"
:rows="6"
placeholder="One seed per line — a username, email, user ID, anything."
class="pg-batch-textarea"
spellcheck="false"
autocomplete="off"
fluid
@keydown.enter="onTextareaEnter"
/>
<span class="pg-batch-counter" :class="{ 'is-over': overCap }">
{{ seedCount }} / {{ SEED_CAP }}
</span>
</div>
<p v-if="overCap" class="pg-batch-hint is-error">
That's more than the {{ SEED_CAP }}-seed cap. Trim the list and try
again.
</p>
</section>
<section
v-if="previewSeeds.length > 0"
class="pg-batch-section pg-batch-preview-section"
>
<header class="pg-batch-section-header">
<span class="pg-batch-eyebrow">Preview</span>
<span class="pg-batch-preview-count">
<span v-if="seedCount > PREVIEW_LIMIT">
first {{ previewSeeds.length }} of {{ seedCount }}
</span>
<span v-else>
all {{ seedCount }} avatar{{ seedCount === 1 ? '' : 's' }}
</span>
</span>
</header>
<ul class="pg-batch-preview-grid">
<li
v-for="seed in previewSeeds"
:key="seed"
class="pg-batch-preview-tile"
>
<div class="pg-batch-preview-tile-avatar">
<UiAvatar
:size="64"
:style-name="store.avatarStyleName"
:style-options="{ ...previewStyleOptions, seed }"
mode="library"
/>
</div>
<code class="pg-batch-preview-tile-seed">{{ seed }}</code>
</li>
</ul>
</section>
<footer class="pg-batch-footer">
<Button
:label="generateLabel"
:disabled="!canGenerate"
:loading="isGenerating"
severity="contrast"
@click="generate"
>
<template #icon><Download :size="16" /></template>
</Button>
<p v-if="errorMessage" class="pg-batch-hint is-error">
{{ errorMessage }}
</p>
</footer>
</template>
<template v-else>
<div class="pg-batch-success">
<UiConfetti />
<div class="dialog-title">Your avatars will be downloaded! 🎉</div>
<div class="dialog-subtitle">
Please note the license below before using.
</div>
<div class="dialog-text">
<UiLicenseAlert :style-name="store.avatarStyleName" />
</div>
<div class="pg-batch-success-actions">
<Button
label="Download another batch"
severity="secondary"
variant="outlined"
@click="reset"
/>
</div>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.pg-batch {
display: flex;
flex-direction: column;
gap: 16px;
}
.pg-batch-top {
display: flex;
justify-content: flex-start;
}
.pg-batch-section {
display: flex;
flex-direction: column;
gap: 12px;
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
}
.pg-batch-eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--ui-c-text-subtle);
}
.pg-batch-random {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.pg-batch-random-suffix {
font-size: 14px;
color: var(--ui-c-text-muted);
}
.pg-batch-shuffle {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding: 8px 14px;
background: transparent;
border: 1px dashed var(--vp-c-border);
border-radius: var(--vp-radius-xs);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ui-c-text-muted);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-smooth);
&:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
border-style: solid;
}
&:active svg {
transform: rotate(-20deg);
}
svg {
transition: transform var(--duration-fast) var(--ease-smooth);
}
}
.pg-batch-paste {
display: flex;
flex-direction: column;
}
.pg-batch-textarea {
font-family: var(--vp-font-family-mono);
font-size: 13px;
line-height: 1.55;
min-height: 160px;
resize: vertical;
}
.pg-batch-counter {
align-self: flex-end;
margin-top: 6px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ui-c-text-subtle);
&.is-over {
color: var(--vp-c-danger-1, #dc2626);
font-weight: 600;
}
}
.pg-batch-hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--ui-c-text-subtle);
&.is-error {
color: var(--vp-c-danger-1, #dc2626);
}
}
.pg-batch-preview-section {
padding-top: 12px;
border-top: 1px solid var(--vp-c-divider);
}
.pg-batch-preview-count {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ui-c-text-muted);
}
.pg-batch-preview-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
}
.pg-batch-preview-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 6px 8px;
margin: 0;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: var(--vp-radius-sm);
&-avatar {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background:
linear-gradient(45deg, var(--vp-c-bg-soft) 25%, transparent 25%) 0 0 / 8px
8px,
linear-gradient(-45deg, var(--vp-c-bg-soft) 25%, transparent 25%) 0 0 /
8px 8px,
var(--vp-c-bg);
border-radius: var(--vp-radius-xs);
overflow: hidden;
}
&-seed {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--vp-font-family-mono);
font-size: 10px;
color: var(--ui-c-text-muted);
padding: 0;
background: transparent;
}
}
.pg-batch-footer {
padding-top: 12px;
border-top: 1px solid var(--vp-c-divider);
display: flex;
flex-direction: column;
gap: 10px;
:deep(.p-button) {
width: 100%;
justify-content: center;
}
}
.pg-batch-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.pg-batch-success-actions {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 8px;
}
</style>
<style lang="scss">
html.dark {
.pg-batch-step-button {
background: var(--p-form-field-background);
}
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { Copy } from '@lucide/vue';
import { ref } from 'vue';
import copy from 'copy-to-clipboard';
import { Avatar } from '@dicebear/core';
import { loadAvatarStyle, clonePlain } from '@theme/utils/avatar/style';
import { track, styleLabel } from '@theme/utils/track';
import { UiAvatar, UiConfetti, UiDialog } from '../ui';
import Button from 'primevue/button';
import PlaygroundLicenseAlert from './PlaygroundLicenseAlert.vue';
import { usePlaygroundDialog } from '@theme/composables/usePlaygroundDialog';
import { DIALOG_PREVIEW_AVATAR_SIZE, DOWNLOAD_AVATAR_SIZE } from './constants';
const props = defineProps<{
seed: string;
}>();
const { store, open, confettiKey, options, showDialog } = usePlaygroundDialog(
() => props.seed,
);
const text = ref('');
async function onClick() {
const avatarStyle = await loadAvatarStyle(store.avatarStyleName);
const avatar = new Avatar(
avatarStyle,
clonePlain({
size: DOWNLOAD_AVATAR_SIZE,
...options.value,
}),
);
const successful = copy(avatar.toString());
if (successful) {
track('Playground: Copy SVG', {
style: styleLabel(store.avatarStyleName),
});
}
text.value = successful
? 'Your avatar was successfully copied! 🎉'
: 'Your avatar could not be copied. 😞';
showDialog();
}
</script>
<template>
<Button label="Copy SVG" severity="secondary" @click="onClick">
<template #icon>
<Copy :size="15" />
</template>
</Button>
<UiDialog v-model:open="open">
<UiConfetti :key="confettiKey" />
<div class="dialog-preview">
<UiAvatar
:style-name="store.avatarStyleName"
:style-options="options"
:size="DIALOG_PREVIEW_AVATAR_SIZE"
mode="library"
/>
</div>
<h2 class="dialog-title">{{ text }}</h2>
<div class="dialog-subtitle">
Please note the license below before using.
</div>
<div class="dialog-text">
<PlaygroundLicenseAlert />
</div>
</UiDialog>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Archive, Download } from '@lucide/vue';
import { Avatar } from '@dicebear/core';
import { getAvatarApiUrl } from '@theme/utils/avatar/api';
import { loadAvatarStyle, clonePlain } from '@theme/utils/avatar/style';
import { triggerDownload } from '@theme/utils/download';
import { track, styleLabel } from '@theme/utils/track';
import { UiAvatar, UiConfetti, UiDialog } from '../ui';
import PlaygroundLicenseAlert from './PlaygroundLicenseAlert.vue';
import PlaygroundBatchDownload from './PlaygroundBatchDownload.vue';
import { usePlaygroundDialog } from '@theme/composables/usePlaygroundDialog';
import Button from 'primevue/button';
import Menu from 'primevue/menu';
import { DIALOG_PREVIEW_AVATAR_SIZE, DOWNLOAD_AVATAR_SIZE } from './constants';
const props = defineProps<{
seed: string;
}>();
const { store, open, confettiKey, options, showDialog } = usePlaygroundDialog(
() => props.seed,
);
const menu = ref();
const batchOpen = ref(false);
async function downloadSvg() {
showDialog();
track('Playground: Download', {
style: styleLabel(store.avatarStyleName),
format: 'svg',
});
const avatarStyle = await loadAvatarStyle(store.avatarStyleName);
const avatar = new Avatar(
avatarStyle,
clonePlain({
size: DOWNLOAD_AVATAR_SIZE,
...options.value,
}),
);
const blob = new Blob([avatar.toString()], { type: 'image/svg+xml' });
triggerDownload(blob, `${store.avatarStyleName}-${Date.now()}.svg`);
}
async function downloadBinary(format: string) {
showDialog();
track('Playground: Download', {
style: styleLabel(store.avatarStyleName),
format,
});
const response = await fetch(
getAvatarApiUrl(store.avatarStyleName, options.value, format),
);
const blob = await response.blob();
triggerDownload(blob, `${store.avatarStyleName}-${Date.now()}.${format}`);
}
function openBatch() {
batchOpen.value = true;
}
// For built-in styles the API serves PNG/JPEG/WebP/AVIF; for custom styles we
// can only produce SVGs locally — so the format choices collapse to SVG +
// Batch. Batch stays available in both modes via a separator at the bottom.
const builtInMenuItems = [
{ label: 'SVG', command: () => downloadSvg() },
{ label: 'PNG', command: () => downloadBinary('png') },
{ label: 'JPEG', command: () => downloadBinary('jpg') },
{ label: 'WebP', command: () => downloadBinary('webp') },
{ label: 'AVIF', command: () => downloadBinary('avif') },
{ separator: true },
{ label: 'Batch download…', icon: Archive, command: () => openBatch() },
];
const customMenuItems = [
{ label: 'SVG', command: () => downloadSvg() },
{ separator: true },
{ label: 'Batch download…', icon: Archive, command: () => openBatch() },
];
function onDownloadClick(e: Event) {
menu.value.toggle(e);
}
</script>
<template>
<Button label="Download" severity="secondary" @click="onDownloadClick">
<template #icon>
<Download :size="15" />
</template>
</Button>
<Menu
ref="menu"
:model="store.isCustomStyle ? customMenuItems : builtInMenuItems"
:popup="true"
>
<template #item="{ item, props }">
<a v-bind="props.action" class="pg-download-menu-item">
<component :is="item.icon" v-if="item.icon" :size="14" />
<span>{{ item.label }}</span>
</a>
</template>
</Menu>
<UiDialog v-model:open="open">
<UiConfetti :key="confettiKey" />
<div class="dialog-preview">
<UiAvatar
:style-name="store.avatarStyleName"
:style-options="options"
:size="DIALOG_PREVIEW_AVATAR_SIZE"
mode="library"
/>
</div>
<div class="dialog-title">Your avatar will be downloaded! 🎉</div>
<div class="dialog-subtitle">
Please note the license below before using.
</div>
<div class="dialog-text">
<PlaygroundLicenseAlert />
</div>
</UiDialog>
<UiDialog v-model:open="batchOpen" header="Batch download" max-width="640px">
<PlaygroundBatchDownload v-if="batchOpen" />
</UiDialog>
</template>
<style scoped lang="scss">
.pg-download-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
</style>
@@ -0,0 +1,476 @@
<script setup lang="ts">
import { Code as CodeIcon } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { UiCard, UiCode, UiDialog } from '../ui';
import {
getAvatarApiUrl,
getAvatarApiCommand,
unsupportedHttpApiOptions,
} from '@theme/utils/avatar/api';
import {
formatPhpValue,
formatPythonValue,
formatGoValue,
formatDartValue,
} from '@theme/utils/code-examples';
import Button from 'primevue/button';
import PlaygroundLicenseAlert from './PlaygroundLicenseAlert.vue';
import { usePlaygroundDialog } from '@theme/composables/usePlaygroundDialog';
import { track, styleLabel } from '@theme/utils/track';
import Message from 'primevue/message';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
const props = defineProps<{
seed: string;
}>();
const { store, open, options } = usePlaygroundDialog(() => props.seed);
const tab = ref<string>(store.isCustomStyle ? 'js-library' : 'http-api');
watch(
() => store.isCustomStyle,
(isCustom) => {
if (isCustom && tab.value === 'http-api') {
tab.value = 'js-library';
}
},
);
watch(open, (isOpen) => {
if (isOpen) {
track('Playground: How To Use', {
style: styleLabel(store.avatarStyleName),
});
}
});
// Only count tabs the user actively picks while the dialog is open, not the
// automatic http-api → js-library switch for custom styles.
watch(tab, (value) => {
if (open.value) {
track('Playground: How To Use Tab', { tab: value });
}
});
const exampleHttpApi = computed(() =>
getAvatarApiUrl(store.avatarStyleName, options.value),
);
const hasExcludedOptions = computed(() => {
const opts = options.value as Record<string, unknown>;
return Object.keys(opts).some(
(k) => unsupportedHttpApiOptions.has(k) && opts[k] !== undefined,
);
});
const exampleHttpApiHtml = computed(
() => `<img
src="${getAvatarApiUrl(store.avatarStyleName, options.value)}"
alt="avatar" />`,
);
const exampleJsLibrary = computed(() => {
if (store.isCustomStyle) {
return `import { Style, Avatar } from '@dicebear/core';
// Your custom style definition
const definition = { /* ... */ };
const style = new Style(definition);
const avatar = new Avatar(style, ${JSON.stringify(options.value, null, 2)});
const svg = avatar.toString();`;
}
return `import { Style, Avatar } from '@dicebear/core';
import definition from '@dicebear/styles/${store.avatarStyleName}.json' with { type: 'json' };
const style = new Style(definition);
const avatar = new Avatar(style, ${JSON.stringify(options.value, null, 2)});
const svg = avatar.toString();`;
});
const examplePhp = computed(() => {
const phpOptions = formatPhpValue(options.value, 1);
if (store.isCustomStyle) {
return `<?php
use DiceBear\\Style;
use DiceBear\\Avatar;
// Your custom style definition (raw JSON)
$style = Style::fromJson(file_get_contents('./my-style.json'));
$avatar = new Avatar($style, ${phpOptions});
$svg = (string) $avatar;`;
}
return `<?php
use Composer\\InstalledVersions;
use DiceBear\\Style;
use DiceBear\\Avatar;
$basePath = InstalledVersions::getInstallPath('dicebear/styles');
$style = Style::fromJson(file_get_contents($basePath . '/src/${store.avatarStyleName}.json'));
$avatar = new Avatar($style, ${phpOptions});
$svg = (string) $avatar;`;
});
const examplePython = computed(() => {
const pythonOptions = formatPythonValue(options.value, 1);
if (store.isCustomStyle) {
return `from pathlib import Path
from dicebear import Avatar, Style
# Your custom style definition (raw JSON)
style = Style.from_json(Path("./my-style.json").read_text("utf-8"))
avatar = Avatar(style, ${pythonOptions})
svg = avatar.to_string()`;
}
return `from importlib.resources import files
from dicebear import Avatar, Style
style = Style.from_json(
files("dicebear_styles").joinpath("${store.avatarStyleName}.json").read_text("utf-8")
)
avatar = Avatar(style, ${pythonOptions})
svg = avatar.to_string()`;
});
const installRust = computed(() =>
store.isCustomStyle
? 'cargo add dicebear-core serde_json'
: `cargo add dicebear-core serde_json\ncargo add dicebear-styles --features ${store.avatarStyleName}`,
);
const exampleRust = computed(() => {
const rustOptions = JSON.stringify(options.value, null, 2);
if (store.isCustomStyle) {
return `use dicebear_core::{Avatar, Style};
use serde_json::json;
// Your custom style definition
let definition = std::fs::read_to_string("./my-style.json")?;
let style = Style::from_str(&definition)?;
let avatar = Avatar::new(&style, json!(${rustOptions}))?;
let svg = avatar.to_svg();`;
}
return `use dicebear_core::{Avatar, Style};
use serde_json::json;
let style = Style::from_str(dicebear_styles::${store.avatarStyleName.toUpperCase().replace(/-/g, '_')})?;
let avatar = Avatar::new(&style, json!(${rustOptions}))?;
let svg = avatar.to_svg();`;
});
const exampleGo = computed(() => {
const goOptions = formatGoValue(options.value, 1);
if (store.isCustomStyle) {
return `import (
"os"
dicebear "github.com/dicebear/dicebear-go/v10"
)
// Your custom style definition
definition, _ := os.ReadFile("./my-style.json")
style, _ := dicebear.NewStyle(definition)
avatar, _ := dicebear.NewAvatar(style, ${goOptions})
svg := avatar.SVG()`;
}
// The Go styles module exports each style as a PascalCase variable
// (e.g. "big-ears" → BigEars).
const styleConst = store.avatarStyleName
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
return `import (
dicebear "github.com/dicebear/dicebear-go/v10"
"github.com/dicebear/styles/v10"
)
style, _ := dicebear.NewStyle([]byte(styles.${styleConst}))
avatar, _ := dicebear.NewAvatar(style, ${goOptions})
svg := avatar.SVG()`;
});
const exampleDart = computed(() => {
const dartOptions = formatDartValue(options.value, 1);
if (store.isCustomStyle) {
return `import 'dart:io';
import 'package:dicebear_core/dicebear_core.dart';
// Your custom style definition (raw JSON)
final style = Style.parse(File('./my-style.json').readAsStringSync());
final avatar = Avatar(style, ${dartOptions});
final svg = avatar.svg;`;
}
// The Dart styles package exposes each style as its own library with a
// camelCase string constant (e.g. "big-ears" → big_ears.dart / bigEars).
const styleLibrary = store.avatarStyleName.replace(/-/g, '_');
const styleConst = store.avatarStyleName
.split('-')
.map((part, i) =>
i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
)
.join('');
return `import 'package:dicebear_core/dicebear_core.dart';
import 'package:dicebear_styles/${styleLibrary}.dart';
final style = Style.parse(${styleConst});
final avatar = Avatar(style, ${dartOptions});
final svg = avatar.svg;`;
});
const exampleCli = computed(() =>
getAvatarApiCommand(
store.isCustomStyle ? './my-style.json' : store.avatarStyleName,
options.value,
),
);
</script>
<template>
<Button label="How to use" severity="contrast" @click="open = true">
<template #icon>
<CodeIcon :size="15" />
</template>
</Button>
<UiDialog v-model:open="open" max-width="800px" header="How to use">
<div class="playground-button-how-to-use-text">
<UiCard flush class="playground-button-how-to-use-tabs-card">
<Tabs v-model:value="tab">
<TabList>
<Tab v-if="!store.isCustomStyle" value="http-api">HTTP-API</Tab>
<Tab value="js-library">JS</Tab>
<Tab value="php-library">PHP</Tab>
<Tab value="python-library">Python</Tab>
<Tab value="rust-library">Rust</Tab>
<Tab value="go-library">Go</Tab>
<Tab value="dart-library">Dart</Tab>
<Tab value="cli">CLI</Tab>
</TabList>
<TabPanels>
<TabPanel v-if="!store.isCustomStyle" value="http-api">
<div class="playground-button-how-to-use-tab-content">
<p>
Use this URL to request this avatar style via our HTTP API.
</p>
<UiCode :code="exampleHttpApi" />
<p>You can use the URL directly as image source.</p>
<UiCode :code="exampleHttpApiHtml" lang="html" />
<Message
v-if="hasExcludedOptions"
severity="warn"
:closable="false"
:style="{ '--p-message-text-font-size': '13px' }"
>
Some options you selected are not supported by our public
HTTP-API and have been omitted from the URL. You can enable
them by
<a href="/guides/host-the-http-api-yourself/"
>hosting your own instance</a
>.
</Message>
<p>
See <a href="/how-to-use/http-api">HTTP-API</a> docs for more
information.
</p>
</div>
</TabPanel>
<TabPanel value="js-library">
<div class="playground-button-how-to-use-tab-content">
<p>First install the required packages via npm:</p>
<UiCode
:code="
store.isCustomStyle
? 'npm install @dicebear/core --save'
: 'npm install @dicebear/core @dicebear/styles --save'
"
/>
<p>Then you can create this avatar as follows:</p>
<UiCode :code="exampleJsLibrary" lang="js" />
<p>
See <a href="/how-to-use/js-library">JS</a> docs for more
information.
</p>
</div>
</TabPanel>
<TabPanel value="php-library">
<div class="playground-button-how-to-use-tab-content">
<p>First install the required packages via Composer:</p>
<UiCode
:code="
store.isCustomStyle
? 'composer require dicebear/core'
: 'composer require dicebear/core dicebear/styles'
"
/>
<p>Then you can create this avatar as follows:</p>
<UiCode :code="examplePhp" lang="php" />
<p v-if="store.isCustomStyle">
Replace <code>./my-style.json</code> with the path to your
style definition.
</p>
</div>
</TabPanel>
<TabPanel value="python-library">
<div class="playground-button-how-to-use-tab-content">
<p>First install the required packages via pip:</p>
<UiCode
:code="
store.isCustomStyle
? 'pip install dicebear-core'
: 'pip install dicebear-core dicebear-styles'
"
/>
<p>Then you can create this avatar as follows:</p>
<UiCode :code="examplePython" lang="python" />
<p v-if="store.isCustomStyle">
Replace <code>./my-style.json</code> with the path to your
style definition.
</p>
<p>
See <a href="/how-to-use/python-library">Python</a> docs for
more information.
</p>
</div>
</TabPanel>
<TabPanel value="rust-library">
<div class="playground-button-how-to-use-tab-content">
<p>First add the required crates with Cargo:</p>
<UiCode :code="installRust" />
<p>Then you can create this avatar as follows:</p>
<UiCode :code="exampleRust" lang="rust" />
<p v-if="store.isCustomStyle">
Replace <code>./my-style.json</code> with the path to your
style definition.
</p>
<p>
See <a href="/how-to-use/rust-library">Rust</a> docs for more
information.
</p>
</div>
</TabPanel>
<TabPanel value="go-library">
<div class="playground-button-how-to-use-tab-content">
<p>First add the required modules with go get:</p>
<UiCode
:code="
store.isCustomStyle
? 'go get github.com/dicebear/dicebear-go/v10'
: 'go get github.com/dicebear/dicebear-go/v10\ngo get github.com/dicebear/styles/v10'
"
/>
<p>Then you can create this avatar as follows:</p>
<UiCode :code="exampleGo" lang="go" />
<p v-if="store.isCustomStyle">
Replace <code>./my-style.json</code> with the path to your
style definition.
</p>
<p>
See <a href="/how-to-use/go-library">Go</a> docs for more
information.
</p>
</div>
</TabPanel>
<TabPanel value="dart-library">
<div class="playground-button-how-to-use-tab-content">
<p>First add the required packages with dart pub:</p>
<UiCode
:code="
store.isCustomStyle
? 'dart pub add dicebear_core'
: 'dart pub add dicebear_core dicebear_styles'
"
/>
<p>Then you can create this avatar as follows:</p>
<UiCode :code="exampleDart" lang="dart" />
<p v-if="store.isCustomStyle">
Replace <code>./my-style.json</code> with the path to your
style definition.
</p>
<p>
See <a href="/how-to-use/dart-library">Dart</a> docs for more
information.
</p>
</div>
</TabPanel>
<TabPanel value="cli">
<div class="playground-button-how-to-use-tab-content">
<p>First install the CLI package via npm:</p>
<UiCode code="npm install --global dicebear" />
<p>Then you can create this avatar as follows:</p>
<UiCode :code="exampleCli" />
<p v-if="store.isCustomStyle">
Replace <code>./my-style.json</code> with the path to your
style definition.
</p>
<p>
See <a href="/how-to-use/cli">CLI</a> docs for more
information.
</p>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</UiCard>
<PlaygroundLicenseAlert />
</div>
</UiDialog>
</template>
<style scoped lang="scss">
.playground-button-how-to-use {
&-text {
display: flex;
flex-direction: column;
gap: 1rem;
a {
color: var(--vp-c-brand-1);
&:hover {
color: var(--vp-c-brand-2);
}
}
}
&-tab-content {
display: flex;
flex-direction: column;
gap: 12px;
p {
margin: 0;
}
}
}
</style>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Plus } from '@lucide/vue';
import Button from 'primevue/button';
import Slider from 'primevue/slider';
import Popover from 'primevue/popover';
import { stripHash } from '@theme/utils/avatar/colors';
defineProps<{
presetColors: string[];
colors: string[];
}>();
const emit = defineEmits<{
add: [hex: string];
}>();
const popover = ref();
const nativePickerColor = ref('#b6e3f4');
const pickerOpacity = ref(100);
function pickerHexWithAlpha(): string {
const hex = stripHash(nativePickerColor.value);
if (pickerOpacity.value >= 100) return hex;
const alpha = Math.round((pickerOpacity.value / 100) * 255)
.toString(16)
.padStart(2, '0');
return `${hex}${alpha}`;
}
function toggle(event: Event) {
popover.value.toggle(event);
}
function addFromPicker() {
emit('add', pickerHexWithAlpha());
popover.value.hide();
}
function addPreset(hex: string) {
emit('add', hex);
popover.value.hide();
}
</script>
<template>
<button class="pg-color-picker-trigger" @click="toggle">
<Plus :size="20" />
</button>
<Popover ref="popover">
<div class="pg-picker">
<div
class="pg-picker-presets"
v-if="presetColors.some((p) => !colors.includes(p))"
>
<button
v-for="preset in presetColors"
:key="preset"
v-show="!colors.includes(preset)"
class="pg-picker-preset"
:style="{ backgroundColor: `#${preset}` }"
@click="addPreset(preset)"
/>
</div>
<div class="pg-picker-custom">
<input
type="color"
v-model="nativePickerColor"
class="pg-picker-native"
/>
<div class="pg-picker-opacity">
<span class="pg-picker-opacity-label">Opacity</span>
<Slider
v-model="pickerOpacity"
:min="0"
:max="100"
:step="1"
class="pg-picker-opacity-slider"
/>
<span>{{ pickerOpacity }}%</span>
</div>
</div>
<Button
:label="`Add #${pickerHexWithAlpha()}`"
severity="secondary"
variant="outlined"
size="small"
@click="addFromPicker"
/>
</div>
</Popover>
</template>
<style scoped lang="scss">
.pg-color-picker-trigger {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-bg-soft);
border: 2px dashed var(--pg-border);
border-radius: var(--vp-radius-xs);
color: var(--ui-c-text-subtle);
cursor: pointer;
&:hover {
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
}
.pg-picker {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 200px;
}
.pg-picker-presets {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.pg-picker-preset {
width: 28px;
height: 28px;
border: none;
border-radius: var(--vp-radius-xs);
cursor: pointer;
transition: transform var(--duration-fast) ease;
&:hover {
transform: scale(1.15);
}
}
.pg-picker-custom {
display: flex;
align-items: center;
gap: 6px;
padding-top: 6px;
border-top: 1px solid var(--pg-border);
}
.pg-picker-native {
width: 36px;
height: 36px;
padding: 2px;
border: 1px solid var(--pg-border);
border-radius: var(--vp-radius-xs);
background: var(--vp-c-bg);
cursor: pointer;
flex-shrink: 0;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
&::-moz-color-swatch {
border: none;
border-radius: 4px;
}
}
.pg-picker-opacity {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
&-label {
font-size: 12px;
color: var(--ui-c-text-subtle);
white-space: nowrap;
}
span {
font-size: 12px;
font-weight: 600;
color: var(--ui-c-text-muted);
min-width: 36px;
text-align: right;
}
}
.pg-picker-opacity-slider {
flex: 1;
min-width: 60px;
}
</style>
@@ -0,0 +1,360 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import { Trash2, ArrowLeftRight, Link2 } from '@lucide/vue';
import Button from 'primevue/button';
import Select from 'primevue/select';
import Slider from 'primevue/slider';
import { capitalCase } from 'change-case';
import useStore from '@theme/stores/playground';
import { useRangeField } from '@theme/composables/useRangeField';
import { stripHash } from '@theme/utils/avatar/colors';
import { navigateToColorKey } from '@theme/components/styles/styleOptionsKeys';
import PlaygroundColorPicker from './PlaygroundColorPicker.vue';
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
const props = defineProps<{
colorName: string;
defaultValues: string[];
hasFill: boolean;
hasAngle: boolean;
hasFillStops: boolean;
contrastTo?: string | null;
}>();
const navigateToColor = inject(navigateToColorKey, null);
function onContrastLinkClick() {
if (props.contrastTo && navigateToColor) {
navigateToColor(props.contrastTo);
}
}
const store = useStore();
const colorKey = `${props.colorName}Color`;
const fillKey = `${colorKey}Fill`;
const angleKey = `${colorKey}Angle`;
const fillStopsKey = `${colorKey}FillStops`;
const colors = computed<string[]>({
get: () => {
const val = store.avatarStyleOptions[colorKey];
if (Array.isArray(val)) return val;
return props.defaultValues;
},
set: (val: string[]) => {
const matchesDefaults =
val.length === props.defaultValues.length &&
val.every((v, i) => v === props.defaultValues[i]);
if (matchesDefaults) {
delete store.avatarStyleOptions[colorKey];
} else {
store.avatarStyleOptions[colorKey] = [...val];
}
},
});
function addColor(hex: string) {
if (!hex) return;
const clean = stripHash(hex).toLowerCase();
if (colors.value.includes(clean)) return;
colors.value = [...colors.value, clean];
}
function removeColor(index: number) {
const next = [...colors.value];
next.splice(index, 1);
colors.value = next;
}
const fillOptions = [
{ label: 'Solid', value: 'solid' },
{ label: 'Linear', value: 'linear' },
{ label: 'Radial', value: 'radial' },
];
const fill = computed({
get: () => {
const val = store.avatarStyleOptions[fillKey];
if (Array.isArray(val)) return val[0] ?? 'solid';
return typeof val === 'string' ? val : 'solid';
},
set: (val: string) => {
if (val === 'solid') {
delete store.avatarStyleOptions[fillKey];
} else {
store.avatarStyleOptions[fillKey] = [val];
}
},
});
const {
isRangeMode,
toggleRangeMode,
resetRangeField,
singleComputed,
rangeComputed,
} = useRangeField(store.avatarStyleOptions);
const angleSingle = singleComputed(angleKey, 0);
const angleRange = rangeComputed(angleKey, 0);
const fillStopsSingle = singleComputed(fillStopsKey, 2);
const fillStopsRange = rangeComputed(fillStopsKey, 2);
</script>
<template>
<div class="pg-color">
<div v-if="contrastTo" class="pg-color-contrast-banner" role="note">
<Link2 :size="14" class="pg-color-contrast-banner-icon" />
<p class="pg-color-contrast-banner-text">
Linked to
<button
type="button"
class="pg-color-contrast-banner-link"
@click="onContrastLinkClick"
>
{{ capitalCase(contrastTo) }}
</button>. The value with the strongest contrast against the chosen
{{ capitalCase(contrastTo).toLowerCase() }} is preferred. Adding more
options here introduces variation, but the highest-contrast value still
dominates.
</p>
</div>
<div class="pg-color-label">
<span>Color</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(colorKey)"
@click="store.resetOption(colorKey)"
/>
</div>
<div class="pg-color-grid">
<div
v-for="(color, i) in colors"
:key="i"
class="pg-color-tile"
:style="{ '--tile-color': `#${color}` }"
@click="removeColor(i)"
>
<div class="pg-color-tile-delete">
<Trash2 :size="16" />
</div>
</div>
<PlaygroundColorPicker
:preset-colors="props.defaultValues"
:colors="colors"
@add="addColor"
/>
</div>
<template v-if="hasFill && colors.length > 0">
<div class="pg-field">
<div class="pg-field-label">
<span>Fill</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(fillKey)"
@click="store.resetOption(fillKey)"
/>
</div>
<Select
v-model="fill"
:options="fillOptions"
option-label="label"
option-value="value"
class="pg-color-fill-select"
/>
</div>
<div class="pg-field" v-if="hasAngle && fill !== 'solid'">
<div class="pg-field-label">
<span>Angle</span>
<Button
size="small"
:severity="isRangeMode(angleKey) ? 'primary' : 'secondary'"
variant="outlined"
v-tooltip="
isRangeMode(angleKey)
? 'Switch to fixed value'
: 'Switch to range'
"
@click="toggleRangeMode(angleKey, 0)"
class="pg-field-toggle"
>
<ArrowLeftRight :size="14" />
</Button>
<PlaygroundFieldReset
v-if="store.isOptionSet(angleKey)"
@click="resetRangeField(angleKey)"
/>
<span class="pg-field-value" v-if="isRangeMode(angleKey)"
>{{ angleRange[0] }}° — {{ angleRange[1] }}°</span
>
<span class="pg-field-value" v-else>{{ angleSingle }}°</span>
</div>
<Slider
v-if="isRangeMode(angleKey)"
v-model="angleRange"
:range="true"
:min="-360"
:max="360"
:step="1"
/>
<Slider v-else v-model="angleSingle" :min="-360" :max="360" :step="1" />
</div>
<div class="pg-field" v-if="hasFillStops && fill !== 'solid'">
<div class="pg-field-label">
<span>Stops</span>
<Button
size="small"
:severity="isRangeMode(fillStopsKey) ? 'primary' : 'secondary'"
variant="outlined"
v-tooltip="
isRangeMode(fillStopsKey)
? 'Switch to fixed value'
: 'Switch to range'
"
@click="toggleRangeMode(fillStopsKey, 2)"
class="pg-field-toggle"
>
<ArrowLeftRight :size="14" />
</Button>
<PlaygroundFieldReset
v-if="store.isOptionSet(fillStopsKey)"
@click="resetRangeField(fillStopsKey)"
/>
<span class="pg-field-value" v-if="isRangeMode(fillStopsKey)"
>{{ fillStopsRange[0] }} — {{ fillStopsRange[1] }}</span
>
<span class="pg-field-value" v-else>{{ fillStopsSingle }}</span>
</div>
<Slider
v-if="isRangeMode(fillStopsKey)"
v-model="fillStopsRange"
:range="true"
:min="2"
:max="5"
:step="1"
/>
<Slider v-else v-model="fillStopsSingle" :min="2" :max="5" :step="1" />
</div>
</template>
</div>
</template>
<style scoped lang="scss">
.pg-color {
display: flex;
flex-direction: column;
gap: 12px;
}
.pg-color-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--ui-c-text-muted);
}
.pg-color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 6px;
}
.pg-color-tile {
aspect-ratio: 1;
border: 1px solid var(--pg-border);
border-radius: var(--vp-radius-xs);
background: repeating-conic-gradient(
var(--vp-c-bg-soft) 0% 25%,
var(--vp-c-bg) 0% 50%
)
50% / 10px 10px;
cursor: pointer;
transition: all var(--duration-fast) ease;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
background: var(--tile-color);
border-radius: calc(var(--vp-radius-xs) - 1px);
}
.pg-color-tile-delete {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: calc(var(--vp-radius-xs) - 1px);
color: white;
opacity: 0;
transition: opacity var(--duration-fast) ease;
z-index: 1;
}
&:hover .pg-color-tile-delete {
opacity: 1;
}
}
.pg-color-fill-select {
width: 100%;
}
.pg-color-contrast-banner {
display: flex;
gap: 8px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.5;
color: var(--ui-c-text-muted);
background: var(--vp-c-bg-soft);
border: 1px solid var(--pg-border);
border-radius: var(--vp-radius-xs);
}
.pg-color-contrast-banner-icon {
flex-shrink: 0;
margin-top: 2px;
opacity: 0.7;
}
.pg-color-contrast-banner-text {
margin: 0;
}
.pg-color-contrast-banner-link {
display: inline;
padding: 0;
margin: 0;
font: inherit;
color: var(--vp-c-brand-1);
background: transparent;
border: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: var(--vp-c-brand-2);
}
}
</style>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useCombinationCount } from '@theme/composables/useCombinationCount';
const count = useCombinationCount();
</script>
<template>
<div v-if="count" class="pg-combo">
<p class="pg-combo-main">
<span class="pg-combo-value">{{ count.display }}</span>
<span class="pg-combo-label">possible avatars</span>
</p>
<p class="pg-combo-hint">
Shrinks as you restrict variants, colors, or probabilities.
<a href="/guides/how-many-unique-avatars/">Learn more </a>
</p>
</div>
</template>
<style scoped lang="scss">
.pg-combo {
display: flex;
flex-direction: column;
gap: 2px;
}
.pg-combo-main {
margin: 0;
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--ui-c-text-muted);
white-space: nowrap;
}
.pg-combo-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--vp-c-text-1);
}
.pg-combo-hint {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--ui-c-text-subtle);
a {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--pg-border);
text-underline-offset: 2px;
white-space: nowrap;
transition:
color var(--duration-fast),
text-decoration-color var(--duration-fast);
&:hover {
color: var(--vp-c-brand-1);
text-decoration-color: var(--vp-c-brand-1);
}
}
}
</style>
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { inject } from 'vue';
import Slider from 'primevue/slider';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import { Weight } from '@lucide/vue';
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
import useStore from '@theme/stores/playground';
import { useRangeField } from '@theme/composables/useRangeField';
import { useVariantWeights } from '@theme/composables/useVariantWeights';
import {
componentPreviewKey,
componentPreviewDefault,
} from '@theme/components/styles/styleOptionsKeys';
const props = defineProps<{
componentName: string;
variants: string[];
hasProbability: boolean;
hasNonDefaultWeights: boolean;
defaultWeights: Record<string, number>;
defaultProbability: number;
}>();
const store = useStore();
const preview = inject(componentPreviewKey, componentPreviewDefault);
const {
showWeights,
variantWeights,
toggleWeights,
toggleVariant,
setWeight,
selectAll,
selectNone,
} = useVariantWeights(
store.avatarStyleOptions,
() => props.componentName,
() => props.variants,
() => props.hasNonDefaultWeights,
() => props.defaultWeights,
);
const { singleComputed } = useRangeField(store.avatarStyleOptions);
const variantKey = `${props.componentName}Variant`;
const probabilityKey = `${props.componentName}Probability`;
const probability = singleComputed(
probabilityKey,
() => props.defaultProbability,
);
function resetVariants() {
store.resetOption(variantKey);
showWeights.value = props.hasNonDefaultWeights;
}
</script>
<template>
<div class="pg-comp-body">
<div class="pg-field" v-if="hasProbability">
<div class="pg-field-label">
<span>Probability</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(probabilityKey)"
@click="store.resetOption(probabilityKey)"
/>
<span class="pg-field-value">{{ probability }}%</span>
</div>
<Slider v-model="probability" :min="0" :max="100" :step="1" />
</div>
<div v-if="variants.length > 0" class="pg-comp-variants">
<div class="pg-field-label">
<span>Variants</span>
<Button
size="small"
:severity="showWeights ? 'primary' : 'secondary'"
variant="outlined"
v-tooltip="showWeights ? 'Hide weights' : 'Show weights'"
@click="toggleWeights"
class="pg-field-toggle"
>
<Weight :size="14" />
</Button>
<Button
label="All"
size="small"
severity="secondary"
variant="outlined"
@click="selectAll"
class="pg-field-toggle"
/>
<Button
label="None"
size="small"
severity="secondary"
variant="outlined"
@click="selectNone"
class="pg-field-toggle"
/>
<PlaygroundFieldReset
v-if="store.isOptionSet(variantKey)"
@click="resetVariants"
/>
</div>
<div class="pg-comp-variants-grid">
<div
v-for="variant in variants"
:key="variant"
class="pg-comp-variant"
:class="{
'pg-comp-variant-off': variantWeights[variant] === undefined,
}"
>
<button
class="pg-comp-variant-btn"
:class="{
'pg-comp-variant-btn-active':
variantWeights[variant] !== undefined,
}"
@click="toggleVariant(variant)"
>
<img
v-if="preview"
:src="preview.toDataUri(componentName, variant)"
alt=""
class="pg-comp-variant-img"
/>
</button>
<span class="pg-comp-variant-name">{{ variant }}</span>
<InputNumber
v-if="showWeights && variantWeights[variant] !== undefined"
v-tooltip="'Weight — higher values increase probability'"
:model-value="variantWeights[variant]"
@update:model-value="
(val: number) => setWeight(variant, Math.max(0, val ?? 0))
"
:min="0"
:min-fraction-digits="0"
:max-fraction-digits="2"
:show-buttons="false"
locale="en-US"
class="pg-comp-variant-weight"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.pg-comp-body {
display: flex;
flex-direction: column;
gap: 24px;
}
.pg-comp-variants {
display: flex;
flex-direction: column;
gap: 8px;
}
.pg-comp-variants-grid {
display: grid;
/* `minmax(0, 1fr)` allows the columns to shrink below the intrinsic
content width — without it long variant names would push the
tracks wider than the container. */
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.pg-comp-variant {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
/* Allow flex children (e.g. the variant name) to shrink so the
ellipsis can engage. */
min-width: 0;
transition: opacity var(--duration-fast) ease;
&-off {
opacity: 0.25;
}
}
.pg-comp-variant-btn {
width: 100%;
padding: 4px;
border: 2px solid transparent;
border-radius: var(--vp-radius-xs);
background: none;
cursor: pointer;
transition: all var(--duration-fast) ease;
&-active {
border-color: var(--p-primary-color);
}
&:hover {
border-color: var(--p-primary-200);
}
}
.pg-comp-variant-img {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
border-radius: 3px;
background: repeating-conic-gradient(
var(--ui-avatar-bg-1, rgba(0, 0, 0, 0.02)) 0% 25%,
var(--ui-avatar-bg-2, rgba(0, 0, 0, 0.07)) 0% 50%
)
50% / 12px 12px;
}
.pg-comp-variant-name {
font-size: 9px;
color: var(--ui-c-text-subtle);
/* `width: 100%` (not `max-width`) ensures the span fills the
column so the ellipsis can clip long names cleanly. */
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.pg-comp-variant-weight {
width: 40px;
:deep(input) {
width: 40px;
height: 20px;
padding: 0 4px;
font-size: 11px;
text-align: center;
}
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { kebabCase } from 'change-case';
import { RotateCcw } from '@lucide/vue';
import PlaygroundOptions from './PlaygroundOptions.vue';
import PlaygroundPreviewPanel from './PlaygroundPreviewPanel.vue';
import useStore from '@theme/stores/playground';
import { track, styleLabel } from '@theme/utils/track';
import Button from 'primevue/button';
const store = useStore();
const { seed } = storeToRefs(store);
function onReset() {
track('Playground: Reset', { style: styleLabel(store.avatarStyleName) });
store.resetOptions();
}
// ?style= query param overrides persisted style (used by "Open in Playground" links)
const styleParam = new URL(window.location.href).searchParams.get('style');
if (styleParam) {
const styleName = kebabCase(styleParam);
if (store.availableAvatarStyles.includes(styleName)) {
store.avatarStyleName = styleName;
store.resetOptions();
}
history.replaceState(null, '', window.location.pathname);
}
</script>
<template>
<div class="pg">
<div class="pg-body">
<aside class="pg-sidebar">
<PlaygroundOptions v-model:seed="seed" />
</aside>
<main class="pg-main">
<div class="pg-main-actions">
<Button
label="Reset"
severity="secondary"
variant="link"
size="small"
class="pg-field-reset"
@click="onReset"
>
<template #icon>
<RotateCcw :size="14" />
</template>
</Button>
</div>
<PlaygroundPreviewPanel :seed="seed" />
</main>
</div>
</div>
</template>
<style scoped lang="scss">
.pg {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px 48px;
}
.pg-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
@media (max-width: 860px) {
grid-template-columns: 1fr;
}
}
.pg-sidebar {
min-width: 0;
@media (min-width: 861px) {
padding-top: 10px;
}
}
.pg-main {
display: flex;
flex-direction: column;
gap: 4px;
@media (min-width: 861px) {
position: sticky;
top: 80px;
align-self: start;
}
@media (max-width: 860px) {
order: -1;
}
&-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
:deep(.p-button-link) {
color: var(--ui-c-text-muted);
}
:deep(.p-button-link:hover .p-button-label) {
text-decoration: none;
}
}
}
</style>
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { Style } from '@dicebear/core';
import { registerCustomStyle } from '@theme/utils/avatar/style';
import useStore from '@theme/stores/playground';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Message from 'primevue/message';
import Textarea from 'primevue/textarea';
import { Upload } from '@lucide/vue';
import { MAX_CUSTOM_STYLE_UPLOAD_BYTES } from './constants';
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
added: [key: string];
}>();
const store = useStore();
const jsonInput = ref('');
const styleName = ref('');
const error = ref('');
const loading = ref(false);
watch(open, (val) => {
if (val) {
jsonInput.value = '';
styleName.value = '';
error.value = '';
}
});
function extractName(definition: Record<string, unknown>): string {
const meta = definition.meta as Record<string, unknown> | undefined;
if (meta) {
const source = meta.source as Record<string, unknown> | undefined;
if (source?.name && typeof source.name === 'string') {
return source.name;
}
const creator = meta.creator as Record<string, unknown> | undefined;
if (creator?.name && typeof creator.name === 'string') {
return creator.name;
}
}
if (definition.$id && typeof definition.$id === 'string') {
return definition.$id;
}
return 'Custom Style';
}
async function submit() {
error.value = '';
if (
new TextEncoder().encode(jsonInput.value).length >
MAX_CUSTOM_STYLE_UPLOAD_BYTES
) {
error.value = 'Style definition is too large (max 1 MB).';
return;
}
loading.value = true;
try {
const parsed = JSON.parse(jsonInput.value);
const name = styleName.value.trim() || extractName(parsed);
new Style(parsed);
const key = store.addCustomStyle(name, parsed);
registerCustomStyle(key, parsed);
emit('added', key);
} catch (err: unknown) {
if (err instanceof SyntaxError) {
error.value = 'Invalid JSON: ' + err.message;
} else if (err instanceof Error) {
error.value =
'Invalid style definition. Check format and required fields.';
} else {
error.value = 'An unknown error occurred.';
}
} finally {
loading.value = false;
}
}
async function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
error.value = '';
if (!file) {
return;
}
if (file.size > MAX_CUSTOM_STYLE_UPLOAD_BYTES) {
error.value = 'File is too large (max 1 MB).';
input.value = '';
return;
}
try {
jsonInput.value = await file.text();
} catch {
error.value = 'Could not read file.';
}
input.value = '';
}
const canSubmit = computed(
() => jsonInput.value.trim().length > 0 && !loading.value,
);
</script>
<template>
<Dialog
v-model:visible="open"
modal
:closable="true"
dismissable-mask
header="Add Custom Style"
:style="{ width: '600px', maxWidth: 'calc(100vw - 32px)' }"
:pt="{ content: { class: 'pg-custom-upload-dialog-content' } }"
>
<div class="pg-custom-upload">
<div class="pg-custom-upload-field">
<label class="pg-custom-upload-label">Style Name (optional)</label>
<InputText
v-model="styleName"
placeholder="My Custom Style"
class="pg-custom-upload-name"
/>
</div>
<div class="pg-custom-upload-field">
<label class="pg-custom-upload-label">Style Definition (JSON)</label>
<Textarea
v-model="jsonInput"
class="pg-custom-upload-textarea"
placeholder="Paste your style definition JSON here..."
:rows="12"
fluid
/>
</div>
<div class="pg-custom-upload-or">
<span>or</span>
</div>
<label class="pg-custom-upload-file">
<Upload :size="16" />
<span>Choose JSON file</span>
<input
type="file"
accept=".json,application/json"
class="pg-custom-upload-file-input"
@change="onFileSelect"
/>
</label>
<Message
v-if="error"
severity="error"
:closable="false"
class="pg-custom-upload-error"
>
{{ error }}
</Message>
<Button
label="Add Style"
:disabled="!canSubmit"
:loading="loading"
@click="submit"
class="pg-custom-upload-submit"
/>
<p class="pg-custom-upload-notice">
Note: Please only upload styles for which you hold the necessary
copyrights. Your data is processed and stored exclusively in your local
browser and never reaches our server.
</p>
</div>
</Dialog>
</template>
<style scoped lang="scss">
.pg-custom-upload {
display: flex;
flex-direction: column;
gap: 16px;
}
.pg-custom-upload-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.pg-custom-upload-label {
font-size: 13px;
font-weight: 600;
color: var(--ui-c-text-muted);
}
.pg-custom-upload-name {
width: 100%;
}
.pg-custom-upload-textarea {
min-height: 200px;
font-family: var(--vp-font-family-mono);
font-size: 12px;
line-height: 1.5;
resize: vertical;
}
.pg-custom-upload-or {
display: flex;
align-items: center;
gap: 12px;
color: var(--ui-c-text-subtle);
font-size: 13px;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--vp-c-border);
}
}
.pg-custom-upload-file {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border: 1px dashed var(--vp-c-border);
border-radius: var(--vp-radius-xs);
font-size: 13px;
font-weight: 500;
color: var(--ui-c-text-muted);
cursor: pointer;
transition: all var(--duration-fast);
&:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}
&-input {
display: none;
}
}
.pg-custom-upload-submit {
width: 100%;
justify-content: center;
}
.pg-custom-upload-notice {
font-size: 13px;
line-height: 1.5;
color: var(--ui-c-text-subtle);
text-align: center;
margin: 0;
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useData } from 'vitepress';
import { ExternalLink } from '@lucide/vue';
import useStore from '@theme/stores/playground';
import type { ThemeOptions } from '@theme/types';
const store = useStore();
const { theme } = useData<ThemeOptions>();
const definitionUrl = computed(
() => theme.value.avatarStyles[store.avatarStyleName]?.definitionUrl,
);
const size = computed(
() => theme.value.avatarStyleSizes.styles[store.avatarStyleName],
);
const fileName = computed(() => {
const url = definitionUrl.value;
if (!url) return null;
const lastSlash = url.lastIndexOf('/');
return lastSlash >= 0 ? url.slice(lastSlash + 1) : url;
});
function formatSize(bytes: number): string {
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} kB`;
return `${bytes} B`;
}
</script>
<template>
<a
v-if="definitionUrl && fileName"
:href="definitionUrl"
target="_blank"
rel="noopener noreferrer"
class="pg-def"
>
<span class="pg-def-main">
<span class="pg-def-name">{{ fileName }}</span>
<ExternalLink :size="13" class="pg-def-icon" />
</span>
<span v-if="size" class="pg-def-size">
<span class="pg-def-size-value">{{ formatSize(size.gzip) }}</span>
<span class="pg-def-size-unit">gzipped</span>
<span class="pg-def-size-sep">·</span>
<span class="pg-def-size-value">{{ formatSize(size.raw) }}</span>
<span class="pg-def-size-unit">raw</span>
</span>
</a>
</template>
<style scoped lang="scss">
.pg-def {
display: flex;
flex-direction: column;
gap: 6px;
text-decoration: none;
color: var(--ui-c-text-muted);
transition: color var(--duration-fast);
&:hover,
&:focus-visible {
color: var(--vp-c-brand-1);
outline: none;
.pg-def-name {
text-decoration-color: var(--vp-c-brand-1);
}
.pg-def-icon {
color: var(--vp-c-brand-1);
transform: translate(1px, -1px);
}
}
}
.pg-def-main {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
line-height: 1.2;
}
.pg-def-name {
font-family: var(--vp-font-family-mono);
font-weight: 500;
color: inherit;
text-decoration: underline;
text-decoration-color: var(--pg-border);
text-underline-offset: 2px;
transition: text-decoration-color var(--duration-fast);
word-break: break-all;
}
.pg-def-icon {
flex-shrink: 0;
color: var(--ui-c-text-subtle);
transition:
color var(--duration-fast),
transform var(--duration-fast) var(--ease-smooth);
}
.pg-def-size {
display: inline-flex;
align-items: baseline;
gap: 4px;
font-size: 11px;
line-height: 1.2;
color: var(--ui-c-text-subtle);
font-variant-numeric: tabular-nums;
}
.pg-def-size-value {
font-weight: 600;
color: var(--ui-c-text-muted);
}
.pg-def-size-sep {
opacity: 0.5;
margin: 0 2px;
}
</style>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue';
import { kebabCase } from 'change-case';
import { ArrowRight } from '@lucide/vue';
import useStore from '@theme/stores/playground';
const store = useStore();
const url = computed(() => `/styles/${kebabCase(store.avatarStyleName)}/`);
</script>
<template>
<a :href="url" class="pg-doc-link">
<span class="pg-doc-main">
<span class="pg-doc-label">Style documentation</span>
<ArrowRight :size="13" class="pg-doc-arrow" />
</span>
<span class="pg-doc-hint"
>Full options reference, code examples, and integration guides.</span
>
</a>
</template>
<style scoped lang="scss">
.pg-doc-link {
display: flex;
flex-direction: column;
gap: 6px;
text-decoration: none;
color: var(--ui-c-text-muted);
transition: color var(--duration-fast);
&:hover,
&:focus-visible {
color: var(--vp-c-brand-1);
outline: none;
.pg-doc-label {
text-decoration-color: var(--vp-c-brand-1);
}
.pg-doc-arrow {
color: var(--vp-c-brand-1);
transform: translateX(2px);
}
}
}
.pg-doc-main {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
line-height: 1.2;
}
.pg-doc-label {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--pg-border);
text-underline-offset: 2px;
transition: text-decoration-color var(--duration-fast);
}
.pg-doc-arrow {
flex-shrink: 0;
color: var(--ui-c-text-subtle);
transition:
color var(--duration-fast),
transform var(--duration-fast) var(--ease-smooth);
}
.pg-doc-hint {
font-size: 11px;
line-height: 1.2;
color: var(--ui-c-text-subtle);
}
</style>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import Button from 'primevue/button';
import { RotateCcw } from '@lucide/vue';
defineEmits<{ click: [] }>();
</script>
<template>
<Button
size="small"
severity="secondary"
variant="outlined"
v-tooltip="'Reset to default'"
class="pg-field-reset pg-field-toggle"
@click="$emit('click')"
>
<RotateCcw :size="14" />
</Button>
</template>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed } from 'vue';
import Select from 'primevue/select';
import useStore from '@theme/stores/playground';
import { webSafeFonts, fontWeights } from '@theme/utils/avatar/fonts';
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
const fontFamilyOptions = [...webSafeFonts];
const fontWeightOptions = [...fontWeights];
defineProps<{
hasFontFamily: boolean;
hasFontWeight: boolean;
}>();
const store = useStore();
const fontFamilyKey = 'fontFamily';
const fontWeightKey = 'fontWeight';
const fontFamily = computed({
get: () => {
const val = store.avatarStyleOptions[fontFamilyKey];
if (typeof val === 'string') return val;
return 'system-ui';
},
set: (val: string) => {
if (val === 'system-ui') {
delete store.avatarStyleOptions[fontFamilyKey];
} else {
store.avatarStyleOptions[fontFamilyKey] = val;
}
},
});
const fontWeight = computed({
get: () => {
const val = store.avatarStyleOptions[fontWeightKey];
if (typeof val === 'number') return val;
return 400;
},
set: (val: number) => {
if (val === 400) {
delete store.avatarStyleOptions[fontWeightKey];
} else {
store.avatarStyleOptions[fontWeightKey] = val;
}
},
});
</script>
<template>
<div class="pg-font">
<div class="pg-field" v-if="hasFontFamily">
<div class="pg-field-label">
<span>Font Family</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(fontFamilyKey)"
@click="store.resetOption(fontFamilyKey)"
/>
</div>
<Select
v-model="fontFamily"
:options="fontFamilyOptions"
class="pg-field-select"
/>
</div>
<div class="pg-field" v-if="hasFontWeight">
<div class="pg-field-label">
<span>Font Weight</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(fontWeightKey)"
@click="store.resetOption(fontWeightKey)"
/>
</div>
<Select
v-model="fontWeight"
:options="fontWeightOptions"
option-label="label"
option-value="value"
class="pg-field-select"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.pg-font {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { UiCard } from '@theme/components/ui';
import PlaygroundLicenseText from './PlaygroundLicenseText.vue';
</script>
<template>
<UiCard padding="sm" class="playground-license-alert">
<strong>
Please note the following license before using the avatar:
</strong>
<PlaygroundLicenseText />
</UiCard>
</template>
<style scoped lang="scss">
.playground-license-alert {
font-size: 14px;
line-height: 1.5;
:deep(strong) {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
}
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { loadAvatarStyle } from '@theme/utils/avatar/style';
import { computed } from 'vue';
import { computedAsync } from '@vueuse/core';
import useStore from '@theme/stores/playground';
import { safeHttpUrl } from '@theme/utils/url';
import { UiLicenseText } from '@theme/components/ui';
const store = useStore();
// For custom styles, load meta from the Style object directly
const customStyleMeta = computedAsync(async () => {
if (!store.isCustomStyle) return null;
try {
const style = await loadAvatarStyle(store.avatarStyleName);
const meta = style.meta();
return {
creator: meta.creator()?.name(),
homepage: meta.creator()?.url(),
title: meta.source()?.name(),
source: meta.source()?.url(),
license: {
name: meta.license()?.name(),
url: meta.license()?.url(),
},
};
} catch {
return null;
}
}, null);
const hasCustomMeta = computed(() => {
const meta = customStyleMeta.value;
return meta && (meta.creator || meta.license?.name);
});
const customSourceUrl = computed(() =>
safeHttpUrl(customStyleMeta.value?.source),
);
const customLicenseUrl = computed(() =>
safeHttpUrl(customStyleMeta.value?.license?.url),
);
const customStyleDisplayName = computed(
() => store.customStyles[store.avatarStyleName]?.name ?? 'Custom Style',
);
</script>
<template>
<p class="playground-license-text" v-if="store.isCustomStyle">
<template v-if="hasCustomMeta">
<span class="playground-license-text-name">{{
customStyleDisplayName
}}</span>
<template v-if="customStyleMeta?.title">
is based on:
<a
v-if="customSourceUrl"
:href="customSourceUrl"
target="_blank"
rel="noopener noreferrer"
>{{ customStyleMeta?.title }}</a
>
<template v-else>{{ customStyleMeta?.title }}</template>
</template>
<template v-if="customStyleMeta?.creator">
by {{ customStyleMeta.creator }}
</template>
<template v-if="customStyleMeta?.license?.name">
, licensed under
<a
v-if="customLicenseUrl"
:href="customLicenseUrl"
target="_blank"
rel="noopener noreferrer"
>{{ customStyleMeta?.license?.name }}</a
>
<template v-else>{{ customStyleMeta?.license?.name }}</template>
</template>
(as stated by the creator; DiceBear has not verified this).
</template>
<template v-else>
This avatar style was provided by a user. License and copyright have not
been verified by DiceBear.
</template>
</p>
<UiLicenseText v-else :style-name="store.avatarStyleName" />
</template>
<style scoped lang="scss">
.playground-license-text {
&-name {
font-weight: 600;
}
a {
font-weight: 500;
color: var(--vp-c-brand-1);
text-decoration-style: dotted;
transition: color var(--duration-fast);
cursor: pointer;
&:hover {
color: var(--vp-c-brand-2);
}
}
}
</style>
@@ -0,0 +1,48 @@
<template>
<div class="playground-loader">
<div class="playground-loader-body">
<div class="playground-loader-spinner" />
<div class="playground-loader-text">Please wait...</div>
</div>
</div>
</template>
<style scoped lang="scss">
.playground-loader {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 180px);
padding: 120px;
&-body {
display: flex;
flex-direction: column;
align-items: center;
}
&-spinner {
width: 48px;
height: 48px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: var(--vp-c-brand-1);
animation: playground-loader 1.1s infinite linear;
}
&-text {
text-align: center;
margin-top: 12px;
}
}
@keyframes playground-loader {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,576 @@
<script setup lang="ts">
import { computed, nextTick, provide, ref, watch } from 'vue';
import { styleUsesVariable } from '@theme/utils/avatar/style';
import {
componentPreviewKey,
navigateToColorKey,
} from '@theme/components/styles/styleOptionsKeys';
import { useStyleOptions } from '@theme/composables/useStyleOptions';
import { capitalCase } from 'change-case';
import useStore from '@theme/stores/playground';
import { track, styleLabel } from '@theme/utils/track';
import { storeToRefs } from 'pinia';
import Accordion from 'primevue/accordion';
import AccordionPanel from 'primevue/accordionpanel';
import AccordionHeader from 'primevue/accordionheader';
import AccordionContent from 'primevue/accordioncontent';
import Tag from 'primevue/tag';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Button from 'primevue/button';
import ToggleSwitch from 'primevue/toggleswitch';
import { Shuffle } from '@lucide/vue';
import PlaygroundComponentSection from './PlaygroundComponentSection.vue';
import PlaygroundColorSection from './PlaygroundColorSection.vue';
import PlaygroundTransformSection from './PlaygroundTransformSection.vue';
import PlaygroundFontSection from './PlaygroundFontSection.vue';
import PlaygroundStyleSelect from './PlaygroundStyleSelect.vue';
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
type ComponentInfo = {
name: string;
variants: string[];
hasProbability: boolean;
defaultProbability: number;
hasNonDefaultWeights: boolean;
defaultWeights: Record<string, number>;
};
type ColorInfo = {
name: string;
key: string;
defaultValues: string[];
hasFill: boolean;
hasAngle: boolean;
hasFillStops: boolean;
contrastTo: string | null;
};
const seed = defineModel<string>('seed', { required: true });
const store = useStore();
const { avatarStyleName } = storeToRefs(store);
const { loadedStyle, descriptor, styleColors, preview } =
useStyleOptions(avatarStyleName);
provide(componentPreviewKey, preview);
const hasFontFamily = computed(() =>
loadedStyle.value
? styleUsesVariable(avatarStyleName.value, 'fontFamily')
: false,
);
const hasFontWeight = computed(() =>
loadedStyle.value
? styleUsesVariable(avatarStyleName.value, 'fontWeight')
: false,
);
const components = computed(() => {
const result: ComponentInfo[] = [];
const style = loadedStyle.value;
// OptionsDescriptor already filters out alias components, so every
// `*Variant` enum here is guaranteed to be a non-alias source.
for (const [key, field] of Object.entries(descriptor.value)) {
if (!key.endsWith('Variant') || field.type !== 'enum' || !field.weighted) {
continue;
}
const name = key.replace(/Variant$/, '');
const comp = style?.components().get(name);
const variantNames = (field.values as string[]) ?? [];
const defaultWeights = comp
? Object.fromEntries(
[...comp.variants()].map(([n, v]) => [n, v.weight()]),
)
: {};
const hasNonDefaultWeights = Object.values(defaultWeights).some(
(w) => w !== 1,
);
result.push({
name,
variants: variantNames,
hasProbability: `${name}Probability` in descriptor.value,
defaultProbability: comp?.probability() ?? 100,
hasNonDefaultWeights,
defaultWeights,
});
}
result.sort((a, b) => a.name.localeCompare(b.name));
return result;
});
const allColors = computed(() => {
const result: ColorInfo[] = [];
for (const [key, field] of Object.entries(descriptor.value)) {
if (field.type !== 'color' || !key.endsWith('Color')) {
continue;
}
if (
key.endsWith('ColorFill') ||
key.endsWith('ColorAngle') ||
key.endsWith('ColorFillStops')
) {
continue;
}
const name = key.replace(/Color$/, '');
result.push({
name,
key,
defaultValues: styleColors.value[name] ?? [],
hasFill: `${key}Fill` in descriptor.value,
hasAngle: `${key}Angle` in descriptor.value,
hasFillStops: `${key}FillStops` in descriptor.value,
contrastTo:
field.type === 'color' && typeof field.contrastTo === 'string'
? field.contrastTo
: null,
});
}
return result;
});
const sortedColors = computed(() => {
const bg = allColors.value.filter((c) => c.name === 'background');
const rest = allColors.value.filter((c) => c.name !== 'background');
rest.sort((a, b) => a.name.localeCompare(b.name));
return [...bg, ...rest];
});
const openColorPanels = ref<string[]>([]);
watch(avatarStyleName, () => {
openColorPanels.value = [];
});
async function navigateToColor(colorName: string) {
const targetKey = `${colorName}Color`;
if (!openColorPanels.value.includes(targetKey)) {
openColorPanels.value = [...openColorPanels.value, targetKey];
}
await nextTick();
const header = document.querySelector(
`[id$="_accordionheader_${targetKey}"]`,
);
if (header instanceof HTMLElement) {
header.scrollIntoView({ behavior: 'smooth', block: 'start' });
header.focus({ preventScroll: true });
}
}
provide(navigateToColorKey, navigateToColor);
function activeCount(comp: ComponentInfo): number {
const val = store.avatarStyleOptions[`${comp.name}Variant`];
if (val === undefined) return comp.variants.length;
if (Array.isArray(val)) return val.length;
if (typeof val === 'object')
return Object.values(val as Record<string, number>).filter((w) => w > 0)
.length;
if (typeof val === 'string') return 1;
return comp.variants.length;
}
function colorCount(color: ColorInfo): number {
const val = store.avatarStyleOptions[color.key];
if (Array.isArray(val)) return val.length;
return color.defaultValues.length;
}
function randomizeSeed() {
seed.value = Math.random().toString(36).substring(2, 10);
track('Playground: Seed Randomized', {
style: styleLabel(avatarStyleName.value),
});
}
const sizeKey = 'size';
const titleKey = 'title';
const idRandomizationKey = 'idRandomization';
const size = computed({
get: () => {
const val = store.avatarStyleOptions[sizeKey];
return typeof val === 'number' ? val : null;
},
set: (val: number | null) => {
if (val === null || Number.isNaN(val)) {
delete store.avatarStyleOptions[sizeKey];
} else {
store.avatarStyleOptions[sizeKey] = val;
}
},
});
const title = computed({
get: () => {
const val = store.avatarStyleOptions[titleKey];
return typeof val === 'string' ? val : '';
},
set: (val: string) => {
if (val === '') {
delete store.avatarStyleOptions[titleKey];
} else {
store.avatarStyleOptions[titleKey] = val;
}
},
});
const idRandomization = computed({
get: () => store.avatarStyleOptions[idRandomizationKey] === true,
set: (val: boolean) => {
if (val) {
store.avatarStyleOptions[idRandomizationKey] = true;
} else {
delete store.avatarStyleOptions[idRandomizationKey];
}
},
});
const onSeedFocus = (e: FocusEvent) => {
const input = e.target as HTMLInputElement;
requestAnimationFrame(() => {
input.setSelectionRange(0, input.value.length);
});
};
</script>
<template>
<div class="pg-options">
<div class="pg-options-group">
<h3 class="pg-options-group-title">Avatar Style</h3>
<PlaygroundStyleSelect />
</div>
<div class="pg-options-group">
<h3 class="pg-options-group-title">General</h3>
<Accordion
:multiple="true"
:value="['__seed']"
class="pg-options-accordion"
>
<AccordionPanel value="__seed">
<AccordionHeader>
<span class="pg-options-label">Seed</span>
</AccordionHeader>
<AccordionContent>
<div class="pg-options-seed-row">
<InputText
v-model="seed"
placeholder="Enter a seed"
class="pg-options-seed"
@focus="onSeedFocus"
/>
<Button
severity="secondary"
variant="outlined"
v-tooltip="'Random seed'"
@click="randomizeSeed"
>
<Shuffle :size="16" />
</Button>
</div>
<p class="pg-options-seed-help">
The seed is the starting value used to generate the avatar.
<strong>The same seed always produces the same avatar</strong>, so
you can reuse it whenever you need the exact same result. For
privacy, prefer an opaque identifier such as a random string or
hashed user ID instead of personal data like names or email
addresses.
</p>
</AccordionContent>
</AccordionPanel>
<AccordionPanel v-if="hasFontFamily || hasFontWeight" value="__font">
<AccordionHeader>
<span class="pg-options-label">Font</span>
</AccordionHeader>
<AccordionContent>
<PlaygroundFontSection
:key="avatarStyleName"
:has-font-family="hasFontFamily"
:has-font-weight="hasFontWeight"
/>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="__transform">
<AccordionHeader>
<span class="pg-options-label">Transform</span>
</AccordionHeader>
<AccordionContent>
<PlaygroundTransformSection :key="avatarStyleName" />
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="__output">
<AccordionHeader>
<span class="pg-options-label">Output</span>
</AccordionHeader>
<AccordionContent>
<div class="pg-options-output">
<div class="pg-field">
<div class="pg-field-label">
<span>Size</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(sizeKey)"
@click="store.resetOption(sizeKey)"
/>
</div>
<InputNumber
v-model="size"
:min="1"
:max="4096"
:step="1"
:use-grouping="false"
placeholder="Auto"
suffix=" px"
show-buttons
button-layout="horizontal"
:input-style="{ width: '6em', textAlign: 'center' }"
>
<template #incrementicon>+</template>
<template #decrementicon></template>
</InputNumber>
<p class="pg-options-field-help">
Output size in pixels. If left empty, the avatar scales to
100% of its container.
</p>
</div>
<div class="pg-field">
<div class="pg-field-label">
<span>Title</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(titleKey)"
@click="store.resetOption(titleKey)"
/>
</div>
<InputText
v-model="title"
placeholder="Accessible title"
class="pg-options-input"
/>
<p class="pg-options-field-help">
Accessible <code>&lt;title&gt;</code> element rendered inside
the SVG. Useful for screen readers.
</p>
</div>
<div class="pg-field">
<div class="pg-field-label pg-options-toggle-row">
<ToggleSwitch v-model="idRandomization" />
<span>Randomize element IDs</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(idRandomizationKey)"
@click="store.resetOption(idRandomizationKey)"
/>
</div>
<p class="pg-options-field-help">
Randomizes all SVG element IDs to avoid conflicts when
embedding multiple avatars in the same page.
</p>
</div>
<p class="pg-options-field-help pg-options-field-help-warn">
<strong>Title</strong> and
<strong>Randomize element IDs</strong>
are not supported by our public
<a href="/how-to-use/http-api/">HTTP-API</a>. You can enable
them by
<a href="/guides/host-the-http-api-yourself/"
>hosting your own instance</a
>.
</p>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
<div class="pg-options-group" v-if="components.length > 0">
<h3 class="pg-options-group-title">Components</h3>
<Accordion :multiple="true" class="pg-options-accordion">
<AccordionPanel
v-for="comp in components"
:key="`${avatarStyleName}-${comp.name}`"
:value="comp.name"
>
<AccordionHeader>
<span class="pg-options-label">{{ capitalCase(comp.name) }}</span>
<Tag
:value="`${activeCount(comp)}/${comp.variants.length}`"
severity="secondary"
class="pg-options-tag"
/>
</AccordionHeader>
<AccordionContent>
<PlaygroundComponentSection
:key="`${avatarStyleName}-${comp.name}`"
:component-name="comp.name"
:variants="comp.variants"
:has-probability="comp.hasProbability"
:default-probability="comp.defaultProbability"
:has-non-default-weights="comp.hasNonDefaultWeights"
:default-weights="comp.defaultWeights"
/>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
<div class="pg-options-group" v-if="allColors.length > 0">
<h3 class="pg-options-group-title">Colors</h3>
<Accordion
v-model:value="openColorPanels"
:multiple="true"
class="pg-options-accordion"
>
<AccordionPanel
v-for="color in sortedColors"
:key="`${avatarStyleName}-${color.key}`"
:value="color.key"
>
<AccordionHeader>
<span class="pg-options-label">{{ capitalCase(color.name) }}</span>
<Tag
:value="String(colorCount(color))"
severity="secondary"
class="pg-options-tag"
/>
</AccordionHeader>
<AccordionContent>
<PlaygroundColorSection
:key="`${avatarStyleName}-${color.key}`"
:color-name="color.name"
:default-values="color.defaultValues"
:has-fill="color.hasFill"
:has-angle="color.hasAngle"
:has-fill-stops="color.hasFillStops"
:contrast-to="color.contrastTo"
/>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div>
</div>
</template>
<style scoped lang="scss">
.pg-options {
display: flex;
flex-direction: column;
gap: 24px;
}
.pg-options-group {
&-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ui-c-text-subtle);
margin: 0 0 8px 2px;
}
}
.pg-options-accordion {
border: 1px solid var(--pg-border);
border-radius: var(--vp-radius-sm);
overflow: hidden;
:deep(.p-accordionpanel:last-child) {
border-width: 0;
}
}
.pg-options-label {
font-weight: 600;
flex: 1;
font-size: 14px;
}
.pg-options-tag {
margin-right: 8px;
}
.pg-options-seed-row {
display: flex;
gap: 8px;
}
.pg-options-seed {
flex: 1;
min-width: 0;
}
.pg-options-seed-help {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--ui-c-text-muted);
}
.pg-options-output {
display: flex;
flex-direction: column;
gap: 20px;
}
.pg-options-input {
width: 100%;
}
.pg-options-toggle-row {
flex-wrap: wrap;
span {
flex: 1;
min-width: 0;
}
}
.pg-options-field-help {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--ui-c-text-muted);
a {
color: var(--vp-c-brand-1);
text-decoration: underline;
&:hover {
color: var(--vp-c-brand-2);
}
}
}
.pg-options-field-help-warn {
padding: 8px 10px;
border-radius: var(--vp-radius-xs);
background: color-mix(in srgb, var(--p-orange-500) 10%, transparent);
color: var(--ui-c-text);
}
</style>
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed } from 'vue';
import { UiAvatar, UiCard } from '../ui';
import useStore from '@theme/stores/playground';
import PlaygroundLicenseText from './PlaygroundLicenseText.vue';
import PlaygroundButtonDownload from './PlaygroundButtonDownload.vue';
import PlaygroundButtonCopy from './PlaygroundButtonCopy.vue';
import PlaygroundButtonHowToUse from './PlaygroundButtonHowToUse.vue';
import PlaygroundCombinationCount from './PlaygroundCombinationCount.vue';
import PlaygroundDefinitionInfo from './PlaygroundDefinitionInfo.vue';
import PlaygroundDocumentationLink from './PlaygroundDocumentationLink.vue';
const props = defineProps<{
seed: string;
}>();
const store = useStore();
const styleOptions = computed(() => ({
...store.avatarStyleOptionsWithoutDefaults,
seed: props.seed,
}));
</script>
<template>
<div class="pg-preview">
<div class="pg-preview-frame">
<div class="pg-preview-canvas">
<UiAvatar
:size="320"
:style-name="store.avatarStyleName"
:style-options="styleOptions"
mode="library"
class="pg-preview-avatar"
/>
</div>
</div>
<div class="pg-preview-actions">
<PlaygroundButtonHowToUse :seed="seed" />
<PlaygroundButtonDownload :seed="seed" />
<PlaygroundButtonCopy :seed="seed" />
</div>
<div class="pg-preview-meta">
<h3 class="pg-preview-meta-title">Details</h3>
<UiCard padding="sm" flush>
<div class="pg-preview-meta-row">
<PlaygroundCombinationCount />
</div>
<div v-if="!store.isCustomStyle" class="pg-preview-meta-row">
<PlaygroundDocumentationLink />
</div>
<div v-if="!store.isCustomStyle" class="pg-preview-meta-row">
<PlaygroundDefinitionInfo />
</div>
<div class="pg-preview-meta-row pg-preview-meta-license">
<PlaygroundLicenseText />
</div>
</UiCard>
</div>
</div>
</template>
<style scoped lang="scss">
.pg-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pg-preview-frame {
width: 100%;
border-radius: var(--vp-radius-sm);
border: 1px solid var(--pg-border);
overflow: hidden;
background: var(--vp-c-bg);
}
.pg-preview-canvas {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 40px 24px;
background: repeating-conic-gradient(
var(--vp-c-bg-soft) 0% 25%,
var(--vp-c-bg) 0% 50%
)
50% / 20px 20px;
@media (max-width: 540px) {
padding: 24px 16px;
}
}
.pg-preview-avatar {
--ui-avatar-bg-1: transparent;
--ui-avatar-bg-2: transparent;
max-width: 100%;
height: auto;
@media (max-width: 540px) {
max-width: 220px;
}
:deep(svg),
:deep(img) {
max-width: 100%;
height: auto;
}
}
.pg-preview-meta {
width: 100%;
margin-top: 16px;
/* Match the left config accordion's surface (PrimeVue content background =
--vp-c-bg-soft) instead of the elevated card surface, so the details panel
reads as the same surface as the options accordion on the left. */
--ui-card-bg: var(--p-content-background);
}
.pg-preview-meta-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ui-c-text-subtle);
margin: 0 0 8px 2px;
border: none;
padding: 0;
}
.pg-preview-meta-row {
padding: 12px 16px;
& + & {
border-top: 1px solid var(--ui-card-border-color);
}
}
.pg-preview-meta-license {
font-size: 12px;
line-height: 1.45;
:deep(p) {
margin: 0;
font-size: inherit;
line-height: inherit;
color: var(--ui-c-text-muted);
}
:deep(a) {
color: var(--ui-c-text-muted);
text-decoration: underline;
text-decoration-color: var(--pg-border);
text-underline-offset: 2px;
transition:
color var(--duration-fast),
text-decoration-color var(--duration-fast);
&:hover {
color: var(--vp-c-brand-1);
text-decoration-color: var(--vp-c-brand-1);
}
}
}
.pg-preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
:deep(> *) {
flex: 1 1 160px;
min-width: 0;
}
:deep(button) {
width: 100%;
justify-content: center;
}
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue';
import Slider from 'primevue/slider';
import Button from 'primevue/button';
import { ArrowLeftRight } from '@lucide/vue';
import useStore from '@theme/stores/playground';
import { useRangeField } from '@theme/composables/useRangeField';
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
const props = withDefaults(
defineProps<{
label: string;
optionKey: string;
min: number;
max: number;
step: number;
unit?: string;
defaultSingle: number;
}>(),
{
unit: '',
},
);
const store = useStore();
const {
isRangeMode,
toggleRangeMode,
resetRangeField,
singleComputed,
rangeComputed,
} = useRangeField(store.avatarStyleOptions);
const singleVal = singleComputed(props.optionKey, () => props.defaultSingle);
const rangeVal = rangeComputed(props.optionKey, () => props.defaultSingle);
const displayRange = computed<[number, number]>(() => {
const [a, b] = rangeVal.value;
return [Math.min(a, b), Math.max(a, b)];
});
</script>
<template>
<div class="pg-field">
<div class="pg-field-label">
<span>{{ label }}</span>
<Button
size="small"
:severity="isRangeMode(optionKey) ? 'primary' : 'secondary'"
variant="outlined"
v-tooltip="
isRangeMode(optionKey) ? 'Switch to fixed value' : 'Switch to range'
"
@click="toggleRangeMode(optionKey, defaultSingle)"
class="pg-field-toggle"
>
<ArrowLeftRight :size="14" />
</Button>
<PlaygroundFieldReset
v-if="store.isOptionSet(optionKey)"
@click="resetRangeField(optionKey)"
/>
<span class="pg-field-value" v-if="isRangeMode(optionKey)"
>{{ displayRange[0] }}{{ unit }} — {{ displayRange[1] }}{{ unit }}</span
>
<span class="pg-field-value" v-else>{{ singleVal }}{{ unit }}</span>
</div>
<Slider
v-if="isRangeMode(optionKey)"
v-model="rangeVal"
:range="true"
:min="min"
:max="max"
:step="step"
/>
<Slider v-else v-model="singleVal" :min="min" :max="max" :step="step" />
</div>
</template>
@@ -0,0 +1,456 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { capitalCase } from 'change-case';
import { useData } from 'vitepress';
import { storeToRefs } from 'pinia';
import { Plus, Trash2 } from '@lucide/vue';
import ChevronRightIcon from '@primevue/icons/chevronright';
import { useStyleFiltering } from '@theme/composables/useStyleFiltering';
import { CUSTOM_CATEGORY } from '@theme/config/styleCategories';
import useStore from '@theme/stores/playground';
import { ThemeOptions } from '@theme/types';
import { UiAvatar } from '../ui';
import { track, styleLabel } from '@theme/utils/track';
import PlaygroundCustomStyleUpload from './PlaygroundCustomStyleUpload.vue';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import Tag from 'primevue/tag';
const store = useStore();
const { avatarStyleName, customStyles } = storeToRefs(store);
const { theme } = useData<ThemeOptions>();
const open = ref(false);
const uploadOpen = ref(false);
const {
searchQuery,
selectedCategories,
selectedLicenses,
availableCategories,
availableLicenses,
groupedStyles,
styleList,
} = useStyleFiltering(theme.value.avatarStyles, customStyles);
function selectStyle(name: string) {
avatarStyleName.value = name;
open.value = false;
track('Playground: Style Selected', { style: styleLabel(name) });
}
function onCustomStyleAdded(key: string) {
uploadOpen.value = false;
selectStyle(key);
}
function deleteCustomStyle(key: string, event: Event) {
event.stopPropagation();
store.removeCustomStyle(key);
}
const customStyleList = computed(() => {
const query = searchQuery.value.toLowerCase().trim();
return Object.entries(store.customStyles)
.map(([key, entry]) => ({ key, name: entry.name }))
.filter((cs) => !query || cs.name.toLowerCase().includes(query));
});
const builtInGroupedStyles = computed(() => {
const result: Record<string, (typeof groupedStyles.value)[string]> = {};
for (const [category, styles] of Object.entries(groupedStyles.value)) {
if (category !== CUSTOM_CATEGORY) {
result[category] = styles;
}
}
return result;
});
const currentDisplayName = computed(() => {
if (store.isCustomStyle) {
return store.customStyles[avatarStyleName.value]?.name ?? 'Custom Style';
}
return capitalCase(avatarStyleName.value);
});
</script>
<template>
<button type="button" class="pg-style-select-trigger" @click="open = true">
<span class="pg-style-select-trigger-avatar">
<UiAvatar
:size="40"
:style-name="avatarStyleName"
:style-options="{ seed: 'JD' }"
mode="library"
/>
</span>
<span class="pg-style-select-trigger-name">{{ currentDisplayName }}</span>
<ChevronRightIcon class="pg-style-select-trigger-chevron" />
</button>
<Dialog
v-model:visible="open"
modal
:closable="true"
dismissable-mask
header="Choose Avatar Style"
:style="{ width: '900px', maxWidth: 'calc(100vw - 32px)' }"
:pt="{ content: { class: 'pg-style-select-dialog-content' } }"
>
<div class="pg-style-select">
<div class="pg-style-select-toolbar">
<InputText v-model="searchQuery" placeholder="Search styles..." fluid />
<MultiSelect
v-model="selectedCategories"
:options="availableCategories"
placeholder="Filter by category"
:showToggleAll="false"
fluid
/>
<MultiSelect
v-model="selectedLicenses"
:options="availableLicenses"
placeholder="Filter by license"
:showToggleAll="false"
fluid
/>
</div>
<div class="pg-style-select-body">
<div
v-if="
selectedCategories.length === 0 ||
selectedCategories.includes(CUSTOM_CATEGORY)
"
class="pg-style-select-group"
>
<h3 class="pg-style-select-group-title">Custom</h3>
<div class="pg-style-select-grid">
<button
class="pg-style-select-card pg-style-select-card-add"
@click="uploadOpen = true"
>
<div class="pg-style-select-card-add-icon">
<Plus :size="24" />
</div>
<span class="pg-style-select-card-name">Add Custom Style</span>
</button>
<div
v-for="cs in customStyleList"
:key="cs.key"
class="pg-style-select-card"
:class="{
'pg-style-select-card-selected': cs.key === avatarStyleName,
}"
@click="selectStyle(cs.key)"
>
<button
class="pg-style-select-card-delete"
@click="deleteCustomStyle(cs.key, $event)"
v-tooltip="'Remove'"
>
<Trash2 :size="12" />
</button>
<div class="pg-style-select-card-avatars">
<UiAvatar
v-for="seed in ['Felix', 'Aneka', 'Milo', 'Luna']"
:key="seed"
:size="40"
:style-name="cs.key"
:style-options="{ seed }"
mode="library"
/>
</div>
<div class="pg-style-select-card-info">
<span class="pg-style-select-card-name">{{ cs.name }}</span>
<Tag
value="Custom"
severity="warn"
class="pg-style-select-card-tag"
/>
</div>
</div>
</div>
</div>
<div
v-for="(styles, category) in builtInGroupedStyles"
:key="category"
class="pg-style-select-group"
>
<h3 class="pg-style-select-group-title">{{ category }}</h3>
<div class="pg-style-select-grid">
<button
v-for="style in styles"
:key="style.name"
class="pg-style-select-card"
:class="{
'pg-style-select-card-selected': style.name === avatarStyleName,
}"
@click="selectStyle(style.name)"
>
<div class="pg-style-select-card-avatars">
<UiAvatar
v-for="avatar in style.avatars"
:key="avatar.seed"
:size="40"
:style-name="style.name"
:style-options="{ seed: avatar.seed }"
mode="http-api"
/>
</div>
<div class="pg-style-select-card-info">
<span class="pg-style-select-card-name">{{
style.displayName
}}</span>
</div>
<span class="pg-style-select-card-creator">{{
style.creator
}}</span>
</button>
</div>
</div>
<div
v-if="styleList.length === 0 && searchQuery"
class="pg-style-select-empty"
>
No styles found matching "{{ searchQuery }}"
</div>
</div>
</div>
</Dialog>
<PlaygroundCustomStyleUpload
v-model:open="uploadOpen"
@added="onCustomStyleAdded"
/>
</template>
<style scoped lang="scss">
.pg-style-select-trigger {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 16px 8px 8px;
background: var(--p-content-background);
border: 1px solid var(--pg-border);
border-radius: var(--vp-radius-xs);
color: var(--p-accordion-header-color);
cursor: pointer;
text-align: left;
transition: color var(--duration-fast);
&:hover {
color: var(--p-accordion-header-hover-color);
}
&:hover &-chevron {
color: var(--p-accordion-header-toggle-icon-hover-color);
}
&:focus-visible {
outline: none;
border-color: var(--p-form-field-focus-border-color);
}
&-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--vp-c-bg-soft);
border-radius: var(--vp-radius-xs);
overflow: hidden;
}
&-name {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-chevron {
flex-shrink: 0;
margin-left: auto;
color: var(--p-accordion-header-toggle-icon-color);
transition: color var(--duration-fast);
}
}
.pg-style-select {
display: flex;
flex-direction: column;
gap: 16px;
}
.pg-style-select-toolbar {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.pg-style-select-body {
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.pg-style-select-group-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ui-c-text-subtle);
margin: 0 0 8px;
}
.pg-style-select-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.pg-style-select-card {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--vp-c-bg-soft);
border: 2px solid transparent;
border-radius: var(--vp-radius-sm);
cursor: pointer;
text-align: left;
transition: all var(--duration-fast);
&:hover {
border-color: var(--vp-c-brand-1);
}
&-selected {
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
}
&-avatars {
display: flex;
gap: 6px;
}
&-info {
display: flex;
align-items: center;
gap: 6px;
}
&-name {
font-size: 13px;
font-weight: 600;
color: var(--vp-c-text-1);
}
&-tag {
font-size: 10px;
}
&-creator {
font-size: 12px;
color: var(--ui-c-text-subtle);
}
&-delete {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-border);
border-radius: var(--vp-radius-xs);
cursor: pointer;
opacity: 0;
transition: all var(--duration-fast);
color: var(--ui-c-text-subtle);
&:hover {
color: var(--vp-c-danger-1);
border-color: var(--vp-c-danger-1);
}
}
&:hover &-delete {
opacity: 1;
}
&-add {
border-style: dashed;
border-color: var(--vp-c-border);
align-items: center;
justify-content: center;
min-height: 120px;
&:hover {
border-color: var(--vp-c-brand-1);
}
&-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--vp-c-bg);
color: var(--ui-c-text-subtle);
transition: all var(--duration-fast);
}
}
&-add:hover &-add-icon {
color: var(--vp-c-brand-1);
background: var(--vp-c-brand-soft);
}
}
.pg-style-select-empty {
text-align: center;
padding: 40px;
color: var(--ui-c-text-subtle);
font-size: 14px;
}
@media (max-width: 640px) {
.pg-style-select-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed } from 'vue';
import Slider from 'primevue/slider';
import SelectButton from 'primevue/selectbutton';
import useStore from '@theme/stores/playground';
import { useRangeField } from '@theme/composables/useRangeField';
import PlaygroundRangeField from './PlaygroundRangeField.vue';
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
const store = useStore();
const { singleComputed } = useRangeField(store.avatarStyleOptions);
const flipKey = 'flip';
const borderRadiusKey = 'borderRadius';
const flipOptions = ['none', 'horizontal', 'vertical', 'both'];
const flip = computed({
get: () => {
const val = store.avatarStyleOptions[flipKey];
return typeof val === 'string' ? val : 'none';
},
set: (val: string) => {
if (val === 'none') {
delete store.avatarStyleOptions[flipKey];
} else {
store.avatarStyleOptions[flipKey] = val;
}
},
});
const borderRadius = singleComputed(borderRadiusKey, 0);
</script>
<template>
<div class="pg-transform">
<div class="pg-transform-body">
<div class="pg-field">
<div class="pg-field-label">
<span>Flip</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(flipKey)"
@click="store.resetOption(flipKey)"
/>
</div>
<SelectButton
v-model="flip"
:options="flipOptions"
:allow-empty="false"
class="pg-transform-flip"
/>
</div>
<PlaygroundRangeField
label="Rotate"
option-key="rotate"
:min="-360"
:max="360"
:step="1"
unit="°"
:default-single="0"
/>
<PlaygroundRangeField
label="Scale"
option-key="scale"
:min="0"
:max="10"
:step="0.01"
:default-single="1"
/>
<div class="pg-field">
<div class="pg-field-label">
<span>Border Radius</span>
<PlaygroundFieldReset
v-if="store.isOptionSet(borderRadiusKey)"
@click="store.resetOption(borderRadiusKey)"
/>
<span class="pg-field-value">{{ borderRadius }}</span>
</div>
<Slider v-model="borderRadius" :min="0" :max="50" :step="1" />
</div>
<PlaygroundRangeField
label="Translate X"
option-key="translateX"
:min="-100"
:max="100"
:step="1"
unit="%"
:default-single="0"
/>
<PlaygroundRangeField
label="Translate Y"
option-key="translateY"
:min="-100"
:max="100"
:step="1"
unit="%"
:default-single="0"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.pg-transform {
display: flex;
flex-direction: column;
gap: 12px;
}
.pg-transform-body {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Let the Flip SelectButton keep its natural button widths and scroll
horizontally on narrow viewports instead of squeezing the labels. */
.pg-transform-flip {
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
:deep(.p-togglebutton) {
flex: 0 0 auto;
}
}
</style>
@@ -0,0 +1,3 @@
export const DOWNLOAD_AVATAR_SIZE = 512;
export const DIALOG_PREVIEW_AVATAR_SIZE = 128;
export const MAX_CUSTOM_STYLE_UPLOAD_BYTES = 1024 * 1024;
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useData } from 'vitepress';
import { ThemeOptions } from '@theme/types';
import { formatLicenseName } from '@theme/utils/format';
import { safeHttpUrl } from '@theme/utils/url';
const { theme } = useData<ThemeOptions>();
const props = defineProps<{
styleName: string;
}>();
const style = computed(() => {
return theme.value.avatarStyles[props.styleName];
});
const sourceUrl = computed(() => safeHttpUrl(style.value.meta?.source));
const homepageUrl = computed(() => safeHttpUrl(style.value.meta?.homepage));
const licenseUrl = computed(() => safeHttpUrl(style.value.meta?.license?.url));
</script>
<template>
<div class="info custom-block">
<p class="custom-block-title">LICENSE</p>
<p>
<template v-if="style.meta?.creator !== 'DiceBear' && style.meta?.title">
<template v-if="style.meta?.license?.name !== 'MIT'">
This avatar style is a remix of:
</template>
<template v-else> This avatar style is based on: </template>
</template>
<a
v-if="sourceUrl"
:href="sourceUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ style.meta?.title ?? 'Design' }}
</a>
<template v-else>{{ style.meta?.title ?? 'Design' }}</template>
by
<a
v-if="homepageUrl"
:href="homepageUrl"
target="_blank"
rel="noopener noreferrer"
>{{ style.meta?.creator }}</a
>
<template v-else>{{ style.meta?.creator }}</template
>, licensed under
<a
v-if="licenseUrl"
:href="licenseUrl"
target="_blank"
rel="noopener noreferrer"
>{{ formatLicenseName(style.meta?.license?.name) }}</a
>
<template v-else>{{
formatLicenseName(style.meta?.license?.name)
}}</template
>.
<template v-if="style.meta?.license?.name !== 'MIT'">
See <a href="#details">details</a> for more information.
</template>
</p>
</div>
</template>
<style lang="scss" scoped>
.custom-block {
padding: 20px;
/* Neutral elevated fill (shared framed-box surface) instead of VitePress's
bluish info tint, so this box matches the preview frame and usage card. */
--vp-custom-block-info-bg: var(--ui-window-bg);
}
</style>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useData } from 'vitepress';
import { ThemeOptions } from '@theme/types';
import { UiCard, UiCode as Code } from '../ui';
import { kebabCase } from 'change-case';
import { safeHttpUrl } from '@theme/utils/url';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const { theme } = useData<ThemeOptions>();
const props = defineProps<{
styleName: string;
}>();
const style = computed(() => {
return theme.value.avatarStyles[props.styleName];
});
const exampleHttpApiUrl = computed(() => {
return `https://api.dicebear.com/10.x/${kebabCase(props.styleName)}/svg`;
});
const exampleDefinitionImport = computed(() => {
return `import definition from '@dicebear/styles/${kebabCase(props.styleName)}.json' with { type: 'json' };`;
});
const exampleCliCommand = computed(() => {
return `dicebear ${props.styleName}`;
});
type Row =
| { label: string; type: 'code'; code: string; lang?: string }
| { label: string; type: 'link'; href: string; text?: string }
| { label: string; type: 'text'; text: string };
const namingRows = computed<Row[]>(() => {
const rows: Row[] = [
{
label: 'Definition Import',
type: 'code',
code: exampleDefinitionImport.value,
lang: 'js',
},
{ label: 'CLI', type: 'code', code: exampleCliCommand.value },
{ label: 'HTTP-API', type: 'link', href: exampleHttpApiUrl.value },
];
if (style.value.definitionUrl) {
rows.push({
label: 'Definition',
type: 'link',
href: style.value.definitionUrl,
});
}
return rows;
});
function linkOrText(
label: string,
text: string | undefined,
url: string | undefined,
): Row | null {
if (!text) {
return null;
}
const href = safeHttpUrl(url);
return href
? { label, type: 'link', href, text }
: { label, type: 'text', text };
}
const sourceRows = computed<Row[]>(() => {
const meta = style.value.meta;
return [
linkOrText('Title', meta.title, undefined),
// Creator/Website both link to the homepage URL.
linkOrText('Creator', meta.creator, meta.homepage),
linkOrText('Website', meta.homepage, meta.homepage),
linkOrText('License', meta.license?.name, meta.license?.url),
linkOrText('Source', meta.source, meta.source),
].filter((row): row is Row => row !== null);
});
</script>
<template>
<UiCard class="style-info-section" title="Naming">
<DataTable :value="namingRows">
<Column field="label" style="width: 200px" />
<Column>
<template #body="{ data }">
<Code
v-if="data.type === 'code'"
:lang="data.lang"
:code="data.code"
/>
<a
v-else-if="data.type === 'link'"
:href="data.href"
target="_blank"
rel="noopener noreferrer"
>
{{ data.text ?? data.href }}
</a>
<template v-else>{{ data.text }}</template>
</template>
</Column>
</DataTable>
</UiCard>
<UiCard class="style-info-section" title="Source">
<DataTable :value="sourceRows">
<Column field="label" style="width: 200px" />
<Column>
<template #body="{ data }">
<a
v-if="data.type === 'link'"
:href="data.href"
target="_blank"
rel="noopener noreferrer"
>
{{ data.text ?? data.href }}
</a>
<template v-else>{{ data.text }}</template>
</template>
</Column>
</DataTable>
</UiCard>
</template>
<style lang="scss" scoped>
.style-info-section {
margin-bottom: 16px;
:deep(.p-datatable-thead) {
display: none;
}
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { useData } from 'vitepress';
import type { ThemeOptions } from '@theme/types';
import { useStyleFiltering } from '@theme/composables/useStyleFiltering';
import { Search, CircleUser, Scale, ArrowRight, Filter } from '@lucide/vue';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import { UiAvatar, UiCard } from '../ui';
const { theme } = useData<ThemeOptions>();
const {
searchQuery,
selectedLicenses,
selectedCategories,
availableLicenses,
availableCategories,
styleList,
groupedStyles,
totalStyles,
} = useStyleFiltering(theme.value.avatarStyles);
</script>
<template>
<div class="style-list">
<UiCard class="style-list-filters">
<div class="style-list-filter-row">
<div class="style-list-filter-group">
<span class="style-list-filter-label">
<Search />
Search
</span>
<InputText
v-model="searchQuery"
:placeholder="`Search ${totalStyles} styles...`"
fluid
/>
</div>
<div class="style-list-filter-group">
<span class="style-list-filter-label">
<Filter />
Category
</span>
<MultiSelect
v-model="selectedCategories"
:options="availableCategories"
placeholder="Filter by category"
:showToggleAll="false"
fluid
/>
</div>
<div class="style-list-filter-group">
<span class="style-list-filter-label">
<Scale />
License
</span>
<MultiSelect
v-model="selectedLicenses"
:options="availableLicenses"
placeholder="Filter by license"
:showToggleAll="false"
fluid
/>
</div>
</div>
</UiCard>
<div
v-for="(styles, category) in groupedStyles"
:key="category"
class="style-list-category"
>
<h2 class="style-list-category-title">{{ category }}</h2>
<div class="style-list-grid">
<UiCard
v-for="style in styles"
:key="style.name"
:href="`/styles/${style.slug}/`"
class="style-list-card"
>
<div class="style-list-card-avatars">
<div
v-for="avatar in style.avatars"
:key="avatar.seed"
class="style-list-card-avatar"
>
<UiAvatar
:style-name="style.slug"
:style-options="{ seed: avatar.seed, size: 80 }"
:alt="`${style.displayName} - ${avatar.seed}`"
/>
</div>
</div>
<div class="style-list-card-content">
<h3 class="style-list-card-name">{{ style.displayName }}</h3>
<div class="style-list-card-meta">
<span class="style-list-card-creator">
<CircleUser />
{{ style.creator }}
</span>
<span class="style-list-card-license">
<Scale />
{{ style.license }}
</span>
</div>
</div>
<div class="style-list-card-arrow">
<ArrowRight />
</div>
</UiCard>
</div>
</div>
<div v-if="styleList.length === 0" class="style-list-empty">
<p v-if="searchQuery">No styles found matching "{{ searchQuery }}"</p>
<p v-else>No styles found with the selected filters</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.style-list {
margin-top: 32px;
&-filters {
margin-bottom: 32px;
}
&-filter-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 16px;
align-items: flex-start;
}
&-filter-group {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
&-filter-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
svg {
width: 16px;
height: 16px;
}
}
&-category {
margin-bottom: 48px;
&-title {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--vp-c-text-1);
margin: 0 0 20px;
border-top: 0 !important;
padding-bottom: 12px;
border-bottom: 2px solid var(--vp-c-border);
}
}
&-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
&-card {
height: 100%;
position: relative;
&-avatars {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
&-avatar {
width: 64px;
height: 64px;
border-radius: var(--vp-radius-sm);
overflow: hidden;
background: var(--vp-c-bg);
box-shadow: var(--vp-shadow-1);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&-content {
flex: 1;
}
&-name {
font-size: 18px;
font-weight: 600;
color: var(--vp-c-text-1);
margin: 0 0 8px;
}
&-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
&-creator,
&-license {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--vp-c-text-2);
svg {
width: 16px;
height: 16px;
color: var(--vp-c-text-3);
}
}
&-arrow {
position: absolute;
top: 20px;
right: 20px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-bg);
border-radius: var(--vp-radius-xs);
opacity: 0;
transform: translateX(-8px);
transition: all var(--duration-fast) ease;
svg {
width: 18px;
height: 18px;
color: var(--vp-c-brand-1);
}
}
&:hover &-arrow {
opacity: 1;
transform: translateX(0);
}
}
&-empty {
text-align: center;
padding: 60px 24px;
color: var(--vp-c-text-2);
p {
margin: 0;
font-size: 16px;
}
}
@media (max-width: 640px) {
&-filter-row {
grid-template-columns: 1fr;
gap: 20px;
}
&-category-title {
font-size: 20px;
}
&-grid {
grid-template-columns: 1fr;
}
&-card-arrow {
display: none;
}
}
}
</style>
@@ -0,0 +1,357 @@
<script setup lang="ts">
import { computed, nextTick, provide, ref, toRef } from 'vue';
import { getScrollOffset, inBrowser } from 'vitepress';
import { styleUsesVariable } from '@theme/utils/avatar/style';
import { watchOnce, watchDebounced } from '@vueuse/core';
import { track } from '@theme/utils/track';
import { capitalCase } from 'change-case';
import { Search } from '@lucide/vue';
import InputText from 'primevue/inputtext';
import StyleOptionsGroup from './StyleOptionsGroup.vue';
import { useStyleOptions } from '@theme/composables/useStyleOptions';
import {
componentNamesKey,
styleColorsKey,
componentPreviewKey,
styleDefaultsKey,
} from './styleOptionsKeys';
type RangeLike = { min: number; max: number } | undefined;
function rangeDefault(
range: RangeLike,
fallback: number,
): number | [number, number] {
if (!range) {
return fallback;
}
return range.min === range.max ? range.min : [range.min, range.max];
}
interface OptionGroup {
id: string;
label: string;
category: 'general' | 'component' | 'color';
options: Record<string, any>;
}
const props = defineProps<{
styleName: string;
}>();
const searchQuery = ref('');
// Track that a user used the option filter, once per non-empty session, so a
// multi-character query counts as a single "searched" interaction, not one per
// keystroke.
let searchTracked = false;
watchDebounced(
searchQuery,
(query) => {
const trimmed = query.trim();
if (!trimmed) {
searchTracked = false;
return;
}
if (!searchTracked) {
searchTracked = true;
track('Docs Options: Search', { style: props.styleName });
}
},
{ debounce: 700 },
);
const {
loadedStyle,
descriptor,
componentNames,
colorNames,
styleColors,
preview,
} = useStyleOptions(toRef(() => props.styleName));
provide(componentNamesKey, componentNames);
provide(componentPreviewKey, preview);
provide(styleColorsKey, styleColors);
const styleDefaults = computed<Record<string, unknown>>(() => {
if (!loadedStyle.value) {
return {};
}
const result: Record<string, unknown> = {
flip: 'none',
fontFamily: 'system-ui',
fontWeight: 400,
scale: 1,
borderRadius: 0,
rotate: 0,
translateX: 0,
translateY: 0,
idRandomization: false,
};
for (const [name, component] of loadedStyle.value.components()) {
const variantDefaults: Record<string, number> = {};
for (const [v, variant] of component.variants()) {
variantDefaults[v] = variant.weight();
}
result[`${name}Variant`] = variantDefaults;
result[`${name}Probability`] = component.probability();
result[`${name}Rotate`] = rangeDefault(component.rotate(), 0);
result[`${name}TranslateX`] = rangeDefault(component.translate().x(), 0);
result[`${name}TranslateY`] = rangeDefault(component.translate().y(), 0);
result[`${name}Scale`] = rangeDefault(component.scale(), 1);
}
for (const [name, values] of Object.entries(styleColors.value)) {
result[`${name}Color`] = values;
result[`${name}ColorFill`] = 'solid';
result[`${name}ColorFillStops`] = 2;
result[`${name}ColorAngle`] = 0;
}
return result;
});
provide(styleDefaultsKey, styleDefaults);
function isComponentOption(key: string, names: string[]): boolean {
return names.some(
(name) =>
key === `${name}Variant` ||
key === `${name}Probability` ||
key === `${name}Rotate` ||
key === `${name}TranslateX` ||
key === `${name}TranslateY` ||
key === `${name}Scale`,
);
}
function isColorOption(key: string, names: string[]): boolean {
return names.some(
(name) =>
key === `${name}Color` ||
key === `${name}ColorFill` ||
key === `${name}ColorFillStops` ||
key === `${name}ColorAngle`,
);
}
function pick(
source: Record<string, any>,
keys: string[],
): Record<string, any> {
const result: Record<string, any> = {};
for (const key of keys) {
if (key in source) {
result[key] = source[key];
}
}
return result;
}
const groups = computed<OptionGroup[]>(() => {
if (!loadedStyle.value) {
return [];
}
const result: OptionGroup[] = [];
const hiddenKeys = new Set<string>();
if (!styleUsesVariable(props.styleName, 'fontFamily')) {
hiddenKeys.add('fontFamily');
}
if (!styleUsesVariable(props.styleName, 'fontWeight')) {
hiddenKeys.add('fontWeight');
}
const generalKeys = Object.keys(descriptor.value).filter(
(key) =>
!hiddenKeys.has(key) &&
!isComponentOption(key, componentNames.value) &&
!isColorOption(key, colorNames.value),
);
if (generalKeys.length > 0) {
result.push({
id: 'general',
label: 'General',
category: 'general',
options: pick(descriptor.value, generalKeys),
});
}
for (const name of componentNames.value) {
const keys = Object.keys(descriptor.value).filter((k) =>
isComponentOption(k, [name]),
);
if (keys.length > 0) {
result.push({
id: `component-${name}`,
label: capitalCase(name),
category: 'component',
options: pick(descriptor.value, keys),
});
}
}
for (const name of colorNames.value) {
const keys = Object.keys(descriptor.value).filter((k) =>
isColorOption(k, [name]),
);
if (keys.length > 0) {
result.push({
id: `color-${name}`,
label: `${capitalCase(name)} Color`,
category: 'color',
options: pick(descriptor.value, keys),
});
}
}
return result;
});
const showSearch = computed(() => Object.keys(descriptor.value).length > 15);
const filteredGroups = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) {
return groups.value;
}
return groups.value
.map((group) => {
const filtered = Object.fromEntries(
Object.entries(group.options).filter(([key]) =>
key.toLowerCase().includes(query),
),
);
return { ...group, options: filtered };
})
.filter((group) => Object.keys(group.options).length > 0);
});
// Option cards render asynchronously, so the browser's initial hash-scroll
// runs before the target anchors exist. Re-do the scroll once they're in.
watchOnce(loadedStyle, async (style) => {
if (!inBrowser || !style) {
return;
}
const hash = window.location.hash;
if (!hash.startsWith('#options-')) {
return;
}
await nextTick();
let id: string;
try {
id = decodeURIComponent(hash).slice(1);
} catch {
return;
}
const target = document.getElementById(id);
if (!target) {
return;
}
const top =
window.scrollY + target.getBoundingClientRect().top - getScrollOffset();
window.scrollTo({ left: 0, top, behavior: 'instant' });
});
</script>
<template>
<div class="style-options" v-if="loadedStyle">
<div class="style-options-search" v-if="showSearch">
<div class="style-options-search-wrapper">
<Search :size="16" class="style-options-search-icon" />
<InputText
v-model="searchQuery"
placeholder="Filter options..."
class="style-options-search-input"
/>
</div>
</div>
<div class="style-options-groups">
<StyleOptionsGroup
v-for="group in filteredGroups"
:key="group.id"
:style-name="styleName"
:label="group.label"
:category="group.category"
:options="group.options"
/>
</div>
<p
class="style-options-empty"
v-if="filteredGroups.length === 0 && searchQuery"
>
No options match "{{ searchQuery }}".
</p>
</div>
</template>
<style scoped lang="scss">
.style-options {
&-search {
margin-bottom: 20px;
&-wrapper {
position: relative;
display: flex;
align-items: center;
}
&-icon {
position: absolute;
left: 14px;
color: var(--vp-c-text-3);
pointer-events: none;
}
&-input {
width: 100%;
padding-left: 40px !important;
border-radius: var(--vp-radius-xs) !important;
font-size: 14px !important;
}
}
&-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
&-empty {
text-align: center;
color: var(--vp-c-text-3);
font-size: 14px;
padding: 32px 0;
}
}
</style>
@@ -0,0 +1,615 @@
<script setup lang="ts">
import { computed, inject, type Component } from 'vue';
import {
Type,
Hash,
ToggleLeft,
List,
Palette,
SlidersHorizontal,
Weight,
Link2,
} from '@lucide/vue';
import Accordion from 'primevue/accordion';
import AccordionPanel from 'primevue/accordionpanel';
import AccordionHeader from 'primevue/accordionheader';
import AccordionContent from 'primevue/accordioncontent';
import { UiCard } from '../ui';
import Tag from 'primevue/tag';
import { capitalCase } from 'change-case';
import StyleOptionsPreview from './StyleOptionsPreview.vue';
import StyleOptionsCodePanel from './StyleOptionsCodePanel.vue';
import Message from 'primevue/message';
import { padColors } from '@theme/utils/avatar/colors';
import { track } from '@theme/utils/track';
import { unsupportedHttpApiOptions } from '@theme/utils/avatar/api';
import {
getOptionDescription,
getOptionExamples,
} from '@theme/utils/styleOptionMeta';
import {
styleColorsKey,
styleColorsDefault,
styleDefaultsKey,
styleDefaultsDefault,
} from './styleOptionsKeys';
export interface OptionValue {
type: string;
values?: string[];
min?: number;
max?: number;
list?: boolean;
weighted?: boolean;
contrastTo?: string;
}
const props = defineProps<{
styleName: string;
name: string;
value: OptionValue;
}>();
const styleColors = inject(styleColorsKey, styleColorsDefault);
const styleDefaults = inject(styleDefaultsKey, styleDefaultsDefault);
function colorExamples(colorName: string, min?: number): string[] {
return padColors(styleColors.value[colorName] ?? [], min);
}
const naturalSort = (a: string | number, b: string | number) => {
return a.toString().localeCompare(b.toString(), undefined, {
numeric: true,
sensitivity: 'base',
});
};
const fieldType = computed(() => props.value.type);
const fieldValues = computed(() => props.value.values ?? []);
const fieldMin = computed(() => props.value.min);
const fieldMax = computed(() => props.value.max);
const isList = computed(() => props.value.list === true);
const isWeighted = computed(() => props.value.weighted === true);
interface BadgeConfig {
label: string;
color: string;
icon: Component;
}
const typeStyles: Record<string, { color: string; icon: Component }> = {
string: { color: 'var(--vp-c-text-2)', icon: Type },
number: { color: 'var(--vp-c-green-1)', icon: Hash },
boolean: { color: 'var(--vp-c-brand-1)', icon: ToggleLeft },
enum: { color: 'var(--vp-c-purple-1)', icon: List },
color: { color: 'var(--vp-c-yellow-1)', icon: Palette },
range: { color: 'var(--vp-c-indigo-1)', icon: SlidersHorizontal },
};
function style(type: string) {
return typeStyles[type] ?? { color: 'var(--vp-c-text-2)', icon: Type };
}
const badges = computed<BadgeConfig[]>(() => {
const type = fieldType.value;
const s = style(type);
switch (type) {
case 'string':
case 'number':
case 'color': {
const result: BadgeConfig[] = [{ label: type, ...s }];
if (isList.value) result.push({ label: `${type}[]`, ...s });
return result;
}
case 'boolean':
return [{ label: 'boolean', ...s }];
case 'enum': {
const result: BadgeConfig[] = [{ label: 'enum', ...s }];
if (isList.value) result.push({ label: 'enum[]', ...s });
if (isWeighted.value)
result.push({ label: 'weighted', color: s.color, icon: Weight });
return result;
}
case 'range':
return [
{ label: 'number', color: s.color, icon: Hash },
{ label: '[min, max]', ...s },
];
default:
return [{ label: type, ...s }];
}
});
const skipPreview = computed(
() =>
fieldType.value === 'boolean' ||
props.name === 'fontFamily' ||
props.name === 'title',
);
const excludeHttpApi = computed(() =>
unsupportedHttpApiOptions.has(props.name),
);
const possibleValues = computed(() => {
if (skipPreview.value) {
return [];
}
return fieldValues.value.slice().sort(naturalSort);
});
const examples = computed<(string | number | boolean)[] | undefined>(() => {
if (skipPreview.value) {
return undefined;
}
if (isWeighted.value && fieldValues.value.length > 0) {
return undefined;
}
return getOptionExamples(props.name, colorExamples);
});
const previewItems = computed(() => {
if (examples.value) {
return examples.value;
}
if (possibleValues.value.length > 0) {
return possibleValues.value;
}
return [];
});
const codeExampleValue = computed(() => {
if (examples.value) {
return isList.value ? examples.value.slice(0, 2) : examples.value[0];
}
if (possibleValues.value.length > 0) {
return isList.value
? possibleValues.value.slice(0, 2)
: possibleValues.value[0];
}
if (fieldType.value === 'boolean') {
return true;
}
if (fieldType.value === 'color') {
return ['b6e3f4'];
}
if (fieldType.value === 'number' || fieldType.value === 'range') {
const dv = styleDefaults.value[props.name];
if (typeof dv === 'number') {
return dv;
}
if (fieldMin.value !== undefined) {
return fieldMin.value;
}
return 0;
}
if (props.name === 'title') {
return 'Avatar';
}
if (props.name === 'fontFamily') {
return 'Arial';
}
return undefined;
});
const headerId = computed(() => {
return `options-${props.name.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
});
const contrastTo = computed(() =>
fieldType.value === 'color' && typeof props.value.contrastTo === 'string'
? props.value.contrastTo
: null,
);
const contrastTargetHref = computed(() => {
const name = contrastTo.value;
if (!name) return null;
const optionName = `${name}Color`;
return `#options-${optionName.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
});
const contrastTargetLabel = computed(() => {
const name = contrastTo.value;
return name ? capitalCase(name) : '';
});
const description = computed(() => getOptionDescription(props.name));
interface MetaItem {
label: string;
value: string;
}
const metaItems = computed<MetaItem[]>(() => {
const items: MetaItem[] = [];
const min = fieldMin.value;
const max = fieldMax.value;
if (min !== undefined && max !== undefined) {
items.push({ label: 'Range', value: `${min}${max}` });
} else if (min !== undefined) {
items.push({ label: 'Min', value: String(min) });
} else if (max !== undefined) {
items.push({ label: 'Max', value: String(max) });
}
const dv = styleDefaults.value[props.name];
if (dv !== undefined) {
let formatted: string;
if (Array.isArray(dv)) {
formatted = `[${dv.join(', ')}]`;
} else if (typeof dv === 'object' && dv !== null) {
formatted = Object.entries(dv)
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
} else {
formatted = String(dv);
}
items.push({ label: 'Default', value: formatted });
}
return items;
});
const descriptionWithHints = computed(() => {
let text = description.value;
if (!text) return undefined;
if (excludeHttpApi.value) text += ' Not available via HTTP-API.';
return text;
});
const weightedExampleValue = computed(() => {
if (!isWeighted.value) return undefined;
const vals = fieldValues.value;
if (vals.length >= 2) {
return { [vals[0]]: 2, [vals[1]]: 1 };
}
if (vals.length === 1) {
return { [vals[0]]: 1 };
}
return { variant01: 2, variant02: 1 };
});
const hasDetails = computed(() => {
return (
previewItems.value.length > 0 ||
codeExampleValue.value !== undefined ||
excludeHttpApi.value
);
});
// Track when a reader expands an option's "Examples" panel. PrimeVue's
// Accordion `update:value` emits the same panel value on both open and close,
// so instead we read the header's resulting `aria-expanded` state after the
// click and only count opens.
function onExamplesToggle(event: MouseEvent) {
const header = event.currentTarget as HTMLElement | null;
requestAnimationFrame(() => {
if (header?.getAttribute('aria-expanded') === 'true') {
track('Docs Options: Examples Opened', {
style: props.styleName,
option: props.name,
});
}
});
}
</script>
<template>
<UiCard class="style-options-card">
<div class="style-options-card-header">
<h3 :id="headerId" tabindex="-1" class="style-options-card-title">
{{ name }}
<a class="header-anchor" :href="`#${headerId}`" aria-hidden="true"></a>
</h3>
<div class="style-options-card-badges">
<Tag
v-for="badge in badges"
:key="badge.label"
:style="{
background: `color-mix(in srgb, ${badge.color} 10%, transparent)`,
color: badge.color,
}"
>
<component v-if="badge.icon" :is="badge.icon" :size="13" />
{{ badge.label }}
</Tag>
</div>
</div>
<p class="style-options-card-description" v-if="descriptionWithHints">
{{ descriptionWithHints }}
</p>
<div
v-if="contrastTo"
class="style-options-card-contrast-banner"
role="note"
>
<Link2 :size="14" class="style-options-card-contrast-banner-icon" />
<p class="style-options-card-contrast-banner-text">
Linked to
<a
:href="contrastTargetHref ?? undefined"
class="style-options-card-contrast-banner-link"
>
{{ contrastTargetLabel }}
</a>. The renderer picks the value with the strongest contrast against the
selected {{ contrastTargetLabel.toLowerCase() }} color, so additional
values mainly serve as fallbacks.
</p>
</div>
<div class="style-options-card-meta" v-if="metaItems.length > 0">
<span
v-for="item in metaItems"
:key="item.label"
class="style-options-card-meta-item"
>
{{ item.label }} <code>{{ item.value }}</code>
</span>
</div>
<Accordion
v-if="hasDetails"
:value="[]"
multiple
lazy
class="style-options-card-details"
>
<AccordionPanel value="0">
<AccordionHeader @click="onExamplesToggle">Examples</AccordionHeader>
<AccordionContent>
<div class="style-options-card-details-body">
<div
class="style-options-card-preview"
v-if="previewItems.length > 0"
>
<div class="style-options-card-preview-grid">
<StyleOptionsPreview
v-for="(val, key) in previewItems"
:key="key"
:style-name="styleName"
:name="name"
:value="val"
/>
</div>
</div>
<div
class="style-options-card-code"
v-if="codeExampleValue !== undefined && !weightedExampleValue"
>
<StyleOptionsCodePanel
:style-name="styleName"
:option-name="name"
:value="codeExampleValue"
:exclude-http-api="excludeHttpApi"
/>
</div>
<div
class="style-options-card-code-group"
v-if="codeExampleValue !== undefined && weightedExampleValue"
>
<div class="style-options-card-code-section">
<span class="style-options-card-code-label">Usage</span>
<StyleOptionsCodePanel
:style-name="styleName"
:option-name="name"
:value="codeExampleValue"
:exclude-http-api="excludeHttpApi"
/>
</div>
<div class="style-options-card-code-section">
<span class="style-options-card-code-label">Weighted</span>
<StyleOptionsCodePanel
:style-name="styleName"
:option-name="name"
:value="weightedExampleValue"
:exclude-http-api="excludeHttpApi"
/>
</div>
</div>
<div v-if="excludeHttpApi" class="style-options-card-message">
<Message severity="warn" :closable="false">
This option is not supported by our public
<a href="/how-to-use/http-api/">HTTP-API</a>. You can enable it
by
<a href="/guides/host-the-http-api-yourself/"
>hosting your own instance</a
>.
</Message>
</div>
</div>
</AccordionContent>
</AccordionPanel>
</Accordion>
</UiCard>
</template>
<style scoped lang="scss">
.style-options-card {
min-width: 0;
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex-shrink: 0;
}
&-title {
margin: 0 !important;
padding: 0;
border: none;
font-size: 16px;
font-weight: 700;
line-height: 1.4;
}
&-description {
margin-top: 10px;
margin-bottom: 0;
font-size: 14px;
line-height: 1.6;
color: var(--vp-c-text-2);
}
&-details {
margin-top: 12px;
--p-accordion-panel-border-color: var(--pg-border);
--p-accordion-header-padding: 8px 0;
--p-accordion-content-padding: 0;
--p-accordion-header-background: transparent;
--p-accordion-header-hover-background: transparent;
--p-accordion-header-active-background: transparent;
--p-accordion-header-active-hover-background: transparent;
--p-accordion-content-background: transparent;
--p-accordion-panel-border-width: 0;
--p-accordion-header-font-size: 13px;
--p-accordion-header-font-weight: 600;
--p-accordion-header-color: var(--vp-c-text-3);
--p-accordion-header-hover-color: var(--vp-c-text-2);
--p-accordion-header-active-color: var(--vp-c-text-3);
--p-accordion-header-active-hover-color: var(--vp-c-text-2);
&-body {
display: flex;
flex-direction: column;
padding-top: 4px;
}
:deep(.p-accordioncontent-wrapper) {
min-width: 0;
}
}
&-contrast-banner {
display: flex;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--vp-c-text-2);
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: var(--vp-radius-xs);
&-icon {
flex-shrink: 0;
margin-top: 2px;
opacity: 0.7;
}
&-text {
margin: 0;
}
&-link {
color: var(--vp-c-brand-1);
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: var(--vp-c-brand-2);
}
}
}
&-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
&-item {
font-size: 13px;
color: var(--vp-c-text-3);
code {
font-size: 12px;
font-weight: 600;
padding: 1px 5px;
border-radius: 3px;
background-color: var(--vp-code-bg);
color: var(--vp-c-text-2);
}
}
}
&-preview {
margin-top: 16px;
&-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 8px;
}
}
&-code {
margin-top: 16px;
}
&-code-group {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
&-code-label {
display: block;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
&-message {
margin-top: 16px;
--p-message-text-font-size: 13px;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More