From 477758f1977b8b8e7846cfb9e049160a9143578e Mon Sep 17 00:00:00 2001 From: Tuan Phung Date: Sun, 11 Oct 2026 11:43:12 +0200 Subject: [PATCH 1/5] Website: SEO foundation for programmatic pages - taxonomy.ts: category and interaction slugs, titles, hand-written intros and use cases, indexable flags and lookups - seo.ts: meaningful keywords, synonyms, variant families, related icons, per-icon titles, descriptions, intros, FAQs, variant notes, seoLint and shared Organization/WebSite/FAQ JSON-LD nodes - git.ts: last-modified dates from Git history (empty on shallow clones) - documentation and icons.json date from Git instead of the build time - sitemap.xml logs seoLint findings once per build --- website/src/lib/git.ts | 55 +++ website/src/lib/seo.ts | 467 ++++++++++++++++++++++++++ website/src/lib/taxonomy.ts | 404 ++++++++++++++++++++++ website/src/pages/documentation.astro | 4 +- website/src/pages/icons.json.ts | 4 +- website/src/pages/sitemap.xml.ts | 9 + 6 files changed, 941 insertions(+), 2 deletions(-) create mode 100644 website/src/lib/git.ts create mode 100644 website/src/lib/seo.ts create mode 100644 website/src/lib/taxonomy.ts diff --git a/website/src/lib/git.ts b/website/src/lib/git.ts new file mode 100644 index 0000000..b037e96 --- /dev/null +++ b/website/src/lib/git.ts @@ -0,0 +1,55 @@ +// Last-modified dates from Git history, so dates in the site change only when content does. +// Never falls back to the build time: no date is better than one that changes on every build. +import { execFileSync } from 'node:child_process'; + +let cache: Map | undefined; + +const git = (...args: string[]) => + execFileSync('git', args, { + cwd: import.meta.env.REPO_DIR, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + +const read = () => { + const dates = new Map(); + // A shallow clone (e.g. a CI checkout) would date everything to its single commit. + if (git('rev-parse', '--is-shallow-repository').trim() !== 'false') return dates; + let date = ''; + // Newest first: a date line (%cI), then the paths of that commit. Keep each path's first date. + for (const line of git('log', '--name-only', '--format=%cI').split('\n')) { + if (!line) continue; + if (/^\d{4}-\d{2}-\d{2}T/.test(line)) date = line; + else if (!dates.has(line)) dates.set(line, date); + } + return dates; +}; + +/** + * Path (relative to the repository root) -> ISO 8601 date of the last commit that touched it. + * Read once per build; empty when Git or its history is unavailable. + */ +export const lastModified = (): Map => { + if (!cache) { + try { + cache = read(); + } catch { + cache = new Map(); + } + } + return cache; +}; + +/** + * The latest commit date over files and directories (a path ending in '/' covers everything + * under it), e.g. lastModifiedOf(['icons/']). Undefined when none of them is in the history. + */ +export const lastModifiedOf = (paths: string[]): string | undefined => { + let latest: string | undefined; + for (const [path, date] of lastModified()) { + const matches = paths.some((p) => (p.endsWith('/') ? path.startsWith(p) : path === p)); + if (matches && (!latest || Date.parse(date) > Date.parse(latest))) latest = date; + } + return latest; +}; diff --git a/website/src/lib/seo.ts b/website/src/lib/seo.ts new file mode 100644 index 0000000..061e9bf --- /dev/null +++ b/website/src/lib/seo.ts @@ -0,0 +1,467 @@ +// Per-icon search copy (titles, descriptions, intros, FAQs), related icons and shared JSON-LD +// nodes. Everything is derived from catalog data, and the template for an icon is picked by a +// stable hash of its id, so pages differ from each other and stay the same between builds. +import { + CATEGORIES, + type Icon, + icons, + INTERACTIONS, + libImport, + shadcnCommand, + SITE, + urls, +} from './icons'; +import { GITHUB, NPM } from './site'; +import { CATEGORY_META, INTERACTION_META } from './taxonomy'; + +export const TITLE_SUFFIX = ' — useAnimations'; +export const MAX_TITLE = 60; +export const MAX_DESCRIPTION = 155; + +/** FNV-1a: a small stable hash, so template choices don't change between builds. */ +const hash = (text: string) => { + let h = 0x811c9dc5; + for (const char of text) h = Math.imul(h ^ char.charCodeAt(0), 0x01000193); + return h >>> 0; +}; +const pick = (icon: Icon, items: T[], salt = '') => items[hash(icon.id + salt) % items.length]; + +const lowerFirst = (text: string) => text[0].toLowerCase() + text.slice(1); +const seconds = (ms: number) => `${Number((ms / 1000).toFixed(2))} s`; +const list = (items: string[]) => + items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`; + +const categoryNames = new Set(CATEGORIES.map((name) => name.toLowerCase())); +const words = (text: string) => + text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); +const squash = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, ''); +/** 'menu-v2' -> 'menu', 'edit-2' -> 'edit'. */ +const baseId = (id: string) => id.replace(/-v\d+$|-\d+$/, ''); +const isNoise = (word: string) => word.length < 2 || /^\d+$/.test(word) || /^v\d+$/.test(word); + +/** + * Keywords that add something to the title: no category names, no words of the title itself, + * no compounds of them ('edit-2', 'loadingv4') and no noise ('2', 'x', single characters). + */ +export const meaningfulKeywords = (icon: Icon): string[] => { + const titleWords = new Set(words(icon.title)); + const own = new Set([squash(icon.title), squash(icon.id), squash(icon.name)]); + return icon.keywords.filter((keyword) => { + const k = keyword.toLowerCase(); + if (categoryNames.has(k) || isNoise(k) || own.has(squash(k))) return false; + return !words(k).every((word) => titleWords.has(word) || isNoise(word)); + }); +}; + +// Groups of terms people use for the same icon; any term finds the others. +const SYNONYM_GROUPS = [ + ['menu', 'hamburger menu', 'burger menu', 'navigation drawer'], + ['trash', 'bin', 'delete', 'garbage', 'recycle bin'], + ['visibility', 'eye', 'show password', 'hide password', 'password toggle'], + ['loading', 'spinner', 'loader', 'progress indicator', 'preloader'], + ['notification', 'bell', 'alarm', 'reminder'], + ['heart', 'like', 'favorite', 'love'], + ['search', 'magnifier', 'magnifying glass', 'find'], + ['settings', 'cog', 'gear', 'preferences'], + ['star', 'rating', 'favorite'], + ['bookmark', 'save for later', 'ribbon'], + ['checkmark', 'tick', 'done', 'success'], + ['check', 'tick', 'done'], + ['plus', 'add', 'create', 'new'], + ['x', 'close', 'cancel', 'dismiss'], + ['download', 'save file', 'export'], + ['upload', 'import', 'attach file'], + ['refresh', 'reload', 'sync'], + ['edit', 'pencil', 'write', 'rename'], + ['copy', 'duplicate', 'clipboard'], + ['share', 'send to'], + ['mail', 'email', 'envelope', 'inbox'], + ['user', 'profile', 'avatar', 'account'], + ['users', 'team', 'group', 'people'], + ['home', 'house', 'start page'], + ['info', 'information', 'about'], + ['help', 'question mark', 'support'], + ['alert', 'warning', 'caution', 'attention'], + ['error', 'failure', 'danger'], + ['lock', 'padlock', 'secure', 'private'], + ['key', 'password', 'access'], + ['link', 'chain', 'url', 'hyperlink'], + ['microphone', 'mic', 'voice input'], + ['volume', 'speaker', 'sound', 'audio'], + ['play-pause', 'play', 'pause', 'media control'], + ['camera', 'photo', 'snapshot'], + ['video', 'video call', 'webcam'], + ['calendar', 'date picker', 'schedule'], + ['clock', 'time', 'watch'], + ['shopping-cart', 'cart', 'basket', 'add to cart'], + ['shopping-bag', 'bag', 'purchase'], + ['credit-card', 'payment', 'card'], + ['dollar-sign', 'price', 'money', 'currency'], + ['tag', 'label', 'price tag'], + ['truck', 'shipping', 'delivery'], + ['map-pin', 'location pin', 'marker', 'place'], + ['globe', 'world', 'language', 'earth'], + ['scroll-down', 'scroll indicator', 'mouse scroll', 'scroll cue'], + ['more-horizontal', 'more', 'ellipsis', 'kebab menu', 'meatballs menu'], + ['more-vertical', 'more', 'ellipsis', 'kebab menu', 'overflow menu'], + ['maximize', 'fullscreen', 'expand'], + ['minimize', 'exit fullscreen', 'collapse'], + ['filter', 'funnel', 'sort'], + ['sun', 'light mode', 'day'], + ['moon', 'dark mode', 'night'], + ['log-in', 'sign in', 'login'], + ['log-out', 'sign out', 'logout'], + ['external-link', 'open in new tab', 'outbound link'], + ['power', 'on/off', 'shutdown'], + ['thumb-up', 'like', 'upvote', 'approve'], + ['thumbs-down', 'dislike', 'downvote'], + ['toggle', 'switch', 'toggle switch'], + ['check-box', 'checkbox', 'tick box'], + ['radio-button', 'radio', 'option button'], + ['send', 'paper plane', 'submit'], + ['twitter-x', 'x', 'x logo'], + ['infinity', 'endless', 'infinite loop'], + ['save', 'floppy disk'], + ['award', 'badge', 'medal', 'achievement'], + ['activity', 'pulse', 'heartbeat'], + ['terminal', 'console', 'command line', 'cli'], + ['code', 'brackets', 'embed'], + ['zap', 'lightning', 'flash', 'bolt'], + ['wifi', 'wireless', 'internet connection'], +]; + +/** Term -> other terms for the same thing, e.g. SYNONYMS.bin includes 'trash' and 'delete'. */ +export const SYNONYMS: Record = {}; +for (const group of SYNONYM_GROUPS) { + for (const term of group) { + SYNONYMS[term] = [...new Set([...(SYNONYMS[term] ?? []), ...group])].filter((t) => t !== term); + } +} + +/** + * Other names people search for, looked up by the icon's id and title; never the title itself. + * Keywords are not looked up: they also name states ('close' on a menu), not just synonyms. + */ +export const synonyms = (icon: Icon): string[] => { + const base = baseId(icon.id); + const terms = new Set([base, icon.id, icon.title.toLowerCase()]); + const title = icon.title.toLowerCase(); + const titleWords = new Set(words(icon.title)); + const found = new Set(); + for (const term of terms) { + for (const synonym of SYNONYMS[term] ?? []) { + if (synonym === title || synonym === base || titleWords.has(synonym)) continue; + found.add(synonym); + } + } + return [...found]; +}; + +const families = Map.groupBy(icons, (icon) => baseId(icon.id)); + +/** The other versions of the same icon (menu, menu-v2, menu-v3…), in site order. */ +export const variantFamily = (icon: Icon): Icon[] => + (families.get(baseId(icon.id)) ?? []).filter((other) => other.id !== icon.id); + +// Words that tie icons together: meaningful keywords plus title words, weighted by rarity (IDF) +// so 'arrow' or 'file' counts for less than 'password'. +const terms = new Map( + icons.map((icon) => [ + icon.id, + new Set( + [...meaningfulKeywords(icon), ...words(icon.title)].filter( + (t) => !isNoise(t) && !categoryNames.has(t) + ) + ), + ]) +); +const documentFrequency = new Map(); +for (const set of terms.values()) { + for (const term of set) documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1); +} +const idf = (term: string) => Math.log(icons.length / (documentFrequency.get(term) ?? 1)); + +/** Up to n related icons: the variant family, then the most shared terms, then the category. */ +export const related = (icon: Icon, n = 12): Icon[] => { + const mine = terms.get(icon.id) ?? new Set(); + const scored = icons + .filter((other) => other.id !== icon.id) + .map((other) => { + let score = 0; + for (const term of terms.get(other.id) ?? []) if (mine.has(term)) score += idf(term); + return { other, score }; + }) + .filter(({ score }) => score > 0) + .toSorted( + (a, b) => + b.score - a.score || + Number(b.other.category === icon.category) - Number(a.other.category === icon.category) + ) + .map(({ other }) => other); + const sameCategory = icons.filter((other) => other.category === icon.category); + const result = new Set(); + for (const other of [...variantFamily(icon), ...scored, ...sameCategory]) { + if (result.size >= n) break; + if (other.id !== icon.id) result.add(other); + } + return [...result]; +}; + +// How each interaction plays, as a verb phrase: "The icon ." +const PLAYS: Record = { + loop: 'loops continuously', + 'click-toggle': 'toggles between two states on click', + 'click-replay': 'replays on every click', + hover: 'plays on hover and reverses on mouse leave', + 'hover-replay': 'plays once on hover', + 'hover-loop': 'loops while hovered', +}; + +/** State names of the static SVGs, e.g. ['eye-off', 'eye']; empty for a single state. */ +const states = (icon: Icon) => { + const names = Object.keys(icon.svgs); + return names.length > 1 ? names : []; +}; + +const fit = (candidates: string[], max: number) => + candidates.find((candidate) => candidate.length <= max) ?? candidates.at(-1)!; + +/** + * The of an icon page: always '<Title> animated icon', with the brand suffix when it fits + * in 60 characters. + */ +export const iconTitle = (icon: Icon): string => { + const t = icon.title; + const label = INTERACTIONS[icon.interaction].label.toLowerCase(); + const [long, short] = pick(icon, [ + [`${t} animated icon for React & Lottie`, `${t} animated icon for React`], + [`Free ${t} animated icon: SVG & Lottie`, `Free ${t} animated icon`], + [`${t} animated icon (${label})`, `${t} animated icon, free`], + [`${t} animated icon for React & shadcn`, `${t} animated icon, MIT`], + ]); + const plain = `${t} animated icon`; + return fit( + [long + TITLE_SUFFIX, short + TITLE_SUFFIX, plain + TITLE_SUFFIX, long, short, plain], + MAX_TITLE + ); +}; + +/** The meta description of an icon page, at most 155 characters. */ +export const iconDescription = (icon: Icon): string => { + const t = icon.title; + const plays = PLAYS[icon.interaction]; + const label = INTERACTIONS[icon.interaction].label.toLowerCase(); + const s = states(icon); + const between = s.length === 2 ? ` between ${s[0]} and ${s[1]}` : ''; + const toggles = + icon.interaction === 'click-toggle' && between ? `toggles${between} on click` : plays; + const svg = Object.keys(icon.svgs).length ? ' and SVG' : ''; + const files = Object.keys(icon.svgs).length ? 'Lottie JSON or SVG' : 'Lottie JSON'; + const templates = [ + [ + `Free ${t} animated icon that ${toggles}. MIT-licensed for React, shadcn/ui, Lottie JSON${svg}.`, + `Free ${t} animated icon that ${plays}. MIT-licensed for React, shadcn/ui and Lottie.`, + ], + [ + `${t} animated icon with a ${label} interaction for React and shadcn/ui. Download the ${files}, free under MIT.`, + `${t} animated icon (${label}) for React and shadcn/ui. Free ${files}, MIT.`, + ], + [ + `Add the ${t} micro animation to React with react-useanimations or shadcn. It ${toggles}; free MIT Lottie${svg} files.`, + `${t} micro animation for React and shadcn. It ${plays}; free MIT Lottie${svg}.`, + ], + [ + `MIT-licensed ${t} animated icon: ${toggles}. Use it in React or shadcn/ui, or get the ${files}.`, + `MIT ${t} animated icon: ${plays}. For React, shadcn/ui and ${files}.`, + ], + ]; + return fit(pick(icon, templates), MAX_DESCRIPTION); +}; + +export type SeoIssue = { + kind: 'duplicate-title' | 'duplicate-description' | 'title-length' | 'description-length'; + ids: string[]; + value: string; +}; + +/** Duplicate and over-long titles and descriptions; for logging at build time, not throwing. */ +export const seoLint = (all: Icon[] = icons): SeoIssue[] => { + const issues: SeoIssue[] = []; + const check = (kind: 'title' | 'description', text: (icon: Icon) => string, max: number) => { + const seen = new Map<string, string[]>(); + for (const icon of all) { + const value = text(icon); + seen.set(value, [...(seen.get(value) ?? []), icon.id]); + if (value.length > max) issues.push({ kind: `${kind}-length`, ids: [icon.id], value }); + } + for (const [value, ids] of seen) { + if (ids.length > 1) issues.push({ kind: `duplicate-${kind}`, ids, value }); + } + }; + check('title', iconTitle, MAX_TITLE); + check('description', iconDescription, MAX_DESCRIPTION); + return issues; +}; + +// Words that say nothing about a use case. +const STOPWORDS = new Set([ + 'and', + 'the', + 'for', + 'with', + 'that', + 'into', + 'from', + 'buttons', + 'button', +]); +const stems = (text: string) => + words(text).filter((w) => w.length > 2 && !STOPWORDS.has(w) && !isNoise(w)); +const sameStem = (a: string, b: string) => + a === b || (Math.min(a.length, b.length) >= 4 && (a.startsWith(b) || b.startsWith(a))); + +/** The category or interaction use case that mentions the icon most, if any does. */ +const bestUseCase = (icon: Icon): string | undefined => { + const mine = [ + icon.title, + baseId(icon.id), + ...meaningfulKeywords(icon), + ...synonyms(icon), + ].flatMap(stems); + const candidates = [ + ...CATEGORY_META[icon.category].useCases, + ...INTERACTION_META[icon.interaction].useCases, + ]; + const scored = candidates.map((useCase) => ({ + useCase, + score: stems(useCase).filter((w) => mine.some((m) => sameStem(w, m))).length, + })); + const top = Math.max(...scored.map(({ score }) => score)); + if (top === 0) return undefined; + return pick( + icon, + scored.filter(({ score }) => score === top).map(({ useCase }) => useCase), + 'use' + ); +}; + +/** Two or three sentences for the top of an icon page, specific to the icon. */ +export const iconIntro = (icon: Icon): string => { + const t = icon.title; + const plays = PLAYS[icon.interaction]; + const time = seconds(icon.durationMs); + const found = bestUseCase(icon); + const useCase = found && lowerFirst(found); + const peers = related(icon, 2).map((other) => other.title); + const set = `the ${icon.category.toLowerCase()} set`; + const first = useCase + ? pick(icon, [ + `The ${t} animated icon ${plays} in ${time}, a good fit for ${useCase}.`, + `${t} is a ${time} micro animation that ${plays}; use it for ${useCase}.`, + `Use the ${t} icon for ${useCase}: it ${plays} and takes ${time}.`, + ]) + : pick(icon, [ + `The ${t} animated icon ${plays} in ${time} and pairs with ${list(peers)} in ${set}.`, + `${t} is a ${time} micro animation from ${set} that ${plays}, like ${list(peers)}.`, + ]); + const s = states(icon); + const second = s.length + ? `Its static SVGs cover the ${list(s)} states, so the resting icon still shows which one is active.` + : INTERACTION_META[icon.interaction].example; + const aka = synonyms(icon).slice(0, 4); + return [first, second, aka.length ? `Also known as ${list(aka)}.` : ''].filter(Boolean).join(' '); +}; + +export type Faq = { question: string; answer: string }[]; + +/** Four questions and answers built from the icon's real data. */ +export const iconFaq = (icon: Icon): Faq => { + const interaction = INTERACTIONS[icon.interaction]; + const u = urls(icon); + const kb = (Buffer.byteLength(icon.lottie) / 1024).toFixed(1); + const svgs = u.svg.length + ? ` and ${u.svg.length === 1 ? 'a static SVG' : `${u.svg.length} static SVGs (${list(Object.keys(icon.svgs))})`}` + : ''; + return [ + { + question: `What triggers the ${icon.title} animation?`, + answer: `${interaction.label}: ${lowerFirst(interaction.description)} One cycle takes ${seconds(icon.durationMs)} at ${Number(icon.fps.toFixed(2))} fps.`, + }, + { + question: `Which files and sizes does the ${icon.title} icon come in?`, + answer: `A Lottie JSON animation (${kb} kB, ${icon.width} × ${icon.height} px artboard)${svgs}, also bundled as a ZIP at ${u.zip}. It scales to any size; 24 or 32 px suits most interfaces.`, + }, + { + question: `How do I use the ${icon.title} icon in React?`, + answer: `Install react-useanimations, add ${libImport(icon).replace(/;$/, '')} and render <UseAnimations animation={${icon.exportName}} size={32} />. The icon follows the text color and respects prefers-reduced-motion.`, + }, + { + question: `Can I add it with shadcn, and do I need lottie-web?`, + answer: `Run ${shadcnCommand(icon)} to add the ${icon.component} component. React needs no extra player; outside React, ${icon.requiresFullPlayer ? "this icon needs lottie-web's full build because it uses expressions" : "lottie-web's light build is enough"}.`, + }, + ]; +}; + +/** One sentence on how `other` differs from `icon`: interaction, duration and states. */ +export const variantNote = (icon: Icon, other: Icon): string => { + const differences: string[] = []; + if (other.interaction !== icon.interaction) differences.push(PLAYS[other.interaction]); + if (Math.abs(other.durationMs - icon.durationMs) >= 50) { + const faster = other.durationMs < icon.durationMs; + differences.push( + `is ${faster ? 'shorter' : 'longer'} at ${seconds(other.durationMs)} instead of ${seconds(icon.durationMs)}` + ); + } + // State names only matter for multi-state icons; a single SVG is named after the icon. + const mine = states(icon); + const theirs = states(other); + if (theirs.join(',') !== mine.join(',')) { + differences.push( + theirs.length ? `has the ${list(theirs)} states` : 'has a single static state' + ); + } + return differences.length + ? `${other.title} ${list(differences)}.` + : `${other.title} has the same ${INTERACTIONS[other.interaction].label.toLowerCase()} interaction and timing with a different drawing.`; +}; + +// JSON-LD nodes shared across pages, linked by @id. +export const ORGANIZATION_ID = `${SITE}/#organization`; +export const WEBSITE_ID = `${SITE}/#website`; + +const SOCIAL = [ + 'https://x.com/useAnimations', + 'https://www.instagram.com/getuseanimations/', + 'https://www.facebook.com/useanimations/', +]; + +export const organizationNode = () => ({ + '@type': 'Organization', + '@id': ORGANIZATION_ID, + name: 'useAnimations', + url: `${SITE}/`, + logo: `${SITE}/logo.svg`, + description: `Free, MIT-licensed animated icons for React, shadcn/ui, Lottie and SVG: ${icons.length} micro interactions that play on hover, click or loop.`, + sameAs: [...SOCIAL, GITHUB, NPM], +}); + +export const websiteNode = () => ({ + '@type': 'WebSite', + '@id': WEBSITE_ID, + name: 'useAnimations', + url: `${SITE}/`, + inLanguage: 'en', + publisher: { '@id': ORGANIZATION_ID }, +}); + +export const faqNode = (faq: Faq) => ({ + '@type': 'FAQPage', + mainEntity: faq.map(({ question, answer }) => ({ + '@type': 'Question', + name: question, + acceptedAnswer: { '@type': 'Answer', text: answer }, + })), +}); diff --git a/website/src/lib/taxonomy.ts b/website/src/lib/taxonomy.ts new file mode 100644 index 0000000..c7483c4 --- /dev/null +++ b/website/src/lib/taxonomy.ts @@ -0,0 +1,404 @@ +// Categories and interactions as browsable collections: slugs, titles and hand-written copy for +// their pages, plus lookups. The copy is specific on purpose; these pages must not read as filler. +import type { Interaction } from 'react-useanimations'; +import { CATEGORIES, type Icon, icons, INTERACTIONS } from './icons'; + +export type CollectionMeta = { + /** URL segment, e.g. 'maps-location'. */ + slug: string; + /** Page heading and <title> stem, e.g. 'Animated loading icons'. */ + title: string; + /** Two or three sentences on what the icons are for in real interfaces. */ + intro: string; + /** Concrete UI places to use them, sentence case, no trailing period. */ + useCases: string[]; +}; + +export type CategoryMeta = CollectionMeta & { + /** Category name as in catalog.json, e.g. 'Maps & location'. */ + name: string; + /** False for 'Other' and categories with fewer than 3 icons: no page in the sitemap. */ + indexable: boolean; +}; + +export type InteractionMeta = CollectionMeta & { + interaction: Interaction; + /** One standalone sentence of typical uses, e.g. mute/unmute, show/hide password, menu/close. */ + example: string; +}; + +/** 'Maps & location' -> 'maps-location', 'Text editing' -> 'text-editing'. */ +export const categorySlug = (name: string) => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + +/** Interactions are already URL-safe ('click-toggle'), so the slug is the key itself. */ +export const interactionSlug = (interaction: Interaction) => interaction; + +type Copy = Pick<CollectionMeta, 'title' | 'intro' | 'useCases'>; + +const CATEGORY_COPY: Record<string, Copy> = { + Alerts: { + title: 'Animated alert icons', + intro: + 'Warning triangles, alert circles and stop signs that pulse just enough to be noticed without flashing. Put them next to a failed form field, at the start of an error banner or in a status column, where a still icon is easy to scroll past.', + useCases: [ + 'Inline validation errors under form fields', + 'Error and warning banners at the top of a page', + 'Failed rows in a deploy or job status table', + 'Destructive-action confirmation dialogs', + 'System status pages during an incident', + ], + }, + Notifications: { + title: 'Animated notification bell icons', + intro: + 'Bell icons that ring for new activity or switch between bell and bell-off. A short swing on the bell draws the eye to an unread count, and the toggling versions double as the mute control for a channel or thread.', + useCases: [ + 'Notification centre button in an app header', + 'Unread badge that rings when a new message arrives', + 'Mute and unmute switch for a channel or thread', + 'Email and push notification settings', + 'Reminder and alert subscription buttons', + ], + }, + Navigation: { + title: 'Animated navigation and arrow icons', + intro: + 'Hamburger menus that morph into a close button, arrows, chevrons and corner arrows that nudge in their direction on hover. They make it obvious which way a control leads: open the drawer, go back, expand the row, leave the site.', + useCases: [ + 'Hamburger menu that turns into a close button on mobile', + 'Previous and next buttons in carousels and pagination', + 'Accordion and dropdown chevrons', + 'Back-to-top and scroll-down cues', + 'Sign-in and sign-out links', + 'External link markers and sidebar toggles', + ], + }, + Action: { + title: 'Animated action icons', + intro: + 'The verbs of an interface: save, delete, download, refresh, like, bookmark, search and settings. Each one confirms the click or hover it belongs to, so a heart fills when you like a post and a trash can lid lifts before you delete.', + useCases: [ + 'Like, favourite and bookmark buttons on cards', + 'Download, upload and save buttons that confirm the action', + 'Show and hide password toggle in a login form', + 'Refresh and sync buttons in dashboards', + 'Delete buttons in tables and file lists', + 'Search field that turns into a clear button', + ], + }, + Content: { + title: 'Animated file, folder and user icons', + intro: + 'Files, folders, books, mail, attachments and people. They label the things a user works with, and the add, remove and check variants make list and team management feel responsive.', + useCases: [ + 'File managers and document lists', + 'Attachment and copy-to-clipboard buttons', + 'Inbox and archive views in mail clients', + 'Team member lists with add and remove user actions', + 'Share menus for documents and links', + ], + }, + Form: { + title: 'Animated form control icons', + intro: + 'A checkbox, a radio button and a toggle switch that animate between their checked and unchecked states. Use them where a plain input feels flat, such as onboarding checklists and settings screens, while keeping the state readable at a glance.', + useCases: [ + 'Settings switches for features and preferences', + 'Onboarding and to-do checklists', + 'Single-choice options in surveys and plan pickers', + 'Terms and consent checkboxes', + ], + }, + Media: { + title: 'Animated media player icons', + intro: + 'Play and pause, volume, microphone, camera, skip and shuffle for audio and video players. The toggling icons switch between their two states, play to pause or mic to muted, so the button always shows what a click will do.', + useCases: [ + 'Play and pause buttons in audio and video players', + 'Mute and unmute controls in video calls', + 'Camera on and off toggles in meeting apps', + 'Playlist controls for skip, repeat and shuffle', + 'Casting and AirPlay buttons', + 'Podcast and music app controls', + ], + }, + 'Text editing': { + title: 'Animated text editing icons', + intro: + 'Bold, italic, underline, alignment and list icons for rich text toolbars. A quick click animation confirms the formatting was applied, which helps in editors where the change to the text itself is subtle.', + useCases: [ + 'Rich text editor toolbars', + 'Comment and message composers', + 'CMS and blog post editors', + 'Markdown and note-taking apps', + 'Text alignment controls in design tools', + ], + }, + Communication: { + title: 'Animated communication icons', + intro: + 'Phones, chat bubbles, send buttons, voicemail and RSS. The call icons cover the incoming, outgoing, missed and forwarded states a call log needs, and the send icon gives a message a small send-off on click.', + useCases: [ + 'Send button in chat and comment forms', + 'Call logs with incoming, outgoing and missed calls', + 'Contact and support buttons', + 'Chat widget launchers', + 'Mention and email address fields', + ], + }, + Devices: { + title: 'Animated device icons', + intro: + 'Phones, tablets, monitors, watches and printers, plus the connection indicators around them: wifi, Bluetooth and battery. They work for device pickers, responsive preview switchers and connectivity status.', + useCases: [ + 'Responsive preview switchers for mobile, tablet and desktop', + 'Wifi and Bluetooth connection status', + 'Battery and charging indicators', + 'Device lists in account security settings', + 'Print buttons on invoices and reports', + ], + }, + Weather: { + title: 'Animated weather icons', + intro: + 'Sun, moon, clouds, rain, snow, lightning and wind for forecasts and anything that follows the time of day. The sun and moon also make a natural light and dark mode switch.', + useCases: [ + 'Weather widgets and forecast cards', + 'Light and dark theme switches', + 'Sunrise and sunset times in travel apps', + 'Temperature readings on smart home dashboards', + 'Energy and power status indicators', + ], + }, + 'Maps & location': { + title: 'Animated map and location icons', + intro: + 'Map pins, navigation arrows, globes, targets and anchors. A pin that drops on hover helps a store locator or delivery address stand out, and the globe works as a language or region switcher.', + useCases: [ + 'Store locators and address fields', + 'Current location and directions buttons', + 'Language and region selectors', + 'Delivery and order tracking', + 'Travel and booking apps', + ], + }, + Commerce: { + title: 'Animated e-commerce icons', + intro: + 'Shopping carts, bags, price tags, gifts, credit cards and delivery trucks for online stores. An add-to-cart animation confirms the click without a toast, and the truck and package icons give order status a little life.', + useCases: [ + 'Add-to-cart buttons on product cards', + 'Cart and bag buttons in a store header', + 'Checkout and payment method selection', + 'Discount, coupon and sale badges', + 'Shipping and order tracking steps', + 'Gift cards and gift wrap options', + ], + }, + Charts: { + title: 'Animated chart icons', + intro: + 'Bar charts, a pie chart and trending arrows for analytics and reporting. The trending-up and trending-down icons show at a glance whether a metric rose or fell, which suits KPI cards and changelogs of numbers.', + useCases: [ + 'KPI cards with rising or falling metrics', + 'Analytics and reports navigation items', + 'Dashboard empty states', + 'Finance and portfolio summaries', + ], + }, + Development: { + title: 'Animated developer icons', + intro: + 'Code brackets, a terminal, Git branches, commits, merges and pull requests, plus servers and databases. Made for developer tools, documentation sites and status pages where Git and infrastructure terms are part of the interface.', + useCases: [ + 'Pull request, merge and branch status in developer tools', + 'Code snippet and embed buttons in documentation', + 'Terminal and CLI sections on landing pages', + 'Server and database health on status pages', + 'Keyboard shortcut hints with the command key', + ], + }, + 'Design & layout': { + title: 'Animated design and layout icons', + intro: + 'Grids, columns, layers, crop, pen tool and cursor icons for editors and builders. They fit toolbars where users arrange content, switch between grid and table views, or pick a drawing tool.', + useCases: [ + 'Grid, list and table view switchers', + 'Layer panels in design and slide tools', + 'Crop and cut tools in image editors', + 'Page builder and layout pickers', + 'Drag-to-move handles', + ], + }, + 'Shapes & math': { + title: 'Animated shape and math icons', + intro: + 'Circles, squares, triangles, hexagons, an octagon and division signs. Use them as shape tools in drawing apps, as calculator operators, or as neutral markers in diagrams and onboarding steps.', + useCases: [ + 'Shape tools in whiteboard and drawing apps', + 'Calculator and formula editor operators', + 'Bullet and step markers in diagrams', + 'Quiz and game interfaces', + ], + }, + Loading: { + title: 'Animated loading icons', + intro: + 'Spinners and loaders that loop for as long as something is in progress. Put one inside a submit button while a request runs, in place of content that is still loading, or on a full-screen splash; they hold still when the user prefers reduced motion.', + useCases: [ + 'Submit buttons waiting on a request', + 'Placeholders while a list or chart loads', + 'Infinite scroll and load-more indicators', + 'Full-screen splash and route transitions', + 'File upload and processing progress', + ], + }, + 'Social media': { + title: 'Animated social media icons', + intro: + 'Brand icons for social networks and the tools developers and designers share work on: X, Instagram, YouTube, Discord, Telegram, Figma and more. They animate on hover, which makes footer and profile links feel clickable.', + useCases: [ + 'Social links in a site footer', + 'Share buttons on blog posts', + 'Author and team profile links', + 'Community invite buttons for chat servers', + 'Sign-in with a social or developer account', + ], + }, + Other: { + title: 'More animated icons', + intro: + 'Icons that do not belong to a larger group: emoji faces for feedback, a coffee cup, a clock, a pulse line and a scroll-down cue. The scroll-down icon is a common hero section hint that there is more below.', + useCases: [ + 'Scroll-down hints at the bottom of a hero section', + 'Happy, neutral and sad faces in feedback widgets', + 'Activity and uptime indicators', + 'Opening hours and time pickers', + 'Buy-me-a-coffee and support links', + ], + }, +}; + +const byCategory = Map.groupBy(icons, (icon) => icon.category); + +/** Every category in site order, with its page copy. */ +export const CATEGORY_META: Record<string, CategoryMeta> = Object.fromEntries( + CATEGORIES.map((name) => { + const copy = CATEGORY_COPY[name]; + if (!copy) throw new Error(`taxonomy: no copy for category "${name}"`); + const indexable = name !== 'Other' && (byCategory.get(name)?.length ?? 0) >= 3; + return [name, { name, slug: categorySlug(name), indexable, ...copy }]; + }) +); + +const INTERACTION_COPY: Record<Interaction, Copy & { example: string }> = { + loop: { + title: 'Looping animated icons', + intro: + 'These icons play continuously for as long as they are on screen, so they suit states that last: something is loading, a warning is active, a terminal is waiting. With reduced motion preferred they stop on their first frame and still read as a normal icon.', + useCases: [ + 'Loading spinners and progress indicators', + 'Active warning and error states', + 'Live and recording indicators', + 'Scroll-down cues in hero sections', + 'Attention markers for new notifications', + ], + example: + 'Looping icons typically show loading spinners, active alerts, a ringing bell or a scroll-down hint.', + }, + 'click-toggle': { + title: 'Click-to-toggle animated icons', + intro: + 'Each click animates between two states and stays there, which makes these icons the visual half of an on/off control. The icon always shows the current state, so pair it with aria-pressed or a checked attribute and the button explains itself.', + useCases: [ + 'Mute and unmute buttons', + 'Show and hide password fields', + 'Hamburger menu and close buttons', + 'Play and pause controls', + 'Like, star and bookmark toggles', + 'Checkboxes, radio buttons and switches', + ], + example: + 'Click-to-toggle icons typically drive mute/unmute, show/hide password, menu/close, play/pause and like buttons.', + }, + 'click-replay': { + title: 'Click animated icons', + intro: + 'These icons play once from the start every time they are clicked, a small confirmation that the action went through. They fit one-shot actions that do not leave a state behind: send, save, refresh, download, print or delete.', + useCases: [ + 'Send buttons in chat and comment forms', + 'Save, download and upload buttons', + 'Refresh and rotate buttons', + 'Add-to-cart buttons', + 'Text formatting buttons in editors', + ], + example: + 'Click icons typically confirm send, save, refresh, download, add-to-cart and formatting buttons.', + }, + hover: { + title: 'Hover animated icons', + intro: + 'These icons play forward when the pointer enters and run backward when it leaves, so the motion follows the cursor and never ends half-way. They suit navigation items and buttons that hint at what they open: a trash lid lifts, a settings cog turns, a folder opens.', + useCases: [ + 'Sidebar and navigation menu items', + 'Delete buttons that lift the lid before the click', + 'Settings and profile links', + 'Zoom in and zoom out controls', + 'Social links in a footer', + ], + example: + 'Hover icons typically mark navigation items, delete buttons, settings, zoom controls and social links.', + }, + 'hover-replay': { + title: 'Hover-to-play animated icons', + intro: + 'These icons play once when the pointer enters and finish on their own, which keeps quick passes over a toolbar from turning into jittery motion. Most of the set works this way: arrows, files, devices, weather and brand icons that come alive on hover.', + useCases: [ + 'Feature lists and pricing tables', + 'Arrow links and call-to-action buttons', + 'Toolbar buttons in editors and dashboards', + 'Cards in a landing page grid', + 'Footer and contact links', + ], + example: + 'Hover-to-play icons typically liven up arrow links, feature lists, toolbar buttons and landing page cards.', + }, + 'hover-loop': { + title: 'Hover loop animated icons', + intro: + 'These icons loop while the pointer stays on them and finish the current cycle after it leaves, so they never stop mid-motion. They work for links that invite a longer look, mostly social and community icons.', + useCases: [ + 'Social media links in headers and footers', + 'Community and chat invite buttons', + 'Portfolio and profile links', + 'Language and region pickers', + ], + example: + 'Hover loop icons typically mark social links, community invites, portfolio links and language pickers.', + }, +}; + +/** Every interaction, keyed like INTERACTIONS in icons.ts, with its page copy. */ +export const INTERACTION_META: Record<Interaction, InteractionMeta> = Object.fromEntries( + (Object.keys(INTERACTIONS) as Interaction[]).map((interaction) => [ + interaction, + { interaction, slug: interactionSlug(interaction), ...INTERACTION_COPY[interaction] }, + ]) +) as Record<Interaction, InteractionMeta>; + +/** The category's metadata from its URL slug, or undefined. */ +export const categoryBySlug = (slug: string): CategoryMeta | undefined => + Object.values(CATEGORY_META).find((meta) => meta.slug === slug); + +/** The icons of a category, in site order. */ +export const iconsByCategory = (name: string): Icon[] => byCategory.get(name) ?? []; + +const byInteraction = Map.groupBy(icons, (icon) => icon.interaction); + +/** The icons with an interaction, in site order. */ +export const iconsByInteraction = (interaction: Interaction): Icon[] => + byInteraction.get(interaction) ?? []; diff --git a/website/src/pages/documentation.astro b/website/src/pages/documentation.astro index 0b2a629..b2e166a 100644 --- a/website/src/pages/documentation.astro +++ b/website/src/pages/documentation.astro @@ -1,6 +1,7 @@ --- import { compiledContent, getHeadings } from '../content/documentation.md'; import Layout from '../layouts/Layout.astro'; +import { lastModifiedOf } from '../lib/git'; import { icons, SITE } from '../lib/icons'; import { withSite } from '../lib/site'; @@ -22,7 +23,8 @@ const jsonLd = { headline: 'useAnimations documentation', description: 'How to use the useAnimations animated icons in React and shadcn/ui.', url: `${SITE}/documentation`, - dateModified: new Date().toISOString().slice(0, 10), + // The last commit to the documentation, not the build date; omitted without Git history. + dateModified: lastModifiedOf(['website/src/content/documentation.md'])?.slice(0, 10), publisher: { '@type': 'Organization', name: 'useAnimations', url: `${SITE}/` }, }, { diff --git a/website/src/pages/icons.json.ts b/website/src/pages/icons.json.ts index 67b5d0f..19c4ccf 100644 --- a/website/src/pages/icons.json.ts +++ b/website/src/pages/icons.json.ts @@ -9,6 +9,7 @@ import { urls, libImport, } from '../lib/icons'; +import { lastModifiedOf } from '../lib/git'; import { json } from '../lib/http'; export const GET = () => @@ -18,7 +19,8 @@ export const GET = () => 'Free library of animated icons (24/32 px artboards) for React, with a built-in interaction each. Every icon also has direct SVG and Lottie JSON URLs.', homepage: `${SITE}/`, documentation: `${SITE}/documentation.md`, - updated: new Date().toISOString().slice(0, 10), + // The last commit to the icon set; omitted without Git history. + updated: lastModifiedOf(['icons/'])?.slice(0, 10), license: { name: 'MIT', url: 'https://opensource.org/license/mit/', diff --git a/website/src/pages/sitemap.xml.ts b/website/src/pages/sitemap.xml.ts index b911974..292f08e 100644 --- a/website/src/pages/sitemap.xml.ts +++ b/website/src/pages/sitemap.xml.ts @@ -1,5 +1,14 @@ import { icons, SITE } from '../lib/icons'; import { text } from '../lib/http'; +import { seoLint } from '../lib/seo'; + +// Duplicate or over-long icon titles and descriptions, logged once per build. +const issues = seoLint(); +console.warn( + issues.length + ? `seoLint: ${issues.length} issue(s)\n${issues.map((i) => ` ${i.kind} [${i.ids.join(', ')}] (${i.value.length}) ${i.value}`).join('\n')}` + : 'seoLint: no duplicate or over-long icon titles and descriptions' +); const pages = [ '/', From e14d1fdb3ea8eb133f15c11a12bd18c772f420e5 Mon Sep 17 00:00:00 2001 From: Tuan Phung <tuan@phung.cz> Date: Sun, 11 Oct 2026 11:56:55 +0200 Subject: [PATCH 2/5] Website: hub pages for icons, categories, interactions, Lottie and shadcn All-icons index at /icons, category and interaction hubs with full icon grids, FAQ and CollectionPage/ItemList/BreadcrumbList JSON-LD, Markdown versions of category pages, /lottie-icons and /shadcn-animated-icons landing pages, a Use without React docs section, and nav/footer links. --- website/src/components/Breadcrumbs.astro | 25 ++ website/src/components/FaqList.astro | 24 ++ website/src/components/IconGrid.astro | 28 ++ website/src/content/documentation.md | 122 ++++++- website/src/lib/hubs.ts | 279 +++++++++++++++ website/src/lib/site.ts | 10 +- website/src/pages/categories/[slug].astro | 136 +++++++ website/src/pages/categories/[slug].md.ts | 73 ++++ website/src/pages/categories/index.astro | 82 +++++ website/src/pages/icons.astro | 88 +++++ website/src/pages/interactions/[slug].astro | 243 +++++++++++++ website/src/pages/interactions/index.astro | 111 ++++++ website/src/pages/lottie-icons.astro | 337 ++++++++++++++++++ website/src/pages/shadcn-animated-icons.astro | 179 ++++++++++ 14 files changed, 1733 insertions(+), 4 deletions(-) create mode 100644 website/src/components/Breadcrumbs.astro create mode 100644 website/src/components/FaqList.astro create mode 100644 website/src/components/IconGrid.astro create mode 100644 website/src/lib/hubs.ts create mode 100644 website/src/pages/categories/[slug].astro create mode 100644 website/src/pages/categories/[slug].md.ts create mode 100644 website/src/pages/categories/index.astro create mode 100644 website/src/pages/icons.astro create mode 100644 website/src/pages/interactions/[slug].astro create mode 100644 website/src/pages/interactions/index.astro create mode 100644 website/src/pages/lottie-icons.astro create mode 100644 website/src/pages/shadcn-animated-icons.astro diff --git a/website/src/components/Breadcrumbs.astro b/website/src/components/Breadcrumbs.astro new file mode 100644 index 0000000..fad078a --- /dev/null +++ b/website/src/components/Breadcrumbs.astro @@ -0,0 +1,25 @@ +--- +import type { Crumb } from '../lib/hubs'; + +interface Props { + /** Every level, the current page last. */ + crumbs: Crumb[]; +} + +const { crumbs } = Astro.props; +--- + +<nav aria-label="Breadcrumb" class="text-sm text-muted"> + { + crumbs.map((crumb, index) => + index < crumbs.length - 1 ? ( + <> + <a href={crumb.path} class="hover:text-fg">{crumb.name}</a> + <span class="mx-1.5 text-subtle">/</span> + </> + ) : ( + <span class="text-fg" aria-current="page">{crumb.name}</span> + ) + ) + } +</nav> diff --git a/website/src/components/FaqList.astro b/website/src/components/FaqList.astro new file mode 100644 index 0000000..1131f7a --- /dev/null +++ b/website/src/components/FaqList.astro @@ -0,0 +1,24 @@ +--- +import type { Faq } from '../lib/seo'; + +interface Props { + faq: Faq; + class?: string; +} + +const { faq, class: className } = Astro.props; +--- + +<section class:list={['max-w-3xl', className]} aria-labelledby="faq-title"> + <h2 id="faq-title" class="text-2xl font-semibold tracking-tight">FAQ</h2> + <div class="mt-5 divide-y divide-line rounded-3xl border border-line"> + { + faq.map(({ question, answer }) => ( + <details class="group px-6 py-4"> + <summary class="cursor-pointer font-medium text-fg">{question}</summary> + <p class="mt-3 leading-relaxed text-muted">{answer}</p> + </details> + )) + } + </div> +</section> diff --git a/website/src/components/IconGrid.astro b/website/src/components/IconGrid.astro new file mode 100644 index 0000000..1da25cc --- /dev/null +++ b/website/src/components/IconGrid.astro @@ -0,0 +1,28 @@ +--- +import type { Icon } from '../lib/icons'; + +interface Props { + icons: Icon[]; + class?: string; +} + +// Static cards: a link to the icon page with its first frame, so every icon is crawlable without +// scripts. Same markup as the related icons on icon pages. +const { icons, class: className } = Astro.props; +--- + +<ul class:list={['grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-3', className]}> + { + icons.map((icon) => ( + <li> + <a + href={`/icons/${icon.id}`} + class="flex aspect-[1/1.08] flex-col items-center justify-center gap-3 rounded-2xl border border-line bg-surface px-2 text-center text-[13px] text-muted transition hover:border-line-strong hover:text-fg" + > + <span data-useanimations="" class="block size-10 text-fg" set:html={icon.firstFrame} /> + {icon.title} + </a> + </li> + )) + } +</ul> diff --git a/website/src/content/documentation.md b/website/src/content/documentation.md index e66f0f2..66660e4 100644 --- a/website/src/content/documentation.md +++ b/website/src/content/documentation.md @@ -162,7 +162,7 @@ import animationData from './my-animation.json'; Your animation must be a Lottie JSON file, the format the icons use. The player supports what the icons need: shape layers, precompositions, null layers and parenting, paths, rectangles, ellipses, strokes, fills, trim paths, masks, keyframes with easing, holds and motion paths, and the `loopOut()` and `wiggle()` expressions. Anything else, such as gradients, text or image layers, effects and mattes, is not drawn; the console warns about unsupported layers, shapes and expressions. -<div id="direct-urls"></div><div id="download"></div><div id="html"></div><div id="interactions-web"></div><div id="vue"></div><div id="without-react"></div><div id="ios"></div><div id="android"></div> +<div id="direct-urls"></div><div id="download"></div> ## Files & icons.json @@ -178,6 +178,124 @@ Every icon can also be downloaded, from its page or from stable, CORS-enabled UR [icons.json](/icons.json) lists every icon with its id, title, category, search keywords, interaction, file URLs, size and duration, its `react-useanimations` import and the shadcn command. The npm package ships the same data: `react-useanimations/catalog.json` (metadata), `react-useanimations/icons` (the animations) and `react-useanimations/svg.json` (the static SVGs). +<div id="without-react"></div> + +## Use without React + +The icons are standard Lottie JSON, so any Lottie player can play them from `https://useanimations.com/icons/<id>.json` or from a copy in your project. Outside React you wire up the interaction yourself. [Lottie icons](/lottie-icons) has more players and examples. + +Some icons use After Effects expressions (`loopOut()`, `wiggle()`); `requiresFullPlayer` marks them in [icons.json](/icons.json). On the web they need lottie-web's full build, not `lottie_light`, and native players don't evaluate expressions. + +<div id="html"></div><div id="interactions-web"></div> + +### HTML and JavaScript + +```html +<div id="icon" style="width: 48px; height: 48px"></div> + +<script src="https://cdn.jsdelivr.net/npm/lottie-web@5.13.0/build/player/lottie.min.js"></script> +<script> + const el = document.getElementById('icon'); + const animation = lottie.loadAnimation({ + container: el, + renderer: 'svg', + loop: false, + autoplay: false, + path: 'https://useanimations.com/icons/archive.json', + }); + // hover: forward on enter, backward on leave + el.addEventListener('mouseenter', () => { + animation.setDirection(1); + animation.play(); + }); + el.addEventListener('mouseleave', () => { + animation.setDirection(-1); + animation.play(); + }); +</script> +``` + +With the SVG renderer, the icon can follow the text color like the React component: + +```css +#icon path[stroke^='rgb']:not([stroke='rgb(255,255,255)']) { + stroke: currentColor; +} +#icon path[fill='rgb(0,0,0)'] { + fill: currentColor; +} +``` + +<div id="vue"></div> + +### Vue + +```vue +<script setup> +import lottie from 'lottie-web'; +import { onBeforeUnmount, onMounted, ref } from 'vue'; + +const el = ref(null); +let animation; +onMounted(() => { + animation = lottie.loadAnimation({ + container: el.value, + renderer: 'svg', + loop: true, + autoplay: true, + path: 'https://useanimations.com/icons/loading.json', + }); +}); +onBeforeUnmount(() => animation?.destroy()); +</script> + +<template> + <div ref="el" style="width: 48px; height: 48px" /> +</template> +``` + +### React Native + +```jsx +import LottieView from 'lottie-react-native'; + +<LottieView source={require('./loading.json')} autoPlay loop style={{ width: 48, height: 48 }} />; +``` + +<div id="ios"></div> + +### iOS + +With [lottie-ios](https://github.com/airbnb/lottie-ios) and the JSON in your app bundle: + +```swift +import Lottie +import SwiftUI + +LottieView(animation: .named("loading")) + .looping() + .frame(width: 48, height: 48) +``` + +<div id="android"></div> + +### Android + +With [lottie-android](https://github.com/airbnb/lottie-android) in Jetpack Compose and the JSON in `res/raw`: + +```kotlin +val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading)) +LottieAnimation(composition, iterations = LottieConstants.IterateForever, modifier = Modifier.size(48.dp)) +``` + +### Flutter + +With the [lottie](https://pub.dev/packages/lottie) package: + +```dart +Lottie.asset('assets/loading.json', width: 48, height: 48) +``` + <div id="performance"></div><div id="accessibility"></div> ## Performance & accessibility @@ -325,7 +443,7 @@ Icons follow the text color. Change it with `strokeColor` and `fillColor`, CSS ` <details><summary>Does it work without React?</summary> -The package is for React. You can still download every icon as an SVG, or as the animation's Lottie JSON for a Lottie player of your choice. See [Files & icons.json](#files--iconsjson). +The package is for React. Every icon is also a Lottie JSON file that lottie-web, lottie-ios, lottie-android, Flutter and React Native can play, and a static SVG. See [Use without React](#use-without-react) and [Lottie icons](/lottie-icons). </details> diff --git a/website/src/lib/hubs.ts b/website/src/lib/hubs.ts new file mode 100644 index 0000000..4488650 --- /dev/null +++ b/website/src/lib/hubs.ts @@ -0,0 +1,279 @@ +// Shared pieces of the hub pages (/icons, /categories, /interactions, /lottie-icons, +// /shadcn-animated-icons): titles and descriptions within the search limits, and JSON-LD nodes. +import type { Interaction } from 'react-useanimations'; +import { type Icon, iconById, icons, INTERACTIONS, libImport, shadcnCommand, SITE } from './icons'; +import { type Faq, MAX_DESCRIPTION, MAX_TITLE, TITLE_SUFFIX, WEBSITE_ID } from './seo'; +import { + CATEGORY_META, + type CategoryMeta, + INTERACTION_META, + iconsByCategory, + iconsByInteraction, +} from './taxonomy'; + +/** The first candidate that fits in max characters, else the last one. */ +export const fit = (candidates: string[], max: number) => + candidates.find((candidate) => candidate.length <= max) ?? candidates.at(-1)!; + +/** A <title>: each stem with the brand suffix if it fits, then without. */ +export const hubTitle = (...stems: string[]) => + fit([...stems.map((stem) => stem + TITLE_SUFFIX), ...stems], MAX_TITLE); + +export const hubDescription = (...candidates: string[]) => fit(candidates, MAX_DESCRIPTION); + +export const list = (items: string[]) => + items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`; + +export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + +export const categoryPath = (meta: CategoryMeta) => `/categories/${meta.slug}`; +export const interactionPath = (interaction: Interaction) => `/interactions/${interaction}`; + +/** Categories in site order, with their icons. */ +export const categories = () => + Object.values(CATEGORY_META).map((meta) => ({ meta, icons: iconsByCategory(meta.name) })); + +/** Interactions in the order of INTERACTIONS, with their icons. */ +export const interactions = () => + (Object.keys(INTERACTIONS) as Interaction[]).map((interaction) => ({ + interaction, + icons: iconsByInteraction(interaction), + })); + +/** Icons by catalog id, in the given order; throws on a typo so a renamed icon fails the build. */ +export const pickIcons = (ids: string[]): Icon[] => + ids.map((id) => { + const icon = iconById.get(id); + if (!icon) throw new Error(`hubs: no icon "${id}"`); + return icon; + }); + +/** Icons that use After Effects expressions: lottie-web's full build, not the light one. */ +export const expressionIcons = () => icons.filter((icon) => icon.requiresFullPlayer); + +export type Crumb = { name: string; path: string }; + +export const breadcrumbNode = (crumbs: Crumb[]) => ({ + '@type': 'BreadcrumbList', + itemListElement: crumbs.map((crumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: crumb.name, + item: `${SITE}${crumb.path}`, + })), +}); + +export const itemListNode = (items: Icon[], name: string) => ({ + '@type': 'ItemList', + name, + numberOfItems: items.length, + itemListElement: items.map((icon, index) => ({ + '@type': 'ListItem', + position: index + 1, + url: `${SITE}/icons/${icon.id}`, + name: icon.title, + })), +}); + +/** A CollectionPage whose main entity is the list of its icons. */ +export const collectionPageNode = ( + path: string, + name: string, + description: string, + items: Icon[] +) => ({ + '@type': 'CollectionPage', + '@id': `${SITE}${path}`, + url: `${SITE}${path}`, + name, + description, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: itemListNode(items, name), +}); + +// How a group of icons plays, as a plural verb phrase: "<n> icons <phrase>". +const PLAY: Record<Interaction, string> = { + loop: 'loop continuously', + 'click-toggle': 'toggle between two states on click', + 'click-replay': 'replay on every click', + hover: 'play on hover and reverse on mouse leave', + 'hover-replay': 'play once on hover', + 'hover-loop': 'loop while hovered', +}; + +/** What each interaction does when the user prefers reduced motion (src/index.tsx). */ +export const REDUCED_MOTION: Record<Interaction, string> = { + loop: 'With reduced motion preferred, the icon does not autoplay and stays on its static first frame.', + 'click-toggle': + 'With reduced motion preferred, a click switches to the other state immediately, without the animation in between.', + 'click-replay': + 'With reduced motion preferred, clicks still reach your handler but the animation does not play.', + hover: 'With reduced motion preferred, hovering does not play the animation.', + 'hover-replay': 'With reduced motion preferred, hovering does not play the animation.', + 'hover-loop': 'With reduced motion preferred, the icon stays static while hovered.', +}; + +// The icon each collection uses in its code example: a well-known member of the group. +const CATEGORY_EXAMPLE: Record<string, string> = { + Alerts: 'alert-triangle', + Notifications: 'notification', + Navigation: 'menu', + Action: 'heart', + Content: 'copy', + Form: 'check-box', + Media: 'play-pause', + 'Text editing': 'bold', + Communication: 'send', + Devices: 'wifi', + Weather: 'sun', + 'Maps & location': 'map-pin', + Commerce: 'shopping-cart', + Charts: 'trending-up', + Development: 'git-branch', + 'Design & layout': 'layers', + 'Shapes & math': 'hexagon', + Loading: 'loading', + 'Social media': 'github', + Other: 'scroll-down', +}; + +export const INTERACTION_EXAMPLE: Record<Interaction, string> = { + loop: 'infinity', + 'click-toggle': 'menu', + 'click-replay': 'download', + hover: 'archive', + 'hover-loop': 'github', + 'hover-replay': 'trending-up', +}; + +const titles = (items: Icon[], max = 12) => + items.length > max + ? `${items + .slice(0, max) + .map((icon) => icon.title) + .join(', ')} and ${items.length - max} more` + : list(items.map((icon) => icon.title)); + +/** ', with static SVGs' when every icon has them, else how many do. */ +const svgNote = (items: Icon[]) => { + const withSvg = items.filter((icon) => Object.keys(icon.svgs).length > 0).length; + if (withSvg === items.length) return ' with static SVGs'; + return withSvg ? `, and ${withSvg} of them also as static SVGs` : ''; +}; + +const lower = (text: string) => text[0].toLowerCase() + text.slice(1); + +/** Icons grouped by interaction, in the order of INTERACTIONS. */ +const groupByInteraction = (items: Icon[]) => + (Object.keys(INTERACTIONS) as Interaction[]) + .map((interaction) => ({ + interaction, + icons: items.filter((icon) => icon.interaction === interaction), + })) + .filter((group) => group.icons.length > 0); + +/** Title, description, summary, interaction breakdown and example icon of a category page. */ +export const categoryText = (meta: CategoryMeta) => { + const items = iconsByCategory(meta.name); + const n = items.length; + const name = lower(meta.title); + const byInteraction = groupByInteraction(items); + const examples = list(items.slice(0, 3).map((icon) => icon.title)); + const plays = list( + byInteraction.map( + ({ interaction, icons: group }) => + `${group.length} ${group.length === 1 ? PLAY[interaction].replace(/^(\w+)/, '$1s') : PLAY[interaction]}` + ) + ); + return { + title: hubTitle( + `${meta.title} for React & Lottie`, + `${meta.title} for React`, + `Free ${name}`, + meta.title + ), + description: hubDescription( + `${n} free ${name}, including ${examples}. Hover, click and loop micro animations for React, shadcn/ui and Lottie JSON, MIT licensed.`, + `${n} free ${name}, including ${examples}. For React, shadcn/ui and Lottie JSON, MIT licensed.`, + `${n} free ${name}, including ${examples}. For React, shadcn/ui and Lottie, MIT.`, + `${n} free ${name} for React, shadcn/ui and Lottie JSON, MIT licensed.` + ), + summary: `${byInteraction.length === 1 ? `All ${n} icons ${PLAY[byInteraction[0].interaction]}` : `Of the ${n} icons, ${plays}`}. Each one comes as a React component, a shadcn/ui component and a Lottie JSON animation${svgNote(items)}, free for commercial use under the MIT license.`, + byInteraction, + example: iconById.get(CATEGORY_EXAMPLE[meta.name]) ?? items[0], + }; +}; + +export const categoryFaq = (meta: CategoryMeta): Faq => { + const items = iconsByCategory(meta.name); + const { example } = categoryText(meta); + const name = lower(meta.title); + const label = INTERACTIONS[example.interaction].label.toLowerCase(); + const expressions = items.filter((icon) => icon.requiresFullPlayer); + return [ + { + question: `Which ${name} are included?`, + answer: `${plural(items.length, 'icon')}: ${titles(items)}. All of them are free for personal and commercial projects under the MIT license, with no attribution required.`, + }, + { + question: `How do I use these ${name} in React?`, + answer: `Install react-useanimations, add ${libImport(example).replace(/;$/, '')} and render <UseAnimations animation={${example.exportName}} size={32} />. The icon plays its ${label} interaction and follows the text color. With shadcn/ui, run ${shadcnCommand(example)} instead.`, + }, + { + question: `Can I use these ${name} without React?`, + answer: `Yes. Each icon is a Lottie JSON file at ${SITE}/icons/<id>.json (for example ${SITE}/icons/${example.id}.json), playable with lottie-web, lottie-ios, lottie-android or Flutter's lottie package${svgNote(items) ? '; static SVGs are on each icon page' : ''}. ${ + expressions.length + ? `${titles(expressions, 6)} ${expressions.length === 1 ? 'uses' : 'use'} After Effects expressions, so on the web ${expressions.length === 1 ? 'it needs' : 'they need'} lottie-web's full build.` + : "None of them use expressions, so lottie-web's light build is enough." + }`, + }, + ]; +}; + +/** Title, description and summary of an interaction page. */ +export const interactionText = (interaction: Interaction) => { + const meta = INTERACTION_META[interaction]; + const items = iconsByInteraction(interaction); + const n = items.length; + const name = lower(meta.title); + const examples = list(items.slice(0, 3).map((icon) => icon.title)); + return { + title: hubTitle(`${meta.title} for React & Lottie`, `${meta.title} for React`, meta.title), + description: hubDescription( + `${n} free ${name} that ${PLAY[interaction]}, such as ${examples}. For React, shadcn/ui and Lottie JSON, MIT licensed.`, + `${n} free ${name} that ${PLAY[interaction]}. For React, shadcn/ui and Lottie JSON, MIT licensed.` + ), + example: iconById.get(INTERACTION_EXAMPLE[interaction]) ?? items[0], + byCategory: categories() + .map(({ meta: category, icons: group }) => ({ + meta: category, + icons: group.filter((icon) => icon.interaction === interaction), + })) + .filter((group) => group.icons.length > 0), + }; +}; + +export const interactionFaq = (interaction: Interaction): Faq => { + const meta = INTERACTION_META[interaction]; + const items = iconsByInteraction(interaction); + const label = INTERACTIONS[interaction].label; + const toggle = + interaction === 'click-toggle' + ? ' To control the state yourself, pass active={isOn} and update it in onClick: clicks then no longer toggle the icon on their own.' + : ''; + return [ + { + question: `Which icons use the ${lower(label)} interaction?`, + answer: `${plural(items.length, 'icon')}: ${titles(items, 15)}. ${meta.example}`, + }, + { + question: `What happens when the user prefers reduced motion?`, + answer: `${REDUCED_MOTION[interaction]} The React component reads prefers-reduced-motion and updates when the setting changes, so you don't need any code for it.`, + }, + { + question: `How do I change an icon's interaction?`, + answer: `Every icon plays the interaction it was designed for. Pass interaction="${interaction}" to give any other icon this behaviour, or another value ('loop', 'click-toggle', 'click-replay', 'hover', 'hover-loop', 'hover-replay') to change it. The autoplay, loop and speed props adjust playback.${toggle}`, + }, + ]; +}; diff --git a/website/src/lib/site.ts b/website/src/lib/site.ts index 86909fc..d22751b 100644 --- a/website/src/lib/site.ts +++ b/website/src/lib/site.ts @@ -14,8 +14,10 @@ export const withSite = (text: string, site: string) => export const NAV = [ { label: 'Icons', href: '/#icons' }, + { label: 'Categories', href: '/categories' }, { label: 'Documentation', href: '/documentation' }, - { label: 'shadcn', href: '/documentation#shadcn' }, + { label: 'shadcn', href: '/shadcn-animated-icons' }, + { label: 'Lottie icons', href: '/lottie-icons' }, { label: 'License', href: '/licencing-and-terms' }, ]; @@ -23,7 +25,11 @@ export const FOOTER = [ { title: 'Product', links: [ - { label: 'Icons', href: '/#icons' }, + { label: 'All icons', href: '/icons' }, + { label: 'Categories', href: '/categories' }, + { label: 'Interactions', href: '/interactions' }, + { label: 'Lottie icons', href: '/lottie-icons' }, + { label: 'shadcn/ui icons', href: '/shadcn-animated-icons' }, { label: 'Documentation', href: '/documentation' }, { label: 'Licencing & Terms', href: '/licencing-and-terms' }, { label: '中文', href: '/zh' }, diff --git a/website/src/pages/categories/[slug].astro b/website/src/pages/categories/[slug].astro new file mode 100644 index 0000000..64cacc9 --- /dev/null +++ b/website/src/pages/categories/[slug].astro @@ -0,0 +1,136 @@ +--- +import type { GetStaticPaths } from 'astro'; +import Breadcrumbs from '../../components/Breadcrumbs.astro'; +import CodeBlock from '../../components/CodeBlock.astro'; +import FaqList from '../../components/FaqList.astro'; +import IconGrid from '../../components/IconGrid.astro'; +import Layout from '../../layouts/Layout.astro'; +import { + breadcrumbNode, + categories, + categoryFaq, + categoryPath, + categoryText, + collectionPageNode, + interactionPath, + plural, +} from '../../lib/hubs'; +import { INTERACTIONS, reactExample, shadcnCommand, shadcnExample } from '../../lib/icons'; +import { faqNode, websiteNode } from '../../lib/seo'; +import { INTERACTION_META } from '../../lib/taxonomy'; + +export const getStaticPaths = (() => + categories().map(({ meta }) => ({ params: { slug: meta.slug }, props: { name: meta.name } }))) satisfies GetStaticPaths; + +const { name } = Astro.props as { name: string }; +const all = categories(); +const { meta, icons } = all.find((category) => category.meta.name === name)!; +const text = categoryText(meta); +const path = categoryPath(meta); +const faq = categoryFaq(meta); +const crumbs = [ + { name: 'Icons', path: '/icons' }, + { name: 'Categories', path: '/categories' }, + { name: meta.name, path }, +]; +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + collectionPageNode(path, meta.title, text.description, icons), + breadcrumbNode(crumbs), + faqNode(faq), + websiteNode(), + ], +}; +--- + +<Layout title={text.title} description={text.description} path={path} jsonLd={jsonLd} noindex={!meta.indexable} head={[{ rel: 'alternate', type: 'text/markdown', href: `${path}.md`, title: `${meta.title} (Markdown)` }]}> + <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> + <Breadcrumbs crumbs={crumbs} /> + + <header class="mt-8 max-w-3xl"> + <p class="text-sm font-medium text-accent">{plural(icons.length, 'icon')} · MIT licensed</p> + <h1 class="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-5xl">{meta.title}</h1> + <p class="mt-4 text-lg leading-relaxed text-muted">{meta.intro}</p> + <p class="mt-4 leading-relaxed text-muted">{text.summary}</p> + </header> + + <section class="mt-12" aria-labelledby="icons-title"> + <h2 id="icons-title" class="text-lg font-semibold">All {meta.name.toLowerCase()} icons</h2> + <IconGrid class="mt-4" icons={icons} /> + </section> + + <div class="mt-16 grid gap-12 lg:grid-cols-2"> + <section aria-labelledby="uses-title"> + <h2 id="uses-title" class="text-2xl font-semibold tracking-tight">Where to use them</h2> + <ul class="mt-4 list-disc space-y-2 pl-5 text-muted marker:text-subtle"> + {meta.useCases.map((useCase) => <li>{useCase}</li>)} + </ul> + </section> + + <section aria-labelledby="by-interaction-title"> + <h2 id="by-interaction-title" class="text-2xl font-semibold tracking-tight">By interaction</h2> + <ul class="mt-4 space-y-4"> + { + text.byInteraction.map(({ interaction, icons: group }) => ( + <li> + <a href={interactionPath(interaction)} class="font-medium text-fg underline-offset-4 hover:underline"> + {INTERACTION_META[interaction].title} + </a> + <span class="text-subtle"> · {group.length}</span> + <p class="mt-1 text-sm leading-relaxed text-muted"> + {INTERACTIONS[interaction].description} {group.map((icon) => icon.title).join(', ')}. + </p> + </li> + )) + } + </ul> + </section> + </div> + + <section class="mt-16 max-w-3xl" aria-labelledby="code-title"> + <h2 id="code-title" class="text-2xl font-semibold tracking-tight">Add one to your app</h2> + <p class="mt-3 leading-relaxed text-muted"> + With React, install <code class="font-mono text-sm text-fg">react-useanimations</code> and import the icon; it + plays its {INTERACTIONS[text.example.interaction].label.toLowerCase()} interaction on its own. Every icon in this + category works the same way: swap <code class="font-mono text-sm text-fg">{text.example.name}</code> for the + name on its page. + </p> + <CodeBlock class="mt-4" lang="bash" code="npm install react-useanimations" track={{ code: 'install' }} /> + <CodeBlock class="mt-3" lang="jsx" code={reactExample(text.example.exportName, text.example.name)} track={{ icon: text.example.id, code: 'react' }} /> + <p class="mt-6 leading-relaxed text-muted"> + With <a class="text-fg underline underline-offset-4" href="/shadcn-animated-icons">shadcn/ui</a>, the CLI adds a + ready-made component: + </p> + <CodeBlock class="mt-3" lang="bash" code={shadcnCommand(text.example)} track={{ icon: text.example.id, code: 'shadcn-command' }} /> + <CodeBlock class="mt-3" lang="tsx" code={shadcnExample(text.example.id, text.example.component)} track={{ icon: text.example.id, code: 'shadcn' }} /> + <p class="mt-6 leading-relaxed text-muted"> + Not using React? Each icon is a Lottie JSON file at <code class="font-mono text-sm text-fg">/icons/<id>.json</code>; + see <a class="text-fg underline underline-offset-4" href="/lottie-icons">Lottie icons</a> for web, iOS, Android + and Flutter players. + </p> + </section> + + <FaqList class="mt-16" faq={faq} /> + + <nav aria-labelledby="more-title" class="mt-16 border-t border-line pt-8"> + <h2 id="more-title" class="text-lg font-semibold">More categories</h2> + <ul class="mt-4 flex flex-wrap gap-2"> + { + all + .filter((other) => other.meta.name !== meta.name) + .map((other) => ( + <li> + <a href={categoryPath(other.meta)} class="inline-flex rounded-full border border-line px-3.5 py-1.5 text-sm text-muted transition hover:border-line-strong hover:text-fg"> + {other.meta.name} <span class="ml-1.5 text-subtle">{other.icons.length}</span> + </a> + </li> + )) + } + <li> + <a href="/icons" class="inline-flex rounded-full bg-fg px-3.5 py-1.5 text-sm font-medium text-bg hover:opacity-85">All icons</a> + </li> + </ul> + </nav> + </div> +</Layout> diff --git a/website/src/pages/categories/[slug].md.ts b/website/src/pages/categories/[slug].md.ts new file mode 100644 index 0000000..d140f4e --- /dev/null +++ b/website/src/pages/categories/[slug].md.ts @@ -0,0 +1,73 @@ +import type { GetStaticPaths } from 'astro'; +import { text } from '../../lib/http'; +import { categories, categoryFaq, categoryPath, categoryText } from '../../lib/hubs'; +import { type Icon, INTERACTIONS, reactExample, shadcnCommand, SITE } from '../../lib/icons'; +import { type CategoryMeta, INTERACTION_META } from '../../lib/taxonomy'; + +export const getStaticPaths = (() => + categories().map(({ meta, icons }) => ({ + params: { slug: meta.slug }, + props: { meta, icons }, + }))) satisfies GetStaticPaths; + +export const GET = ({ props }: { props: { meta: CategoryMeta; icons: Icon[] } }) => { + const { meta } = props; + const copy = categoryText(meta); + const others = categories().filter((other) => other.meta.name !== meta.name); + return text( + `# ${meta.title} + +> ${copy.description} + +${meta.intro} + +${copy.summary} + +## Icons + +${props.icons + .map( + (icon) => `- [${icon.title}](${SITE}/icons/${icon.id}) (\`${icon.name}\`, ${icon.interaction})` + ) + .join('\n')} + +## Where to use them + +${meta.useCases.map((useCase) => `- ${useCase}`).join('\n')} + +## By interaction + +${copy.byInteraction + .map( + ({ interaction, icons }) => + `- [${INTERACTION_META[interaction].title}](${SITE}/interactions/${interaction}) (${icons.length}): ${INTERACTIONS[interaction].description}` + ) + .join('\n')} + +## Code + +\`\`\`jsx +${reactExample(copy.example.exportName, copy.example.name)} +\`\`\` + +\`\`\`bash +${shadcnCommand(copy.example)} +\`\`\` + +Every icon is also a Lottie JSON file at \`${SITE}/icons/<id>.json\`: [Lottie icons](${SITE}/lottie-icons). + +## FAQ + +${categoryFaq(meta) + .map(({ question, answer }) => `### ${question}\n\n${answer}`) + .join('\n\n')} + +## More categories + +${others.map((other) => `- [${other.meta.title}](${SITE}${categoryPath(other.meta)}) (${other.icons.length})`).join('\n')} + +HTML version: ${SITE}${categoryPath(meta)} +`, + 'text/markdown' + ); +}; diff --git a/website/src/pages/categories/index.astro b/website/src/pages/categories/index.astro new file mode 100644 index 0000000..058b62a --- /dev/null +++ b/website/src/pages/categories/index.astro @@ -0,0 +1,82 @@ +--- +import Breadcrumbs from '../../components/Breadcrumbs.astro'; +import Layout from '../../layouts/Layout.astro'; +import { breadcrumbNode, categories, categoryPath, hubDescription, hubTitle } from '../../lib/hubs'; +import { icons, SITE } from '../../lib/icons'; +import { websiteNode, WEBSITE_ID } from '../../lib/seo'; + +const all = categories(); +const title = hubTitle(`Animated icon categories: ${all.length} collections`, 'Animated icon categories'); +const description = hubDescription( + `Browse ${icons.length} free animated icons in ${all.length} categories: navigation, action, media, loading, social media and more. For React, shadcn/ui and Lottie.` +); +const crumbs = [ + { name: 'Icons', path: '/icons' }, + { name: 'Categories', path: '/categories' }, +]; +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'CollectionPage', + '@id': `${SITE}/categories`, + url: `${SITE}/categories`, + name: 'Animated icon categories', + description, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: { + '@type': 'ItemList', + numberOfItems: all.length, + itemListElement: all.map(({ meta }, index) => ({ + '@type': 'ListItem', + position: index + 1, + url: `${SITE}${categoryPath(meta)}`, + name: meta.title, + })), + }, + }, + breadcrumbNode(crumbs), + websiteNode(), + ], +}; +--- + +<Layout title={title} description={description} path="/categories" jsonLd={jsonLd}> + <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> + <Breadcrumbs crumbs={crumbs} /> + <header class="mt-8 max-w-3xl"> + <p class="text-sm font-medium text-accent">{icons.length} icons · {all.length} categories</p> + <h1 class="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-5xl">Animated icon categories</h1> + <p class="mt-4 text-lg leading-relaxed text-muted"> + Every useAnimations icon belongs to one category, from navigation arrows and media controls to loaders and + social media logos. Open a category for all of its icons, where to use them and the code to add one, or browse + by <a href="/interactions" class="text-fg underline underline-offset-4">interaction</a> instead. + </p> + </header> + + <ul class="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> + { + all.map(({ meta, icons: group }) => ( + <li> + <a + href={categoryPath(meta)} + class="group flex h-full flex-col rounded-3xl border border-line bg-surface p-6 transition hover:border-line-strong" + > + <span class="flex gap-2 text-fg" aria-hidden="true"> + {group.slice(0, 5).map((icon) => ( + <span data-useanimations="" class="block size-8" set:html={icon.firstFrame} /> + ))} + </span> + <span class="mt-5 flex items-baseline justify-between gap-3"> + <span class="text-lg font-semibold tracking-tight text-fg">{meta.name}</span> + <span class="text-sm text-subtle">{group.length} icons</span> + </span> + <span class="mt-2 line-clamp-3 text-sm leading-relaxed text-muted">{meta.intro}</span> + </a> + </li> + )) + } + </ul> + </div> +</Layout> diff --git a/website/src/pages/icons.astro b/website/src/pages/icons.astro new file mode 100644 index 0000000..f212974 --- /dev/null +++ b/website/src/pages/icons.astro @@ -0,0 +1,88 @@ +--- +import Breadcrumbs from '../components/Breadcrumbs.astro'; +import IconGrid from '../components/IconGrid.astro'; +import Layout from '../layouts/Layout.astro'; +import { + breadcrumbNode, + categories, + categoryPath, + collectionPageNode, + hubDescription, + hubTitle, + interactionPath, + interactions, +} from '../lib/hubs'; +import { icons, INTERACTIONS } from '../lib/icons'; +import { websiteNode } from '../lib/seo'; + +// The index of every icon page, grouped like the home page; plain links, no scripts. +const groups = categories(); +const title = hubTitle(`All ${icons.length} animated icons by category`, `All ${icons.length} animated icons`); +const description = hubDescription( + `Every useAnimations icon on one page: ${icons.length} free animated icons in ${groups.length} categories, for React, shadcn/ui, Lottie JSON and SVG. MIT licensed.` +); +const crumbs = [{ name: 'Icons', path: '/icons' }]; +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + collectionPageNode('/icons', 'All animated icons', description, icons), + breadcrumbNode(crumbs), + websiteNode(), + ], +}; +--- + +<Layout title={title} description={description} path="/icons" jsonLd={jsonLd}> + <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> + <header class="max-w-3xl"> + <Breadcrumbs crumbs={crumbs} /> + <p class="mt-8 text-sm font-medium text-accent">{icons.length} icons · MIT licensed</p> + <h1 class="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-5xl">All animated icons</h1> + <p class="mt-4 text-lg leading-relaxed text-muted"> + Every icon in the set, grouped by category. Open one for a live preview, its React and shadcn/ui code, and + the Lottie JSON, SVG and ZIP downloads. To search by keyword and try the animations in place, use the{' '} + <a href="/#icons" class="text-fg underline underline-offset-4">icon browser</a>. + </p> + </header> + + <nav aria-label="Categories" class="mt-10 flex flex-wrap gap-2"> + { + groups.map(({ meta, icons: group }) => ( + <a href={`#${meta.slug}`} class="inline-flex rounded-full border border-line px-3.5 py-1.5 text-sm text-muted transition hover:border-line-strong hover:text-fg"> + {meta.name} <span class="ml-1.5 text-subtle">{group.length}</span> + </a> + )) + } + </nav> + <p class="mt-4 text-sm text-muted"> + By interaction:{' '} + { + interactions().map(({ interaction, icons: group }, index) => ( + <> + {index > 0 && ' · '} + <a href={interactionPath(interaction)} class="text-fg underline-offset-4 hover:underline"> + {INTERACTIONS[interaction].label} + </a>{' '} + <span class="text-subtle">({group.length})</span> + </> + )) + } + </p> + + { + groups.map(({ meta, icons: group }) => ( + <section id={meta.slug} class="mt-14 scroll-mt-24" aria-labelledby={`${meta.slug}-title`}> + <div class="flex flex-wrap items-baseline justify-between gap-3"> + <h2 id={`${meta.slug}-title`} class="text-2xl font-semibold tracking-tight"> + <a href={categoryPath(meta)} class="hover:underline hover:underline-offset-4">{meta.name}</a> + </h2> + <a href={categoryPath(meta)} class="text-sm text-muted hover:text-fg"> + {group.length} {meta.name.toLowerCase()} icons → + </a> + </div> + <IconGrid class="mt-4" icons={group} /> + </section> + )) + } + </div> +</Layout> diff --git a/website/src/pages/interactions/[slug].astro b/website/src/pages/interactions/[slug].astro new file mode 100644 index 0000000..cab76c0 --- /dev/null +++ b/website/src/pages/interactions/[slug].astro @@ -0,0 +1,243 @@ +--- +import type { GetStaticPaths } from 'astro'; +import type { Interaction } from 'react-useanimations'; +import Breadcrumbs from '../../components/Breadcrumbs.astro'; +import CodeBlock from '../../components/CodeBlock.astro'; +import FaqList from '../../components/FaqList.astro'; +import IconGrid from '../../components/IconGrid.astro'; +import InteractionDemo from '../../components/InteractionDemo'; +import Layout from '../../layouts/Layout.astro'; +import { + breadcrumbNode, + categoryPath, + collectionPageNode, + interactionFaq, + interactionPath, + interactions, + interactionText, + pickIcons, + plural, + REDUCED_MOTION, +} from '../../lib/hubs'; +import { INTERACTIONS } from '../../lib/icons'; +import { faqNode, websiteNode } from '../../lib/seo'; +import { iconsByInteraction, INTERACTION_META } from '../../lib/taxonomy'; + +export const getStaticPaths = (() => + interactions().map(({ interaction }) => ({ params: { slug: interaction }, props: { interaction } }))) satisfies GetStaticPaths; + +const { interaction } = Astro.props as { interaction: Interaction }; +const meta = INTERACTION_META[interaction]; +const info = INTERACTIONS[interaction]; +const items = iconsByInteraction(interaction); +const copy = interactionText(interaction); +const path = interactionPath(interaction); +const faq = interactionFaq(interaction); +const hover = interaction.startsWith('hover'); +const crumbs = [ + { name: 'Icons', path: '/icons' }, + { name: 'Interactions', path: '/interactions' }, + { name: info.label, path }, +]; + +// Another icon given this interaction with the prop. +const [other] = pickIcons(['settings', 'bookmark', 'star']).filter((icon) => icon.interaction !== interaction); +const ex = copy.example; +const imports = (icon: typeof ex) => `import ${icon.exportName} from 'react-useanimations/lib/${icon.name}';`; +const overrideCode = `import UseAnimations from 'react-useanimations'; +${imports(other)} + +// ${other.title} normally uses '${other.interaction}' +<UseAnimations animation={${other.exportName}} interaction="${interaction}" size={32} />`; + +const extra: Record<Interaction, { text: string; code: string }> = { + loop: { + text: 'Loops autoplay and pause while scrolled out of view. Slow one down with speed, play it once with loop={false}, or keep it still with autoplay={false}:', + code: `${imports(ex)} + +<UseAnimations animation={${ex.exportName}} speed={0.5} /> // half speed +<UseAnimations animation={${ex.exportName}} loop={false} /> // plays once, then stops +<UseAnimations animation={${ex.exportName}} autoplay={false} /> // static first frame`, + }, + 'click-toggle': { + text: 'Clicks toggle the icon on their own. To keep it in sync with your state, pass active: the icon animates whenever active changes, and clicks no longer toggle it by themselves.', + code: `import { useState } from 'react'; +${imports(ex)} + +export function MenuButton() { + const [open, setOpen] = useState(false); + return ( + <UseAnimations + animation={${ex.exportName}} + active={open} // true shows the end state + onClick={() => setOpen(!open)} + aria-label={open ? 'Close menu' : 'Open menu'} + /> + ); +}`, + }, + 'click-replay': { + text: 'Put the icon in a real button with the render prop, so it can be focused and announced. Your onClick runs first; call event.preventDefault() to skip the animation.', + code: `${imports(ex)} + +<UseAnimations + animation={${ex.exportName}} + size={24} + onClick={handleDownload} + render={(eventProps, animationProps) => ( + <button type="button" aria-label="Download" {...eventProps}> + <span {...animationProps} /> + </button> + )} +/>`, + }, + hover: { + text: 'Put the icon in a link or button with the render prop: the animation then also plays when the element gets keyboard focus, and taps on touch screens do not trigger it.', + code: `${imports(ex)} + +<UseAnimations + animation={${ex.exportName}} + render={(eventProps, animationProps) => ( + <a href="/archive" {...eventProps}> + <span {...animationProps} /> Archive + </a> + )} +/>`, + }, + 'hover-replay': { + text: 'Hover the whole card or link, not just the icon: with the render prop the events go on your element, and keyboard focus plays the animation too.', + code: `${imports(ex)} + +<UseAnimations + animation={${ex.exportName}} + render={(eventProps, animationProps) => ( + <a href="/reports" className="card" {...eventProps}> + <span {...animationProps} /> Weekly growth + </a> + )} +/>`, + }, + 'hover-loop': { + text: 'Use the render prop so the whole link triggers the loop, including keyboard focus. Change the pace with speed:', + code: `${imports(ex)} + +<UseAnimations + animation={${ex.exportName}} + speed={1.5} + render={(eventProps, animationProps) => ( + <a href="https://github.com/useAnimations" aria-label="GitHub" {...eventProps}> + <span {...animationProps} /> + </a> + )} +/>`, + }, +}; + +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + collectionPageNode(path, meta.title, copy.description, items), + breadcrumbNode(crumbs), + faqNode(faq), + websiteNode(), + ], +}; +--- + +<Layout title={copy.title} description={copy.description} path={path} jsonLd={jsonLd}> + <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> + <Breadcrumbs crumbs={crumbs} /> + + <div class="mt-8 grid items-start gap-10 lg:grid-cols-[1fr_20rem]"> + <header class="max-w-3xl"> + <p class="text-sm font-medium text-accent"> + {plural(items.length, 'icon')} · <code class="font-mono">interaction="{interaction}"</code> + </p> + <h1 class="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-5xl">{meta.title}</h1> + <p class="mt-4 text-lg leading-relaxed text-muted">{meta.intro}</p> + </header> + <InteractionDemo + client:visible + lottie={ex.lottie} + interaction={interaction} + title={ex.title} + description={info.description} + hint={info.description} + /> + </div> + + <section class="mt-12" aria-labelledby="icons-title"> + <h2 id="icons-title" class="text-lg font-semibold">All {info.label.toLowerCase()} icons</h2> + <IconGrid class="mt-4" icons={items} /> + </section> + + <div class="mt-16 grid gap-12 lg:grid-cols-2"> + <section aria-labelledby="behaviour-title"> + <h2 id="behaviour-title" class="text-2xl font-semibold tracking-tight">How it behaves</h2> + <p class="mt-4 leading-relaxed text-muted">{info.description}</p> + <p class="mt-3 leading-relaxed text-muted"> + {REDUCED_MOTION[interaction]} + {hover && ' Taps on touch screens do not start hover animations, and keyboard focus does when the icon is inside a focusable element.'}{' '} + The React component handles all of this; you only pick the icon. + </p> + <h3 class="mt-8 font-semibold">Where to use it</h3> + <ul class="mt-3 list-disc space-y-2 pl-5 text-muted marker:text-subtle"> + {meta.useCases.map((useCase) => <li>{useCase}</li>)} + </ul> + </section> + + <section aria-labelledby="by-category-title"> + <h2 id="by-category-title" class="text-2xl font-semibold tracking-tight">By category</h2> + <ul class="mt-4 space-y-3"> + { + copy.byCategory.map(({ meta: category, icons: group }) => ( + <li> + <a href={categoryPath(category)} class="font-medium text-fg underline-offset-4 hover:underline">{category.name}</a> + <span class="text-subtle"> · {group.length}</span> + <p class="mt-0.5 text-sm text-muted">{group.map((icon) => icon.title).join(', ')}</p> + </li> + )) + } + </ul> + </section> + </div> + + <section class="mt-16 max-w-3xl" aria-labelledby="code-title"> + <h2 id="code-title" class="text-2xl font-semibold tracking-tight">Use it in React</h2> + <p class="mt-3 leading-relaxed text-muted"> + Icons listed here play this interaction by default. Any other icon can use it too: pass the{' '} + <code class="font-mono text-sm text-fg">interaction</code> prop. + </p> + <CodeBlock class="mt-4" lang="jsx" code={overrideCode} track={{ icon: other.id, code: 'interaction' }} /> + <p class="mt-6 leading-relaxed text-muted">{extra[interaction].text}</p> + <CodeBlock class="mt-3" lang="jsx" code={extra[interaction].code} track={{ icon: ex.id, code: 'react' }} /> + <p class="mt-6 leading-relaxed text-muted"> + Every prop is in the <a class="text-fg underline underline-offset-4" href="/documentation#props">documentation</a>. + Outside React, the icons are Lottie JSON files; <a class="text-fg underline underline-offset-4" href="/lottie-icons">Lottie icons</a>{' '} + shows how to recreate the interactions with lottie-web. + </p> + </section> + + <FaqList class="mt-16" faq={faq} /> + + <nav aria-labelledby="more-title" class="mt-16 border-t border-line pt-8"> + <h2 id="more-title" class="text-lg font-semibold">Other interactions</h2> + <ul class="mt-4 flex flex-wrap gap-2"> + { + interactions() + .filter((group) => group.interaction !== interaction) + .map((group) => ( + <li> + <a href={interactionPath(group.interaction)} class="inline-flex rounded-full border border-line px-3.5 py-1.5 text-sm text-muted transition hover:border-line-strong hover:text-fg"> + {INTERACTIONS[group.interaction].label} <span class="ml-1.5 text-subtle">{group.icons.length}</span> + </a> + </li> + )) + } + <li> + <a href="/categories" class="inline-flex rounded-full bg-fg px-3.5 py-1.5 text-sm font-medium text-bg hover:opacity-85">Browse by category</a> + </li> + </ul> + </nav> + </div> +</Layout> diff --git a/website/src/pages/interactions/index.astro b/website/src/pages/interactions/index.astro new file mode 100644 index 0000000..a3aaeda --- /dev/null +++ b/website/src/pages/interactions/index.astro @@ -0,0 +1,111 @@ +--- +import Breadcrumbs from '../../components/Breadcrumbs.astro'; +import Layout from '../../layouts/Layout.astro'; +import { breadcrumbNode, hubDescription, hubTitle, interactionPath, interactions, REDUCED_MOTION } from '../../lib/hubs'; +import { icons, INTERACTIONS, SITE } from '../../lib/icons'; +import { websiteNode, WEBSITE_ID } from '../../lib/seo'; +import { INTERACTION_META } from '../../lib/taxonomy'; + +const all = interactions(); +const title = hubTitle('Micro interactions: hover, click and loop icons', 'Micro interactions for animated icons'); +const description = hubDescription( + `${icons.length} animated icons in ${all.length} micro interactions: loop, click toggle, click replay, hover, hover replay and hover loop. With reduced-motion support.` +); +const crumbs = [ + { name: 'Icons', path: '/icons' }, + { name: 'Interactions', path: '/interactions' }, +]; +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'CollectionPage', + '@id': `${SITE}/interactions`, + url: `${SITE}/interactions`, + name: 'Animated icon interactions', + description, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: { + '@type': 'ItemList', + numberOfItems: all.length, + itemListElement: all.map(({ interaction }, index) => ({ + '@type': 'ListItem', + position: index + 1, + url: `${SITE}${interactionPath(interaction)}`, + name: INTERACTION_META[interaction].title, + })), + }, + }, + breadcrumbNode(crumbs), + websiteNode(), + ], +}; +--- + +<Layout title={title} description={description} path="/interactions" jsonLd={jsonLd}> + <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> + <Breadcrumbs crumbs={crumbs} /> + <header class="mt-8 max-w-3xl"> + <p class="text-sm font-medium text-accent">{all.length} interactions · {icons.length} icons</p> + <h1 class="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-5xl">Animated icon interactions</h1> + <p class="mt-4 text-lg leading-relaxed text-muted"> + Every icon ships with the micro interaction it was designed for: it loops, toggles or replays on click, or plays + on hover. The React component wires it up and respects reduced motion, and the{' '} + <code class="font-mono text-base text-fg">interaction</code> prop switches any icon to another one. + </p> + </header> + + <ul class="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> + { + all.map(({ interaction, icons: group }) => ( + <li> + <a + href={interactionPath(interaction)} + class="group flex h-full flex-col rounded-3xl border border-line bg-surface p-6 transition hover:border-line-strong" + > + <span class="flex gap-2 text-fg" aria-hidden="true"> + {group.slice(0, 5).map((icon) => ( + <span data-useanimations="" class="block size-8" set:html={icon.firstFrame} /> + ))} + </span> + <span class="mt-5 font-mono text-xs text-accent">interaction="{interaction}"</span> + <span class="mt-2 flex items-baseline justify-between gap-3"> + <span class="text-lg font-semibold tracking-tight text-fg">{INTERACTION_META[interaction].title}</span> + <span class="shrink-0 text-sm text-subtle">{group.length} icons</span> + </span> + <span class="mt-2 text-sm leading-relaxed text-muted">{INTERACTIONS[interaction].description}</span> + <span class="mt-2 text-sm leading-relaxed text-subtle">{INTERACTION_META[interaction].example}</span> + </a> + </li> + )) + } + </ul> + + <div class="mt-16 grid max-w-5xl gap-12 lg:grid-cols-2"> + <section aria-labelledby="choose-title"> + <h2 id="choose-title" class="text-2xl font-semibold tracking-tight">Choosing an interaction</h2> + <ul class="mt-4 list-disc space-y-2 pl-5 leading-relaxed text-muted marker:text-subtle"> + <li><strong class="font-medium text-fg">State that lasts</strong>, such as loading or an active alert: <a class="text-fg underline underline-offset-4" href={interactionPath('loop')}>loop</a>.</li> + <li><strong class="font-medium text-fg">An on/off control</strong>, such as mute, menu or show password: <a class="text-fg underline underline-offset-4" href={interactionPath('click-toggle')}>click toggle</a>, with <code class="font-mono text-sm text-fg">active</code> for controlled state.</li> + <li><strong class="font-medium text-fg">A one-shot action</strong>, such as send, save or refresh: <a class="text-fg underline underline-offset-4" href={interactionPath('click-replay')}>click replay</a>.</li> + <li><strong class="font-medium text-fg">A hint of what a link opens</strong>: <a class="text-fg underline underline-offset-4" href={interactionPath('hover')}>hover</a>, which reverses when the pointer leaves.</li> + <li><strong class="font-medium text-fg">Toolbars and lists people sweep across</strong>: <a class="text-fg underline underline-offset-4" href={interactionPath('hover-replay')}>hover replay</a>, which finishes on its own.</li> + <li><strong class="font-medium text-fg">Links that invite a longer look</strong>, such as social icons: <a class="text-fg underline underline-offset-4" href={interactionPath('hover-loop')}>hover loop</a>.</li> + </ul> + </section> + <section aria-labelledby="motion-title"> + <h2 id="motion-title" class="text-2xl font-semibold tracking-tight">Reduced motion</h2> + <p class="mt-4 leading-relaxed text-muted"> + The React component follows <code class="font-mono text-sm text-fg">prefers-reduced-motion</code> and updates + when the setting changes. Taps on touch screens never start hover animations. + </p> + <ul class="mt-4 space-y-2 text-sm leading-relaxed text-muted"> + {all.map(({ interaction }) => ( + <li><code class="font-mono text-fg">{interaction}</code>: {REDUCED_MOTION[interaction].replace('With reduced motion preferred, ', '')}</li> + ))} + </ul> + </section> + </div> + </div> +</Layout> diff --git a/website/src/pages/lottie-icons.astro b/website/src/pages/lottie-icons.astro new file mode 100644 index 0000000..375b577 --- /dev/null +++ b/website/src/pages/lottie-icons.astro @@ -0,0 +1,337 @@ +--- +import Breadcrumbs from '../components/Breadcrumbs.astro'; +import CodeBlock from '../components/CodeBlock.astro'; +import FaqList from '../components/FaqList.astro'; +import IconGrid from '../components/IconGrid.astro'; +import Layout from '../layouts/Layout.astro'; +import { breadcrumbNode, categories, categoryPath, expressionIcons, hubDescription, hubTitle, itemListNode, pickIcons } from '../lib/hubs'; +import { iconById, icons, lottieSnippet, SITE } from '../lib/icons'; +import { type Faq, faqNode, websiteNode, WEBSITE_ID } from '../lib/seo'; + +const popular = pickIcons([ + 'menu', 'refresh', 'notification', 'heart', 'visibility', 'trash', 'settings', 'search-to-x', + 'play-pause', 'volume', 'download', 'check-box', 'toggle', 'scroll-down', 'arrow-right', 'bookmark', + 'star', 'send', 'github', 'sun', 'infinity', 'alert-triangle', 'microphone', 'shopping-cart', +]); +const expressions = expressionIcons(); +const sizes = icons.map((icon) => Buffer.byteLength(icon.lottie)).toSorted((a, b) => a - b); +const kb = (bytes: number) => `${Math.round(bytes / 1024)} kB`; +const median = kb(sizes[Math.floor(sizes.length / 2)]); +const loop = `${SITE}/icons/loading.json`; +const menu = iconById.get('menu')!; + +const players = [ + { + id: 'web', + title: 'Web: lottie-web', + text: 'The reference player from Airbnb. Load it from a CDN, point it at an icon URL and choose the SVG renderer, which keeps the icon sharp and lets CSS recolor it. Use the full build (lottie.min.js): the light build skips expressions.', + blocks: [ + { + lang: 'html', + code: `<div id="icon" style="width: 48px; height: 48px"></div> + +<script src="https://cdn.jsdelivr.net/npm/lottie-web@5.13.0/build/player/lottie.min.js"></script> +<script> + lottie.loadAnimation({ + container: document.getElementById('icon'), + renderer: 'svg', + loop: true, + autoplay: true, + path: '${loop}', + }); +</script>`, + }, + { + lang: 'css', + label: 'Follow the text color (SVG renderer)', + code: `#icon path[stroke^='rgb']:not([stroke='rgb(255,255,255)']) { stroke: currentColor; } +#icon path[fill='rgb(0,0,0)'] { fill: currentColor; }`, + }, + ], + }, + { + id: 'web-interactions', + title: 'Web: hover and click interactions', + text: `lottie-web plays and stops, but the interactions are up to you. This is the menu icon's click toggle with reduced-motion support; every interaction follows the same pattern, and the React component below does all of it for you.`, + blocks: [ + { + lang: 'js', + code: `import lottie from 'lottie-web'; // npm install lottie-web + +const el = document.querySelector('#menu-button'); +${lottieSnippet(menu)}`, + }, + ], + }, + { + id: 'dotlottie', + title: 'Web: dotLottie web component', + text: "LottieFiles' player renders with a WebAssembly engine to a canvas and plays plain Lottie JSON as well as .lottie files. A canvas keeps the colors of the file, so the icons stay black unless you recolor the JSON.", + blocks: [ + { + lang: 'html', + code: `<script type="module" src="https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-wc@0.10.0/dist/dotlottie-wc.js"></script> + +<dotlottie-wc src="${loop}" autoplay loop style="width: 48px; height: 48px"></dotlottie-wc>`, + }, + { + lang: 'js', + label: 'Or the JavaScript API: npm install @lottiefiles/dotlottie-web', + code: `import { DotLottie } from '@lottiefiles/dotlottie-web'; + +new DotLottie({ + canvas: document.querySelector('#icon-canvas'), + src: '${loop}', + autoplay: true, + loop: true, +});`, + }, + ], + }, + { + id: 'react', + title: 'React: react-useanimations', + text: 'No Lottie player to install: the package has its own 8 kB player, plays each icon’s interaction, follows the text color and respects reduced motion. Each icon is a separate module, so you only ship the ones you import.', + blocks: [ + { + lang: 'jsx', + code: `// npm install react-useanimations +import UseAnimations from 'react-useanimations'; +import loading from 'react-useanimations/lib/loading'; + +<UseAnimations animation={loading} size={48} />`, + }, + ], + }, + { + id: 'react-native', + title: 'React Native: lottie-react-native', + text: 'Bundle the JSON with require() for offline use, or load it from its URL.', + blocks: [ + { + lang: 'jsx', + code: `// npm install lottie-react-native +import LottieView from 'lottie-react-native'; + +<LottieView + source={{ uri: '${loop}' }} // or require('./loading.json') + autoPlay + loop + style={{ width: 48, height: 48 }} +/>`, + }, + ], + }, + { + id: 'ios', + title: 'iOS: lottie-ios (SwiftUI)', + text: 'Add the package from https://github.com/airbnb/lottie-spm.git with Swift Package Manager, then drop the JSON into your bundle or load it from its URL.', + blocks: [ + { + lang: 'swift', + code: `import Lottie +import SwiftUI + +struct LoadingIcon: View { + var body: some View { + // Bundled file: LottieView(animation: .named("loading")) + LottieView { + await LottieAnimation.loadedFrom(url: URL(string: "${loop}")!) + } + .looping() + .frame(width: 48, height: 48) + } +}`, + }, + ], + }, + { + id: 'android', + title: 'Android: lottie-android (Jetpack Compose)', + text: 'Add com.airbnb.android:lottie-compose (or com.airbnb.android:lottie for views). Put the JSON in res/raw for offline use with LottieCompositionSpec.RawRes.', + blocks: [ + { + lang: 'kotlin', + code: `// build.gradle.kts: implementation("com.airbnb.android:lottie-compose:6.7.1") +@Composable +fun LoadingIcon() { + val composition by rememberLottieComposition( + LottieCompositionSpec.Url("${loop}") + ) + LottieAnimation( + composition = composition, + iterations = LottieConstants.IterateForever, + modifier = Modifier.size(48.dp), + ) +}`, + }, + ], + }, + { + id: 'flutter', + title: 'Flutter: lottie', + text: 'The lottie package is a pure Dart player for Android, iOS, web and desktop.', + blocks: [ + { + lang: 'dart', + code: `// pubspec.yaml: lottie: ^3.6.1 +import 'package:lottie/lottie.dart'; + +Lottie.network( + '${loop}', + width: 48, + height: 48, +) // or Lottie.asset('assets/loading.json')`, + }, + ], + }, +]; + +const faq: Faq = [ + { + question: 'Are these Lottie icons free for commercial use?', + answer: `Yes. All ${icons.length} Lottie icons are MIT-licensed: use, modify and redistribute them in personal and commercial projects, with no attribution required and no account to download them.`, + }, + { + question: 'What format are the Lottie files?', + answer: `Standard Lottie JSON (the Bodymovin format) with shape layers only: no images, fonts or external assets. Each icon is drawn on a 24 or 32 px artboard, scales to any size and weighs ${kb(sizes[0])} to ${kb(sizes.at(-1)!)}, ${median} median.`, + }, + { + question: 'Do I need lottie-web to use the icons in React?', + answer: 'No. react-useanimations has its own 8 kB player with no dependencies, so you install one package and import the icons you need. lottie-web is only needed outside React, on plain web pages or in other frameworks.', + }, + { + question: 'Which icons need the full lottie-web build?', + answer: `${expressions.length} icons use After Effects expressions (loopOut or wiggle): ${expressions.map((icon) => icon.title).join(', ')}. On the web they need lottie-web's full build; lottie_light ignores expressions. Native players (lottie-ios, lottie-android, Flutter's lottie) do not evaluate expressions either, so these icons may move differently there.`, + }, + { + question: 'Can I change the color of a Lottie icon?', + answer: "On the web, lottie-web's SVG renderer writes the colors as attributes, so a CSS rule on the paths recolors the icon. Native players use their dynamic property APIs: ColorValueProvider on iOS, LottieProperty.COLOR and STROKE_COLOR on Android, and ValueDelegate.color in Flutter.", + }, +]; + +const title = hubTitle(`Free Lottie icons: ${icons.length} animated JSON icons`, 'Free Lottie icons'); +const description = hubDescription( + `${icons.length} free Lottie icons as plain Lottie JSON, MIT licensed. Copy-paste code for lottie-web, dotLottie, React, React Native, iOS, Android and Flutter.` +); +const crumbs = [ + { name: 'Icons', path: '/icons' }, + { name: 'Lottie icons', path: '/lottie-icons' }, +]; +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'CollectionPage', + '@id': `${SITE}/lottie-icons`, + url: `${SITE}/lottie-icons`, + name: 'Free Lottie icons', + description, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: itemListNode(popular, 'Popular Lottie icons'), + }, + { + '@type': 'Dataset', + name: 'useAnimations icon catalog', + description: `Metadata and file URLs of ${icons.length} animated Lottie icons: id, title, category, keywords, interaction, size and duration.`, + url: `${SITE}/lottie-icons`, + license: 'https://opensource.org/license/mit/', + creator: { '@type': 'Organization', name: 'useAnimations', url: `${SITE}/` }, + distribution: [{ '@type': 'DataDownload', encodingFormat: 'application/json', contentUrl: `${SITE}/icons.json` }], + }, + breadcrumbNode(crumbs), + faqNode(faq), + websiteNode(), + ], +}; +--- + +<Layout title={title} description={description} path="/lottie-icons" jsonLd={jsonLd}> + <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> + <Breadcrumbs crumbs={crumbs} /> + + <header class="mt-8 max-w-3xl"> + <p class="text-sm font-medium text-accent">{icons.length} icons · Lottie JSON · MIT licensed</p> + <h1 class="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-5xl">Free Lottie icons</h1> + <p class="mt-4 text-lg leading-relaxed text-muted"> + Every useAnimations icon is a standard Lottie JSON animation, so any Lottie player can play it: lottie-web on + the web, lottie-ios, lottie-android, Flutter and React Native. The files are free under the MIT license, with + no sign-up, and served from stable URLs with CORS enabled, so you can load them directly or download them into + your project. + </p> + </header> + + <section class="mt-12" aria-labelledby="popular-title"> + <div class="flex flex-wrap items-baseline justify-between gap-3"> + <h2 id="popular-title" class="text-lg font-semibold">Popular Lottie icons</h2> + <a href="/icons" class="text-sm text-muted hover:text-fg">All {icons.length} icons →</a> + </div> + <IconGrid class="mt-4" icons={popular} /> + </section> + + <div class="mt-16 max-w-3xl"> + <section aria-labelledby="files-title"> + <h2 id="files-title" class="text-2xl font-semibold tracking-tight">Files and URLs</h2> + <p class="mt-3 leading-relaxed text-muted"> + Each icon has a page at <code class="font-mono text-sm text-fg">/icons/<id></code> with a preview and + downloads. The files themselves: + </p> + <CodeBlock + class="mt-4" + lang="bash" + code={`${SITE}/icons/<id>.json # Lottie JSON animation +${SITE}/icons/<id>.zip # the JSON and static SVGs +${SITE}/icons/<id>/<state>.svg # static SVG of each state +${SITE}/icons.json # catalog of every icon`} + track={{ code: 'files' }} + /> + <p class="mt-4 leading-relaxed text-muted"> + <a href="/icons.json" class="text-fg underline underline-offset-4">icons.json</a> lists every icon with its id, + title, category, keywords, interaction, size, duration and file URLs, which makes it easy to pick icons by + keyword in a script or let a coding agent choose one. Lottie files weigh {median} at the median. + </p> + </section> + + { + players.map((player) => ( + <section id={player.id} class="mt-14 scroll-mt-24" aria-labelledby={`${player.id}-title`}> + <h2 id={`${player.id}-title`} class="text-2xl font-semibold tracking-tight">{player.title}</h2> + <p class="mt-3 leading-relaxed text-muted">{player.text}</p> + {player.blocks.map((block) => ( + <CodeBlock class="mt-4" lang={block.lang} label={block.label} code={block.code} track={{ code: `lottie-${player.id}` }} /> + ))} + </section> + )) + } + + <section id="expressions" class="mt-14 scroll-mt-24" aria-labelledby="expressions-title"> + <h2 id="expressions-title" class="text-2xl font-semibold tracking-tight">Icons with expressions</h2> + <p class="mt-3 leading-relaxed text-muted"> + {expressions.length} icons use After Effects expressions + (<code class="font-mono text-sm text-fg">loopOut</code> or <code class="font-mono text-sm text-fg">wiggle</code>). On the web, play them with lottie-web's full build, + not <code class="font-mono text-sm text-fg">lottie_light</code>. Native players do not evaluate expressions, so + test these on iOS, Android and Flutter or pick another icon; react-useanimations plays them as designed. + </p> + <IconGrid class="mt-4" icons={expressions} /> + </section> + </div> + + <FaqList class="mt-16" faq={faq} /> + + <nav aria-labelledby="more-title" class="mt-16 border-t border-line pt-8"> + <h2 id="more-title" class="text-lg font-semibold">Lottie icons by category</h2> + <ul class="mt-4 flex flex-wrap gap-2"> + { + categories().map(({ meta, icons: group }) => ( + <li> + <a href={categoryPath(meta)} class="inline-flex rounded-full border border-line px-3.5 py-1.5 text-sm text-muted transition hover:border-line-strong hover:text-fg"> + {meta.name} <span class="ml-1.5 text-subtle">{group.length}</span> + </a> + </li> + )) + } + </ul> + </nav> + </div> +</Layout> diff --git a/website/src/pages/shadcn-animated-icons.astro b/website/src/pages/shadcn-animated-icons.astro new file mode 100644 index 0000000..85ea0b9 --- /dev/null +++ b/website/src/pages/shadcn-animated-icons.astro @@ -0,0 +1,179 @@ +--- +import Breadcrumbs from '../components/Breadcrumbs.astro'; +import CodeBlock from '../components/CodeBlock.astro'; +import FaqList from '../components/FaqList.astro'; +import IconGrid from '../components/IconGrid.astro'; +import Layout from '../layouts/Layout.astro'; +import { breadcrumbNode, categories, categoryPath, hubDescription, hubTitle, itemListNode, pickIcons } from '../lib/hubs'; +import { iconById, icons, shadcnCommand, shadcnExample, SITE, VERSION } from '../lib/icons'; +import { type Faq, faqNode, websiteNode, WEBSITE_ID } from '../lib/seo'; + +const popular = pickIcons([ + 'menu', 'check-box', 'toggle', 'visibility', 'heart', 'bookmark', 'star', 'trash', + 'copy', 'download', 'search-to-x', 'settings', 'notification', 'refresh', 'send', 'sun', + 'play-pause', 'volume', 'shopping-cart', 'alert-circle', 'arrow-right', 'external-link', 'github', 'plus-to-x', +]); +const menu = iconById.get('menu')!; +const checkmark = iconById.get('checkmark')!; + +const componentsJson = `{ + "registries": { + "@useanimations": "${SITE}/r/{name}.json" + } +}`; +const toggleExample = `"use client" + +import { useState } from "react" +import { AnimatedMenu } from "@/components/animations/menu" +import { Button } from "@/components/ui/button" + +export function MenuToggle() { + const [open, setOpen] = useState(false) + return ( + <Button + variant="ghost" + size="icon" + aria-label={open ? "Close menu" : "Open menu"} + aria-expanded={open} + onClick={() => setOpen(!open)} + > + <AnimatedMenu size={20} active={open} /> + </Button> + ) +}`; + +const faq: Faq = [ + { + question: 'Are the shadcn/ui animated icons free?', + answer: `Yes. All ${icons.length} icons and the component code are MIT-licensed, for personal and commercial projects. The registry needs no account or API key.`, + }, + { + question: 'What does the shadcn CLI add to my project?', + answer: `The icon component in components/animations/<id>.tsx, the shared UseAnimation player wrapper in components/use-animation.tsx (once, as a registry dependency) and react-useanimations@^${VERSION} from npm. lottie-web is not needed: the package has its own 8 kB player.`, + }, + { + question: 'Do the components work with Next.js server components?', + answer: 'Yes. The components are marked "use client" and draw the animation only in the browser, so you can render them from server components and pages without dynamic imports or ssr: false.', + }, + { + question: 'Can I change how the icons look and move?', + answer: 'The code is yours, in your repository. The components take the props of UseAnimations: size, strokeColor, fillColor, interaction, active, autoplay, loop and speed. Icons follow the text color, so Tailwind classes such as text-primary or text-muted-foreground recolor them.', + }, +]; + +const title = hubTitle(`Animated icons for shadcn/ui: ${icons.length} components`, 'Animated icons for shadcn/ui'); +const description = hubDescription( + `${icons.length} animated icons for shadcn/ui: add any icon with one shadcn CLI command. Hover, click and loop micro interactions, MIT licensed, no lottie-web.` +); +const crumbs = [ + { name: 'Icons', path: '/icons' }, + { name: 'shadcn/ui', path: '/shadcn-animated-icons' }, +]; +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'CollectionPage', + '@id': `${SITE}/shadcn-animated-icons`, + url: `${SITE}/shadcn-animated-icons`, + name: 'Animated icons for shadcn/ui', + description, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: itemListNode(popular, 'Popular shadcn/ui animated icons'), + }, + breadcrumbNode(crumbs), + faqNode(faq), + websiteNode(), + ], +}; +--- + +<Layout title={title} description={description} path="/shadcn-animated-icons" jsonLd={jsonLd}> + <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> + <Breadcrumbs crumbs={crumbs} /> + + <header class="mt-8 max-w-3xl"> + <p class="text-sm font-medium text-accent">{icons.length} components · shadcn registry · MIT licensed</p> + <h1 class="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-5xl">Animated icons for shadcn/ui</h1> + <p class="mt-4 text-lg leading-relaxed text-muted"> + Every useAnimations icon is in a shadcn registry. One CLI command adds a typed React component to your + project, with the hover, click or loop interaction it was designed for, reduced-motion support and colors that + follow your theme. The code lands in your repository, like any other shadcn/ui component. + </p> + </header> + + <div class="mt-12 max-w-3xl"> + <section aria-labelledby="add-title"> + <h2 id="add-title" class="text-2xl font-semibold tracking-tight">Add an icon</h2> + <p class="mt-3 leading-relaxed text-muted">Run the shadcn CLI with the icon's registry URL:</p> + <CodeBlock class="mt-4" lang="bash" code={shadcnCommand(checkmark)} track={{ icon: checkmark.id, code: 'shadcn-command' }} /> + <CodeBlock class="mt-3" lang="tsx" code={shadcnExample(checkmark.id, checkmark.component)} track={{ icon: checkmark.id, code: 'shadcn' }} /> + <p class="mt-4 leading-relaxed text-muted"> + The component is named <code class="font-mono text-sm text-fg">Animated</code> + the icon id in PascalCase + (<code class="font-mono text-sm text-fg">check-box</code> → <code class="font-mono text-sm text-fg">AnimatedCheckBox</code>) + and lands in <code class="font-mono text-sm text-fg">components/animations/<id>.tsx</code>. The CLI also + adds the shared <code class="font-mono text-sm text-fg">components/use-animation.tsx</code> wrapper and installs{' '} + <code class="font-mono text-sm text-fg">react-useanimations</code>, which brings its own player: no lottie-web. + </p> + </section> + + <section class="mt-14" aria-labelledby="namespace-title"> + <h2 id="namespace-title" class="text-2xl font-semibold tracking-tight">Short names with a registry namespace</h2> + <p class="mt-3 leading-relaxed text-muted"> + Add the registry to <code class="font-mono text-sm text-fg">components.json</code> once, then add icons by + name, several at a time: + </p> + <CodeBlock class="mt-4" lang="json" code={componentsJson} track={{ code: 'components-json' }} /> + <CodeBlock class="mt-3" lang="bash" code="npx shadcn@latest add @useanimations/menu @useanimations/heart @useanimations/loading" track={{ code: 'shadcn-namespace' }} /> + <p class="mt-4 leading-relaxed text-muted"> + The registry index at <a href="/r/registry.json" class="text-fg underline underline-offset-4">/r/registry.json</a>{' '} + lists every item with its title, category and interaction. + </p> + </section> + + <section class="mt-14" aria-labelledby="usage-title"> + <h2 id="usage-title" class="text-2xl font-semibold tracking-tight">Use it in a shadcn Button</h2> + <p class="mt-3 leading-relaxed text-muted"> + The components take the same props as <code class="font-mono text-sm text-fg">UseAnimations</code>. For a + toggle such as the menu icon, pass <code class="font-mono text-sm text-fg">active</code> and keep the state in + your button; the icon animates whenever it changes: + </p> + <CodeBlock class="mt-3" lang="bash" code={shadcnCommand(menu)} track={{ icon: menu.id, code: 'shadcn-command' }} /> + <CodeBlock class="mt-3" lang="tsx" code={toggleExample} track={{ icon: menu.id, code: 'shadcn' }} /> + <p class="mt-4 leading-relaxed text-muted"> + Icons follow the text color, so <code class="font-mono text-sm text-fg">text-primary</code> or{' '} + <code class="font-mono text-sm text-fg">text-muted-foreground</code> recolor them and dark mode needs nothing + extra. More props and patterns are in the{' '} + <a href="/documentation#shadcn" class="text-fg underline underline-offset-4">documentation</a>. + </p> + </section> + </div> + + <section class="mt-16" aria-labelledby="popular-title"> + <div class="flex flex-wrap items-baseline justify-between gap-3"> + <h2 id="popular-title" class="text-lg font-semibold">Popular icons for shadcn/ui</h2> + <a href="/icons" class="text-sm text-muted hover:text-fg">All {icons.length} icons →</a> + </div> + <p class="mt-2 text-sm text-muted">Every icon page shows its shadcn command.</p> + <IconGrid class="mt-4" icons={popular} /> + </section> + + <FaqList class="mt-16" faq={faq} /> + + <nav aria-labelledby="more-title" class="mt-16 border-t border-line pt-8"> + <h2 id="more-title" class="text-lg font-semibold">Browse by category</h2> + <ul class="mt-4 flex flex-wrap gap-2"> + { + categories().map(({ meta, icons: group }) => ( + <li> + <a href={categoryPath(meta)} class="inline-flex rounded-full border border-line px-3.5 py-1.5 text-sm text-muted transition hover:border-line-strong hover:text-fg"> + {meta.name} <span class="ml-1.5 text-subtle">{group.length}</span> + </a> + </li> + )) + } + </ul> + </nav> + </div> +</Layout> From 44bb05af268d4d48d483bcba1a17e8f879718a2a Mon Sep 17 00:00:00 2001 From: Tuan Phung <tuan@phung.cz> Date: Sun, 11 Oct 2026 11:59:00 +0200 Subject: [PATCH 3/5] Website: richer icon pages, per-icon OG images and Markdown versions - Icon pages use the generated titles, descriptions, intros and FAQs from lib/seo.ts, with synonyms, linked interaction and category facts, a real breadcrumb, other versions and related icons - Usage tabs: React, shadcn/ui, HTML + lottie-web (full build for icons with expressions), native mobile players and static SVG - Static SVG previews as <img>, inverted in dark mode - JSON-LD graph: ItemPage, ImageObject, MediaObject, SoftwareSourceCode, BreadcrumbList, FAQPage - /og/icons/<id>.png social cards drawn with resvg and Geist at build time - /icons/<id>.md Markdown version of each icon page - Layout: image, image alt, og:type and Markdown alternate props --- package-lock.json | 226 +++++++++++++++++ website/package.json | 2 + website/src/components/IconUsageTabs.astro | 103 ++++++++ website/src/layouts/Layout.astro | 29 ++- website/src/lib/icons.ts | 66 +++++ website/src/lib/og-image.ts | 222 ++++++++++++++++ website/src/pages/icons/[id].astro | 278 ++++++++++++++++----- website/src/pages/icons/[id].md.ts | 94 +++++++ website/src/pages/og/icons/[id].png.ts | 10 + 9 files changed, 960 insertions(+), 70 deletions(-) create mode 100644 website/src/components/IconUsageTabs.astro create mode 100644 website/src/lib/og-image.ts create mode 100644 website/src/pages/icons/[id].md.ts create mode 100644 website/src/pages/og/icons/[id].png.ts diff --git a/package-lock.json b/package-lock.json index 6b343d8..5ad7ad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -780,6 +780,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-u03ftlfVwb05uijq2jGDJ03/HKGqMKaNFk7KOhkpBUex8+BhrAOfMs/XciJ2UUNUX9fEsZbZQld/RWMA2oFU7g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -2121,6 +2130,221 @@ "dev": true, "license": "MIT" }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", @@ -7758,6 +7982,8 @@ "@astrojs/react": "^7.0.1", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource/geist": "^5.3.0", + "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.3.3", "astro": "^7.3.8", "fflate": "^0.8.3", diff --git a/website/package.json b/website/package.json index 19cf427..47a9fe8 100644 --- a/website/package.json +++ b/website/package.json @@ -11,6 +11,8 @@ "@astrojs/react": "^7.0.1", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@fontsource/geist": "^5.3.0", + "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.3.3", "astro": "^7.3.8", "fflate": "^0.8.3", diff --git a/website/src/components/IconUsageTabs.astro b/website/src/components/IconUsageTabs.astro new file mode 100644 index 0000000..29bb833 --- /dev/null +++ b/website/src/components/IconUsageTabs.astro @@ -0,0 +1,103 @@ +--- +// How to use one icon on each platform, as tabs (scripts/tabs.ts): React, shadcn/ui, HTML with +// lottie-web, native mobile players and the static SVGs. +import CodeBlock from './CodeBlock.astro'; +import { + htmlLottieExample, + type Icon, + mobileExamples, + reactExample, + shadcnCommand, + shadcnExample, + svgImgExample, +} from '../lib/icons'; + +interface Props { + icon: Icon; +} +const { icon } = Astro.props; + +type Block = { lang: string; code: string; codeId: string; label?: string }; +type Tab = { key: string; name: string; blocks: Block[]; note?: string }; + +const expressions = icon.requiresFullPlayer; +const tabs: Tab[] = [ + { + key: 'react', + name: 'React', + blocks: [ + { lang: 'bash', code: 'npm install react-useanimations', codeId: 'install' }, + { lang: 'jsx', code: reactExample(icon.exportName, icon.name), codeId: 'react' }, + ], + }, + { + key: 'shadcn', + name: 'shadcn/ui', + blocks: [ + { lang: 'bash', code: shadcnCommand(icon), codeId: 'shadcn-command' }, + { lang: 'tsx', code: shadcnExample(icon.id, icon.component), codeId: 'shadcn' }, + ], + }, + { + key: 'html', + name: 'HTML + Lottie', + blocks: [{ lang: 'html', code: htmlLottieExample(icon), codeId: 'html-lottie' }], + note: expressions + ? `${icon.title} uses After Effects expressions: load the full lottie.min.js, not lottie_light.` + : `${icon.title} runs on the light build, lottie_light.min.js.`, + }, + { + key: 'mobile', + name: 'Mobile', + blocks: mobileExamples(icon).map((example) => ({ + lang: example.lang, + code: example.code, + codeId: `mobile-${example.lang}`, + label: example.name, + })), + note: expressions + ? `Native players may not support the After Effects expressions in ${icon.id}.json; check it on a device.` + : `Bundle ${icon.id}.json with the app to use it offline.`, + }, +]; +if (Object.keys(icon.svgs).length) { + tabs.push({ + key: 'svg', + name: 'Static SVG', + blocks: [{ lang: 'html', code: svgImgExample(icon), codeId: 'svg-img' }], + note: 'Inside <img> the SVG draws black; inline it to follow the text color.', + }); +} +--- + +<div data-tabs class="min-w-0"> + <div role="tablist" aria-label="Platform" class="no-scrollbar flex gap-1 overflow-x-auto rounded-full border border-line bg-surface-2 p-1"> + { + tabs.map((tab, i) => ( + <button + type="button" + role="tab" + id={`usage-tab-${tab.key}`} + aria-controls={`usage-panel-${tab.key}`} + aria-selected={i === 0 ? 'true' : 'false'} + data-track="platform-tab" + data-track-tab={tab.key} + data-track-icon={icon.id} + class="shrink-0 rounded-full px-4 py-1.5 text-sm text-muted transition aria-selected:bg-surface aria-selected:text-fg aria-selected:shadow-sm" + > + {tab.name} + </button> + )) + } + </div> + { + tabs.map((tab, i) => ( + <div role="tabpanel" id={`usage-panel-${tab.key}`} aria-labelledby={`usage-tab-${tab.key}`} hidden={i !== 0} class="mt-4"> + {tab.blocks.map((block) => ( + <CodeBlock class="mt-3" lang={block.lang} code={block.code} label={block.label} track={{ icon: icon.id, code: block.codeId }} /> + ))} + {tab.note && <p class="mt-3 text-sm text-muted">{tab.note}</p>} + </div> + )) + } +</div> diff --git a/website/src/layouts/Layout.astro b/website/src/layouts/Layout.astro index a96af10..89f8fc9 100644 --- a/website/src/layouts/Layout.astro +++ b/website/src/layouts/Layout.astro @@ -16,11 +16,31 @@ interface Props { /** Extra <link>/<meta> tags. */ head?: { rel: string; href: string; type?: string; title?: string }[]; noindex?: boolean; + /** Social card, a path or URL of a 1200 × 630 image; the site-wide card by default. */ + image?: string; + imageAlt?: string; + /** og:type, e.g. 'article'. */ + type?: string; + /** Path of the page's Markdown version, e.g. /icons/heart.md. */ + markdown?: string; } -const { title, description, path, lang = 'en', alternates, jsonLd, head = [], noindex } = Astro.props; +const { + title, + description, + path, + lang = 'en', + alternates, + jsonLd, + head = [], + noindex, + image: imagePath = '/og.png', + imageAlt = 'useAnimations: free animated icons for React, shadcn/ui, Lottie and SVG', + type = 'website', + markdown, +} = Astro.props; const canonical = path && new URL(path, SITE).href; -const image = `${SITE}/og.png`; +const image = new URL(imagePath, SITE).href; --- <!doctype html> @@ -38,8 +58,9 @@ const image = `${SITE}/og.png`; <link rel="alternate" hreflang={hreflang} href={new URL(href, SITE).href} /> )) } + {markdown && <link rel="alternate" type="text/markdown" href={markdown} title={`${title} (Markdown)`} />} {head.map((link) => <link {...link} />)} - <meta property="og:type" content="website" /> + <meta property="og:type" content={type} /> <meta property="og:site_name" content="useAnimations" /> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> @@ -47,11 +68,13 @@ const image = `${SITE}/og.png`; <meta property="og:image" content={image} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> + <meta property="og:image:alt" content={imageAlt} /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:site" content="@useAnimations" /> <meta name="twitter:title" content={title} /> <meta name="twitter:description" content={description} /> <meta name="twitter:image" content={image} /> + <meta name="twitter:image:alt" content={imageAlt} /> <meta name="theme-color" content="#fbfbfd" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#08090c" media="(prefers-color-scheme: dark)" /> <link rel="icon" href="/favicon.ico" sizes="32x32" /> diff --git a/website/src/lib/icons.ts b/website/src/lib/icons.ts index 142a2db..ae6ec1b 100644 --- a/website/src/lib/icons.ts +++ b/website/src/lib/icons.ts @@ -176,6 +176,8 @@ export const urls = (icon: Icon) => ({ zip: `${SITE}/icons/${icon.id}.zip`, svg: Object.keys(icon.svgs).map((state) => `${SITE}/icons/${icon.id}/${state}.svg`), registry: `${SITE}/r/${icon.id}.json`, + markdown: `${SITE}/icons/${icon.id}.md`, + og: `${SITE}/og/icons/${icon.id}.png`, }); export const shadcnCommand = (icon: Icon) => `npx shadcn@latest add ${urls(icon).registry}`; @@ -198,6 +200,70 @@ const SNIPPETS: Record<Interaction, string> = { export const lottieSnippet = (icon: Icon) => SNIPPETS[icon.interaction].replace('{lottie}', urls(icon).lottie); +/** The lottie-web version the HTML example loads from a CDN. */ +export const LOTTIE_WEB_VERSION = '5.13.0'; + +/** A complete HTML page fragment: a container, lottie-web from a CDN and the interaction code. */ +export const htmlLottieExample = (icon: Icon) => { + // The light build has no expression support; icons with expressions need the full player. + const build = icon.requiresFullPlayer ? 'lottie.min.js' : 'lottie_light.min.js'; + return `<div id="${icon.id}-icon" style="width: 48px; height: 48px"></div> + +<script src="https://cdn.jsdelivr.net/npm/lottie-web@${LOTTIE_WEB_VERSION}/build/player/${build}"></script> +<script> + const el = document.getElementById('${icon.id}-icon'); + ${lottieSnippet(icon).replaceAll('\n', '\n ')} +</script>`; +}; + +/** Minimal native examples that load the Lottie JSON from its URL. */ +export const mobileExamples = (icon: Icon) => { + const url = urls(icon).lottie; + const loop = icon.interaction === 'loop'; + return [ + { + name: 'React Native', + lang: 'tsx', + code: `import LottieView from 'lottie-react-native'; + +<LottieView source={{ uri: '${url}' }} autoPlay${loop ? ' loop' : ' loop={false}'} style={{ width: 48, height: 48 }} />`, + }, + { + name: 'iOS (SwiftUI)', + lang: 'swift', + code: `import Lottie + +LottieView { + await LottieAnimation.loadedFrom(url: URL(string: "${url}")!) +} +.playing(loopMode: ${loop ? '.loop' : '.playOnce'}) +.frame(width: 48, height: 48)`, + }, + { + name: 'Android (Compose)', + lang: 'kotlin', + code: `val composition by rememberLottieComposition(LottieCompositionSpec.Url("${url}")) +LottieAnimation(composition, iterations = ${loop ? 'LottieConstants.IterateForever' : '1'}, modifier = Modifier.size(48.dp))`, + }, + { + name: 'Flutter', + lang: 'dart', + code: `import 'package:lottie/lottie.dart'; + +Lottie.network('${url}', width: 48, height: 48, repeat: ${loop})`, + }, + ]; +}; + +/** <img> tags for the static SVGs. */ +export const svgImgExample = (icon: Icon) => + Object.keys(icon.svgs) + .map( + (state) => + `<img src="${SITE}/icons/${icon.id}/${state}.svg" alt="${icon.title}" width="24" height="24" />` + ) + .join('\n'); + /** Keywords plus everything else a search should match. */ export const searchText = (icon: Icon) => [icon.title, icon.name, icon.id, icon.category, ...icon.keywords].join(' ').toLowerCase(); diff --git a/website/src/lib/og-image.ts b/website/src/lib/og-image.ts new file mode 100644 index 0000000..6ee630c --- /dev/null +++ b/website/src/lib/og-image.ts @@ -0,0 +1,222 @@ +// Per-icon social cards (/og/icons/<id>.png): an SVG composed at build time and drawn by resvg, +// in the look of the site-wide og.png (dark background, faint grid, accent glow, Geist). +import { Resvg } from '@resvg/resvg-js'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { inflateSync } from 'node:zlib'; +import { parseHTML } from 'linkedom'; +import { createRenderer } from '../../../src/player/render'; +import { type Icon, INTERACTIONS, SITE } from './icons'; + +export const OG_WIDTH = 1200; +export const OG_HEIGHT = 630; + +const BG = '#08090c'; +const FG = '#eef0f6'; +const MUTED = '#9aa0b2'; +const SUBTLE = '#6b7183'; +const SURFACE = '#101218'; +const ACCENT = '#7b8bff'; +const ACCENT_2 = '#c084fc'; + +/** Table lengths in a font file are padded to 4 bytes. */ +const padded = (n: number) => (n + 3) & ~3; + +/** WOFF 1.0 -> the TrueType/OpenType file it wraps: resvg reads only TTF/OTF. */ +const woffToSfnt = (woff: Buffer): Buffer => { + const flavor = woff.readUInt32BE(4); + const count = woff.readUInt16BE(12); + const tables = Array.from({ length: count }, (_, i) => { + const at = 44 + i * 20; + const offset = woff.readUInt32BE(at + 4); + const compressed = woff.readUInt32BE(at + 8); + const length = woff.readUInt32BE(at + 12); + const raw = woff.subarray(offset, offset + compressed); + return { + tag: woff.readUInt32BE(at), + checksum: woff.readUInt32BE(at + 16), + data: compressed < length ? inflateSync(raw) : raw, + }; + }); + const header = 12 + count * 16; + const out = Buffer.alloc(header + tables.reduce((sum, t) => sum + padded(t.data.length), 0)); + const log2 = Math.floor(Math.log2(count)); + out.writeUInt32BE(flavor, 0); + out.writeUInt16BE(count, 4); + out.writeUInt16BE(2 ** log2 * 16, 6); + out.writeUInt16BE(log2, 8); + out.writeUInt16BE(count * 16 - 2 ** log2 * 16, 10); + let offset = header; + tables.forEach((table, i) => { + const at = 12 + i * 16; + out.writeUInt32BE(table.tag, at); + out.writeUInt32BE(table.checksum, at + 4); + out.writeUInt32BE(offset, at + 8); + out.writeUInt32BE(table.data.length, at + 12); + table.data.copy(out, offset); + offset += padded(table.data.length); + }); + return out; +}; + +// Fontsource names the weights as families: 'Geist' (400) and 'Geist SemiBold' (600). +const REGULAR = 'Geist'; +const SEMIBOLD = 'Geist SemiBold'; + +let fonts: string[] | undefined; +/** Geist as TTF files in the temp directory: resvg's Node binding loads fonts only from files. */ +const fontFiles = () => { + if (!fonts) { + // The package's exports cover only its CSS, so find the font files next to its LICENSE. + const require = createRequire(join(import.meta.env.REPO_DIR, 'website/package.json')); + const source = join(dirname(require.resolve('@fontsource/geist/LICENSE')), 'files'); + const dir = join(tmpdir(), 'useanimations-og-fonts'); + mkdirSync(dir, { recursive: true }); + fonts = [400, 600].map((weight) => { + const file = join(dir, `geist-${weight}.ttf`); + writeFileSync( + file, + woffToSfnt(readFileSync(join(source, `geist-latin-${weight}-normal.woff`))) + ); + return file; + }); + } + return fonts; +}; + +const escape = (text: string) => + text.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!); + +const LOGO_MARK = + '<path fill="url(#accent)" fill-opacity=".55" d="M11.29 33.89c.28 0 1.3.18 1.3-1.1V14.96a3 3 0 0 0-3-3H3a3 3 0 0 0-3 3v7.97c0 6.05 5.05 10.96 11.29 10.96"/><path fill="url(#accent)" d="M32.61.01c.43 0 2.02-.27 2.02 1.71V31a3 3 0 0 1-3 3H18.11a3 3 0 0 1-3-3V17c0-9.38 7.84-16.99 17.5-16.99"/>'; + +const { document } = parseHTML('<!doctype html><html><body></body></html>'); + +/** The icon at `progress` (0–1) of its animation, drawn by the library's renderer. */ +const frameAt = (icon: Icon, progress: number) => { + const data = JSON.parse(icon.lottie) as { fr: number; ip: number; op: number }; + const previous = (globalThis as { document?: unknown }).document; + (globalThis as { document?: unknown }).document = document; + try { + const { svg, render } = createRenderer(data as never, { frameRate: data.fr, warn: () => {} }); + render(data.ip + (data.op - 1 - data.ip) * progress); + return svg.outerHTML.replace(/__ua[a-z0-9]*_/g, `__og_${icon.id}_`); + } finally { + (globalThis as { document?: unknown }).document = previous; + } +}; + +const recolor = (svg: string) => + svg + .replace(/(stroke|fill)="rgb\(255,\s*255,\s*255\)"/g, `$1="${SURFACE}"`) + .replace(/(stroke|fill)="rgb\([^)]*\)"/g, `$1="${ACCENT}"`) + .replaceAll('currentColor', ACCENT); + +/** The drawing as a nested <svg> at (x, y), size × size; undefined without a viewBox. */ +const place = (svg: string, x: number, y: number, size: number) => { + const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1]; + if (!viewBox) return undefined; + return svg.replace( + /^<svg[^>]*>/, + `<svg xmlns="http://www.w3.org/2000/svg" x="${x}" y="${y}" width="${size}" height="${size}" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" fill="none">` + ); +}; + +/** True when the drawing paints at least a few pixels (trimmed or transparent frames don't). */ +const paints = (svg: string) => { + const pixels = new Resvg(svg, { + fitTo: { mode: 'width', value: 48 }, + font: { loadSystemFonts: false }, + }).render().pixels; + let painted = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0 && ++painted > 20) return true; + return false; +}; + +/** + * The icon in the accent color: the first frame, as on the page before any script; for frames that + * draw nothing (loaders that start empty) a later frame, then a static SVG, then the logo mark. + */ +const drawing = (icon: Icon, x: number, y: number, size: number) => { + const candidates = [ + () => icon.firstFrame, + () => frameAt(icon, 0.5), + () => frameAt(icon, 0.25), + ...Object.values(icon.svgs).map((svg) => () => svg), + ]; + for (const candidate of candidates) { + const svg = recolor(candidate()); + const preview = place(svg, 0, 0, 48); + if (preview && paints(preview)) return place(svg, x, y, size)!; + } + return `<svg x="${x + size * 0.2}" y="${y + size * 0.2}" width="${size * 0.6}" height="${size * 0.6}" viewBox="0 0 35 34">${LOGO_MARK}</svg>`; +}; + +/** Splits a title into at most two lines and picks a font size that fits `width`. */ +const fitTitle = (title: string, width: number) => { + // Geist SemiBold at -0.04 em tracking averages under 0.48 em per character in title case. + const fits = (text: string, size: number) => text.length * size * 0.48 <= width; + for (const size of [96, 88, 80, 72]) if (fits(title, size)) return { lines: [title], size }; + // Two lines: the split whose longer line is shortest, at the largest size that fits it. + const words = title.split(' '); + const splits = words + .slice(1) + .map((_, i) => [words.slice(0, i + 1).join(' '), words.slice(i + 1).join(' ')]); + const lines = splits.reduce<string[]>( + (best, split) => + Math.max(...split.map((l) => l.length)) < Math.max(...best.map((l) => l.length)) + ? split + : best, + [title] + ); + const longest = lines.reduce((a, b) => (a.length >= b.length ? a : b)); + return { lines, size: [88, 80, 72, 64].find((size) => fits(longest, size)) ?? 56 }; +}; + +export const ogSvg = (icon: Icon): string => { + const formats = ['React', 'Lottie', ...(Object.keys(icon.svgs).length ? ['SVG'] : [])]; + const tagline = ['Animated icon', INTERACTIONS[icon.interaction].label, ...formats].join(' · '); + const { lines, size } = fitTitle(icon.title, 600); + const lineHeight = size * 1.02; + // The first baseline: 330 for one line, a little under half a line higher for two. + const baseline = 330 - (lines.length - 1) * (lineHeight / 2 - 8); + const tile = { x: 720, y: 115, size: 400 }; + const host = SITE.replace(/^https?:\/\//, ''); + return `<svg xmlns="http://www.w3.org/2000/svg" width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}"> +<defs> + <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0.3"><stop offset="0" stop-color="${ACCENT}"/><stop offset="1" stop-color="${ACCENT_2}"/></linearGradient> + <radialGradient id="glow" cx="0.72" cy="0.1" r="0.7"><stop offset="0" stop-color="${ACCENT}" stop-opacity="0.22"/><stop offset="1" stop-color="${ACCENT}" stop-opacity="0"/></radialGradient> + <radialGradient id="tileGlow" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="${ACCENT_2}" stop-opacity="0.16"/><stop offset="1" stop-color="${ACCENT_2}" stop-opacity="0"/></radialGradient> + <radialGradient id="fade" cx="0" cy="0.5" r="0.8"><stop offset="0.1" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient> + <pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="#fff" stroke-opacity="0.07"/></pattern> + <mask id="gridMask"><rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#fade)"/></mask> +</defs> +<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="${BG}"/> +<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#glow)"/> +<rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="url(#grid)" mask="url(#gridMask)" opacity="0.5"/> +<svg x="80" y="96" width="56" height="54" viewBox="0 0 35 34">${LOGO_MARK}</svg> +<text x="152" y="134" font-family="${SEMIBOLD}" font-size="30" letter-spacing="-0.6" fill="${FG}">useAnimations</text> +<text font-family="${SEMIBOLD}" font-size="${size}" letter-spacing="${(-size * 0.04).toFixed(1)}" fill="${FG}">${lines + .map( + (line, i) => + `<tspan x="76" y="${(baseline + i * lineHeight).toFixed(1)}">${escape(line)}</tspan>` + ) + .join('')}</text> +<text x="80" y="${(baseline + (lines.length - 1) * lineHeight + 64).toFixed(1)}" font-family="${REGULAR}" font-size="26" fill="${MUTED}">${escape(tagline)}</text> +<text x="80" y="540" font-family="${REGULAR}" font-size="22" fill="${SUBTLE}">${escape(`${host}/icons/${icon.id}`)}</text> +<rect x="${tile.x - 80}" y="${tile.y - 80}" width="${tile.size + 160}" height="${tile.size + 160}" fill="url(#tileGlow)"/> +<rect x="${tile.x}" y="${tile.y}" width="${tile.size}" height="${tile.size}" rx="64" fill="${SURFACE}" stroke="#fff" stroke-opacity="0.13" stroke-width="2"/> +${drawing(icon, tile.x + 80, tile.y + 80, tile.size - 160)} +</svg>`; +}; + +export const ogPng = (icon: Icon): Uint8Array<ArrayBuffer> => + new Uint8Array( + new Resvg(ogSvg(icon), { + font: { fontFiles: fontFiles(), loadSystemFonts: false, defaultFontFamily: REGULAR }, + }) + .render() + .asPng() + ); diff --git a/website/src/pages/icons/[id].astro b/website/src/pages/icons/[id].astro index dd85f31..dc8f17a 100644 --- a/website/src/pages/icons/[id].astro +++ b/website/src/pages/icons/[id].astro @@ -1,19 +1,28 @@ --- import type { GetStaticPaths } from 'astro'; -import CodeBlock from '../../components/CodeBlock.astro'; import IconPreview from '../../components/IconPreview'; +import IconUsageTabs from '../../components/IconUsageTabs.astro'; import Layout from '../../layouts/Layout.astro'; +import { lastModifiedOf } from '../../lib/git'; +import { type Icon, icons, INTERACTIONS, reactExample, SITE, urls } from '../../lib/icons'; import { - filesExample, - type Icon, - icons, - INTERACTIONS, - reactExample, - shadcnCommand, - shadcnExample, - SITE, - urls, -} from '../../lib/icons'; + faqNode, + iconDescription, + iconFaq, + iconIntro, + iconTitle, + meaningfulKeywords, + ORGANIZATION_ID, + organizationNode, + related as relatedIcons, + synonyms, + variantFamily, + variantNote, + WEBSITE_ID, + websiteNode, +} from '../../lib/seo'; +import { GITHUB } from '../../lib/site'; +import { CATEGORY_META, INTERACTION_META } from '../../lib/taxonomy'; export const getStaticPaths = (() => icons.map((icon, index) => ({ @@ -23,68 +32,134 @@ export const getStaticPaths = (() => const { icon, previous, next } = Astro.props as { icon: Icon; previous?: Icon; next?: Icon }; const u = urls(icon); -const related = icons.filter((other) => other.category === icon.category && other.id !== icon.id).slice(0, 12); +const title = iconTitle(icon); +const description = iconDescription(icon); +const category = CATEGORY_META[icon.category]; +const interaction = INTERACTION_META[icon.interaction]; +const categoryHref = `/categories/${category.slug}`; +const interactionHref = `/interactions/${interaction.slug}`; +const family = variantFamily(icon); +const related = relatedIcons(icon, 12 + family.length) + .filter((other) => !family.includes(other)) + .slice(0, 12); +const aka = synonyms(icon); +const tags = meaningfulKeywords(icon).filter((keyword) => !aka.includes(keyword.toLowerCase())); +const faq = iconFaq(icon); +const states = Object.keys(icon.svgs); const kb = (Buffer.byteLength(icon.lottie) / 1024).toFixed(1); -const facts = [ - ['Interaction', INTERACTIONS[icon.interaction].label], - ['Duration', `${(icon.durationMs / 1000).toFixed(2)} s`], - ['Artboard', `${icon.width} × ${icon.height} px`], - ['Lottie size', `${kb} kB`], +const facts: { label: string; value: string; href?: string }[] = [ + { label: 'Interaction', value: INTERACTIONS[icon.interaction].label, href: interactionHref }, + { label: 'Category', value: icon.category, href: categoryHref }, + { label: 'Duration', value: `${(icon.durationMs / 1000).toFixed(2)} s` }, + { label: 'Frame rate', value: `${Number(icon.fps.toFixed(2))} fps` }, + { label: 'Artboard', value: `${icon.width} × ${icon.height} px` }, + { label: 'Lottie size', value: `${kb} kB` }, ]; +const imageAlt = `${icon.title} animated icon from useAnimations`; +const modified = lastModifiedOf([`icons/${icon.name}.json`]); +const license = 'https://opensource.org/license/mit/'; +const pageId = `${u.page}#page`; +const imageId = `${u.page}#image`; const jsonLd = { '@context': 'https://schema.org', '@graph': [ + organizationNode(), + websiteNode(), + { + '@type': 'ItemPage', + '@id': pageId, + url: u.page, + name: title, + description, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + about: { '@id': imageId }, + primaryImageOfPage: { '@id': imageId }, + breadcrumb: { '@id': `${u.page}#breadcrumb` }, + ...(modified && { dateModified: modified.slice(0, 10) }), + }, { '@type': 'ImageObject', + '@id': imageId, name: `${icon.title} animated icon`, - description: `Animated ${icon.title.toLowerCase()} icon (${icon.interaction}) from useAnimations, free under the MIT license.`, - contentUrl: u.lottie, - thumbnailUrl: u.svg[0], - license: 'https://opensource.org/license/mit/', + description: iconIntro(icon), + contentUrl: u.svg[0] ?? u.og, + encodingFormat: u.svg.length ? 'image/svg+xml' : 'image/png', + thumbnailUrl: u.og, + creator: { '@id': ORGANIZATION_ID }, + publisher: { '@id': ORGANIZATION_ID }, + creditText: 'useAnimations', + copyrightNotice: '© useAnimations, released under the MIT License', + license, acquireLicensePage: `${SITE}/licencing-and-terms`, - keywords: icon.keywords.join(', '), + keywords: [...new Set([icon.title.toLowerCase(), ...tags, ...aka])].join(', '), + }, + { + '@type': 'MediaObject', + '@id': `${u.page}#lottie`, + name: `${icon.title} Lottie animation`, + contentUrl: u.lottie, + encodingFormat: 'application/json', + width: icon.width, + height: icon.height, + duration: `PT${Number((icon.durationMs / 1000).toFixed(2))}S`, + contentSize: `${kb} kB`, + creator: { '@id': ORGANIZATION_ID }, + license, + }, + { + '@type': 'SoftwareSourceCode', + '@id': `${u.page}#react`, + name: `${icon.title} animated icon in React`, + programmingLanguage: 'TSX', + runtimePlatform: 'React', + codeRepository: GITHUB, + license, + text: reactExample(icon.exportName, icon.name), }, { '@type': 'BreadcrumbList', + '@id': `${u.page}#breadcrumb`, itemListElement: [ - { '@type': 'ListItem', position: 1, name: 'Icons', item: `${SITE}/#icons` }, - { '@type': 'ListItem', position: 2, name: icon.category, item: `${SITE}/?category=${encodeURIComponent(icon.category)}#icons` }, + { '@type': 'ListItem', position: 1, name: 'Icons', item: `${SITE}/icons` }, + { '@type': 'ListItem', position: 2, name: icon.category, item: `${SITE}${categoryHref}` }, { '@type': 'ListItem', position: 3, name: icon.title, item: u.page }, ], }, + { '@id': `${u.page}#faq`, ...faqNode(faq) }, ], }; --- <Layout - title={`${icon.title} animated icon — useAnimations`} - description={`Free animated ${icon.title.toLowerCase()} icon (${INTERACTIONS[icon.interaction].label.toLowerCase()}) for React and shadcn/ui. Download the SVG, Lottie JSON or ZIP. MIT licensed.`} + title={title} + description={description} path={`/icons/${icon.id}`} jsonLd={jsonLd} + image={`/og/icons/${icon.id}.png`} + imageAlt={imageAlt} + markdown={`/icons/${icon.id}.md`} > <div class="mx-auto max-w-6xl px-5 pt-10 sm:px-8"> <nav aria-label="Breadcrumb" class="text-sm text-muted"> - <a href="/#icons" class="hover:text-fg">Icons</a> - <span class="mx-1.5 text-subtle">/</span> - <a href={`/?category=${encodeURIComponent(icon.category)}#icons`} class="hover:text-fg">{icon.category}</a> - <span class="mx-1.5 text-subtle">/</span> - <span class="text-fg">{icon.title}</span> + <ol class="flex flex-wrap items-center"> + <li><a href="/icons" class="hover:text-fg">Icons</a></li> + <li><span class="mx-1.5 text-subtle" aria-hidden="true">›</span><a href={categoryHref} class="hover:text-fg">{icon.category}</a></li> + <li><span class="mx-1.5 text-subtle" aria-hidden="true">›</span><span class="text-fg" aria-current="page">{icon.title}</span></li> + </ol> </nav> <div class="mt-8 grid gap-10 lg:grid-cols-[minmax(0,26rem)_1fr]"> <div> - <IconPreview - client:load - lottie={icon.lottie} - interaction={icon.interaction} - title={icon.title} - /> + <IconPreview client:load lottie={icon.lottie} interaction={icon.interaction} title={icon.title} /> <dl class="mt-8 grid grid-cols-2 gap-x-6 gap-y-4 rounded-3xl border border-line p-6 text-sm"> { - facts.map(([label, value]) => ( + facts.map(({ label, value, href }) => ( <div> <dt class="text-subtle">{label}</dt> - <dd class="mt-0.5 text-fg">{value}</dd> + <dd class="mt-0.5 text-fg"> + {href ? <a href={href} class="underline decoration-line-strong underline-offset-4 hover:decoration-fg">{value}</a> : value} + </dd> </div> )) } @@ -93,12 +168,27 @@ const jsonLd = { <div class="min-w-0"> <h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{icon.title}</h1> - <p class="mt-4 max-w-xl text-lg text-muted"> - {INTERACTIONS[icon.interaction].description} Free under the MIT license. - </p> - <div class="mt-6 flex flex-wrap gap-2"> - {icon.keywords.map((keyword) => <span class="rounded-full bg-surface-2 px-3 py-1 text-xs text-muted">{keyword}</span>)} - </div> + <p class="mt-2 text-lg text-subtle">Animated {icon.title.toLowerCase()} icon</p> + <p class="mt-4 max-w-xl text-lg text-muted">{iconIntro(icon)}</p> + { + aka.length > 0 && ( + <div class="mt-6 flex flex-wrap items-center gap-2 text-sm"> + <span class="text-subtle">Also known as</span> + {aka.map((term) => ( + <span class="rounded-full bg-surface-2 px-3 py-1 text-xs text-muted">{term}</span> + ))} + </div> + ) + } + { + tags.length > 0 && ( + <div class="mt-3 flex flex-wrap gap-2"> + {tags.map((keyword) => ( + <span class="rounded-full border border-line px-3 py-1 text-xs text-subtle">{keyword}</span> + ))} + </div> + ) + } <div class="mt-8 flex flex-wrap gap-2"> <a @@ -118,7 +208,7 @@ const jsonLd = { class="rounded-full border border-line-strong px-5 py-2.5 text-sm hover:bg-surface-2">Lottie JSON</a > { - Object.keys(icon.svgs).map((state) => ( + states.map((state) => ( <a href={`/icons/${icon.id}/${state}.svg`} download @@ -128,35 +218,37 @@ const jsonLd = { data-track-file={`${state}.svg`} class="rounded-full border border-line-strong px-5 py-2.5 text-sm hover:bg-surface-2" > - {Object.keys(icon.svgs).length > 1 ? `${state}.svg` : 'SVG'} + {states.length > 1 ? `${state}.svg` : 'SVG'} </a> )) } </div> - - <h2 class="mt-12 text-lg font-semibold">React</h2> - <CodeBlock class="mt-3" lang="bash" code="npm install react-useanimations" track={{ icon: icon.id, code: 'install' }} /> - <CodeBlock class="mt-3" lang="jsx" code={reactExample(icon.exportName, icon.name)} track={{ icon: icon.id, code: 'react' }} /> - - <h2 class="mt-10 text-lg font-semibold">shadcn/ui</h2> - <CodeBlock class="mt-3" lang="bash" code={shadcnCommand(icon)} track={{ icon: icon.id, code: 'shadcn-command' }} /> - <CodeBlock class="mt-3" lang="tsx" code={shadcnExample(icon.id, icon.component)} track={{ icon: icon.id, code: 'shadcn' }} /> - - <h2 class="mt-10 text-lg font-semibold">Files</h2> - <CodeBlock class="mt-3" lang="bash" code={filesExample(icon.id)} track={{ icon: icon.id, code: 'files' }} /> - <p class="mt-3 text-sm text-muted"> - The animation as Lottie JSON and a ZIP with it and the SVGs. See{' '} - <a class="underline underline-offset-4" href="/documentation#files--iconsjson">Files & icons.json</a>. + <p class="mt-3 text-sm text-subtle"> + Free under the <a class="underline underline-offset-4 hover:text-fg" href="/licencing-and-terms">MIT license</a> ·{' '} + <a class="underline underline-offset-4 hover:text-fg" href={`/icons/${icon.id}.md`}>Markdown version</a> ·{' '} + <a class="underline underline-offset-4 hover:text-fg" href="/documentation#files--iconsjson">About the files</a> </p> + <h2 class="mt-12 text-lg font-semibold">Use it</h2> + <div class="mt-3"> + <IconUsageTabs icon={icon} /> + </div> + { - Object.keys(icon.svgs).length > 0 && ( + states.length > 0 && ( <> <h2 class="mt-10 text-lg font-semibold">Static SVG</h2> <div class="mt-3 flex flex-wrap gap-3"> - {Object.entries(icon.svgs).map(([state, svg]) => ( + {states.map((state) => ( <figure class="flex flex-col items-center gap-2 rounded-2xl border border-line p-4"> - <span class="block size-10 [&_svg]:size-full" set:html={svg} /> + <img + src={`/icons/${icon.id}/${state}.svg`} + alt={`${icon.title} icon, ${state} state`} + width="40" + height="40" + loading="lazy" + class="size-10 dark:invert" + /> <figcaption class="font-mono text-xs text-subtle">{state}.svg</figcaption> </figure> ))} @@ -167,12 +259,60 @@ const jsonLd = { </div> </div> + { + family.length > 0 && ( + <section class="mt-20" aria-labelledby="versions-title"> + <h2 id="versions-title" class="text-lg font-semibold"> + Other versions + </h2> + <ul class="mt-4 grid gap-3 sm:grid-cols-2"> + {family.map((other) => ( + <li> + <a + href={`/icons/${other.id}`} + class="flex items-center gap-4 rounded-2xl border border-line bg-surface p-4 transition hover:border-line-strong" + > + <span data-useanimations="" class="block size-10 shrink-0 text-fg" set:html={other.firstFrame} /> + <span> + <span class="block text-sm font-medium text-fg">{other.title}</span> + <span class="mt-0.5 block text-sm text-muted">{variantNote(icon, other)}</span> + </span> + </a> + </li> + ))} + </ul> + </section> + ) + } + + <section class="mt-20 max-w-3xl" aria-labelledby="faq-title"> + <h2 id="faq-title" class="text-lg font-semibold">Questions about the {icon.title} icon</h2> + <div class="mt-4 divide-y divide-line border-y border-line"> + { + faq.map(({ question, answer }) => ( + <details class="group py-4"> + <summary class="cursor-pointer list-none font-medium text-fg marker:hidden"> + <span class="mr-2 inline-block text-subtle transition group-open:rotate-90">›</span> + {question} + </summary> + <p class="mt-2 pl-5 text-sm text-muted">{answer}</p> + </details> + )) + } + </div> + </section> + { related.length > 0 && ( <section class="mt-20" aria-labelledby="related-title"> - <h2 id="related-title" class="text-lg font-semibold"> - More in {icon.category} - </h2> + <div class="flex flex-wrap items-baseline justify-between gap-4"> + <h2 id="related-title" class="text-lg font-semibold"> + Related icons + </h2> + <a href={categoryHref} class="text-sm text-muted hover:text-fg"> + All {icon.category.toLowerCase()} icons → + </a> + </div> <ul class="mt-4 grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-3"> {related.map((other) => ( <li> @@ -196,3 +336,7 @@ const jsonLd = { </nav> </div> </Layout> + +<script> + import '../../scripts/tabs'; +</script> diff --git a/website/src/pages/icons/[id].md.ts b/website/src/pages/icons/[id].md.ts new file mode 100644 index 0000000..d685c22 --- /dev/null +++ b/website/src/pages/icons/[id].md.ts @@ -0,0 +1,94 @@ +// The icon page as Markdown, for AI agents and anyone who wants the facts without the page. +import type { GetStaticPaths } from 'astro'; +import { + htmlLottieExample, + type Icon, + icons, + INTERACTIONS, + reactExample, + shadcnCommand, + shadcnExample, + SITE, + urls, +} from '../../lib/icons'; +import { text } from '../../lib/http'; +import { + iconDescription, + iconFaq, + iconIntro, + related, + variantFamily, + variantNote, +} from '../../lib/seo'; +import { CATEGORY_META, INTERACTION_META } from '../../lib/taxonomy'; + +export const getStaticPaths = (() => + icons.map((icon) => ({ params: { id: icon.id }, props: { icon } }))) satisfies GetStaticPaths; + +const code = (lang: string, body: string) => `\`\`\`${lang}\n${body}\n\`\`\``; + +export const GET = ({ props: { icon } }: { props: { icon: Icon } }) => { + const u = urls(icon); + const category = CATEGORY_META[icon.category]; + const interaction = INTERACTION_META[icon.interaction]; + const family = variantFamily(icon); + const others = related(icon, 12 + family.length).filter((other) => !family.includes(other)); + const kb = (Buffer.byteLength(icon.lottie) / 1024).toFixed(1); + const states = Object.keys(icon.svgs); + const sections = [ + `# ${icon.title} animated icon + +> ${iconDescription(icon)} + +${iconIntro(icon)}`, + `## Facts + +- Interaction: [${INTERACTIONS[icon.interaction].label}](${SITE}/interactions/${interaction.slug}). ${INTERACTIONS[icon.interaction].description} +- Category: [${icon.category}](${SITE}/categories/${category.slug}) +- Duration: ${(icon.durationMs / 1000).toFixed(2)} s at ${Number(icon.fps.toFixed(2))} fps +- Artboard: ${icon.width} × ${icon.height} px +- Lottie size: ${kb} kB${icon.requiresFullPlayer ? ' (uses After Effects expressions: needs the full lottie-web build)' : ''} +- License: MIT, attribution optional ([Licencing & Terms](${SITE}/licencing-and-terms))`, + `## Files + +${[ + `- Lottie JSON: ${u.lottie}`, + ...states.map((state, i) => `- Static SVG (${state}): ${u.svg[i]}`), + `- ZIP (Lottie JSON and SVGs): ${u.zip}`, + `- shadcn registry item: ${u.registry}`, + `- Web page: ${u.page}`, +].join('\n')}`, + `## React + +${code('bash', 'npm install react-useanimations')} + +${code('jsx', reactExample(icon.exportName, icon.name))}`, + `## shadcn/ui + +${code('bash', shadcnCommand(icon))} + +${code('tsx', shadcnExample(icon.id, icon.component))}`, + `## HTML and lottie-web + +${code('html', htmlLottieExample(icon))}`, + family.length + ? `## Other versions + +${family.map((other) => `- [${other.title}](${SITE}/icons/${other.id}): ${variantNote(icon, other)}`).join('\n')}` + : '', + `## FAQ + +${iconFaq(icon) + .map(({ question, answer }) => `### ${question}\n\n${answer}`) + .join('\n\n')}`, + `## Related icons + +${others + .slice(0, 12) + .map((other) => `- [${other.title}](${SITE}/icons/${other.id}) (${other.interaction})`) + .join('\n')} + +[All ${icon.category.toLowerCase()} icons](${SITE}/categories/${category.slug}) · [All icons](${SITE}/icons)`, + ]; + return text(`${sections.filter(Boolean).join('\n\n')}\n`, 'text/markdown'); +}; diff --git a/website/src/pages/og/icons/[id].png.ts b/website/src/pages/og/icons/[id].png.ts new file mode 100644 index 0000000..2572ad1 --- /dev/null +++ b/website/src/pages/og/icons/[id].png.ts @@ -0,0 +1,10 @@ +// The social card of each icon page, 1200 × 630, drawn at build time (lib/og-image.ts). +import type { GetStaticPaths } from 'astro'; +import { type Icon, icons } from '../../../lib/icons'; +import { ogPng } from '../../../lib/og-image'; + +export const getStaticPaths = (() => + icons.map((icon) => ({ params: { id: icon.id }, props: { icon } }))) satisfies GetStaticPaths; + +export const GET = ({ props: { icon } }: { props: { icon: Icon } }) => + new Response(ogPng(icon), { headers: { 'Content-Type': 'image/png' } }); From f1ae1f467f84a5745d118d82ee28f777ca76d07f Mon Sep 17 00:00:00 2001 From: Tuan Phung <tuan@phung.cz> Date: Sun, 11 Oct 2026 12:05:06 +0200 Subject: [PATCH 4/5] Website: technical SEO for home, sitemaps, redirects and Markdown negotiation - Home: keyword H1 under the slogan, micro animation copy, FAQ with FAQPage JSON-LD, category and interaction link blocks, WebSite/Organization/SoftwareApplication/Dataset schema - Lighter home page: compact prerendered SVG frames, icon card styles in CSS, demos load their Lottie JSON when visible, content-visibility on icon sections - Sitemap index with page, hub and icon sitemaps (Git lastmod, image entries) - Edge functions: one 301 to canonical URLs (trailing slash, .html, aliases); Markdown negotiation for icon, category and licence pages - HSTS everywhere, noindex for /og and Markdown copies, robots.txt without the unknown directive - llms.txt hubs, new llms-full.txt and licencing-and-terms.md, expanded /zh - Contrast: darker subtle grey, high-contrast light code theme; install button name matches its text --- website/astro.config.ts | 3 +- .../netlify/edge-functions/canonical-url.ts | 38 +++ .../edge-functions/markdown-negotiation.ts | 51 ++-- website/public/_headers | 28 +- website/public/_redirects | 11 +- website/src/components/ColorRow.tsx | 58 ++-- website/src/components/IconCard.tsx | 29 +- website/src/components/IconField.astro | 8 +- website/src/components/InstallCommand.astro | 4 +- website/src/components/InteractionDemo.tsx | 81 +++--- website/src/lib/highlight.ts | 7 +- website/src/lib/home.ts | 73 +++++ website/src/lib/icons.ts | 6 +- website/src/lib/sitemap.ts | 106 +++++++ website/src/lib/svg.ts | 98 +++++++ website/src/pages/404.astro | 8 +- website/src/pages/index.astro | 178 ++++++++++-- website/src/pages/index.md.ts | 33 ++- website/src/pages/licencing-and-terms.astro | 3 +- website/src/pages/licencing-and-terms.md.ts | 41 +++ website/src/pages/llms-full.txt.ts | 42 +++ website/src/pages/llms.txt.ts | 32 ++- website/src/pages/robots.txt.ts | 11 +- website/src/pages/sitemap-hubs.xml.ts | 4 + website/src/pages/sitemap-icons.xml.ts | 4 + website/src/pages/sitemap-pages.xml.ts | 4 + website/src/pages/sitemap.xml.ts | 20 +- website/src/pages/zh.astro | 268 ++++++++++++++++-- website/src/styles/global.css | 79 +++++- 29 files changed, 1141 insertions(+), 187 deletions(-) create mode 100644 website/netlify/edge-functions/canonical-url.ts create mode 100644 website/src/lib/home.ts create mode 100644 website/src/lib/sitemap.ts create mode 100644 website/src/lib/svg.ts create mode 100644 website/src/pages/licencing-and-terms.md.ts create mode 100644 website/src/pages/llms-full.txt.ts create mode 100644 website/src/pages/sitemap-hubs.xml.ts create mode 100644 website/src/pages/sitemap-icons.xml.ts create mode 100644 website/src/pages/sitemap-pages.xml.ts diff --git a/website/astro.config.ts b/website/astro.config.ts index f538138..badf9b0 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -15,8 +15,9 @@ export default defineConfig({ build: { format: 'file' }, integrations: [react()], markdown: { + // The same themes as lib/highlight.ts. shikiConfig: { - themes: { light: 'github-light', dark: 'github-dark-dimmed' }, + themes: { light: 'github-light-high-contrast', dark: 'github-dark-dimmed' }, defaultColor: false, }, }, diff --git a/website/netlify/edge-functions/canonical-url.ts b/website/netlify/edge-functions/canonical-url.ts new file mode 100644 index 0000000..4a2a887 --- /dev/null +++ b/website/netlify/edge-functions/canonical-url.ts @@ -0,0 +1,38 @@ +// One 301 to the canonical URL for the variants static hosting would otherwise serve as copies: +// a trailing slash (/zh/, /icons/menu/), the .html file (/icons/menu.html) and old or misspelled +// paths (/docs, /licensing). A rule in _redirects can't do this: Netlify matches its rules with and +// without the trailing slash alike, so /documentation/ -> /documentation would loop. +type Context = { next: () => Promise<Response> }; + +const ALIASES: Record<string, string> = { + '/docs': '/documentation', + '/license': '/licencing-and-terms', + '/licence': '/licencing-and-terms', + '/licensing': '/licencing-and-terms', + '/licensing-and-terms': '/licencing-and-terms', +}; + +/** The canonical path of a pathname, e.g. /icons/menu.html/ -> /icons/menu. */ +const canonicalPath = (pathname: string) => { + let path = pathname.replace(/\/+$/, '') || '/'; + if (path.endsWith('.html') && path !== '/404.html') path = path.slice(0, -'.html'.length); + path = path.replace(/\/index$/, '') || '/'; + return ALIASES[path] ?? path; +}; + +export default async (request: Request, context: Context) => { + const url = new URL(request.url); + const path = canonicalPath(url.pathname); + if (path === url.pathname) return context.next(); + return new Response(null, { + status: 301, + headers: { Location: `${path}${url.search}`, 'Cache-Control': 'public, max-age=3600' }, + }); +}; + +// Paths with a trailing slash, .html pages and the aliases; files (.json, .zip, .svg, .md, .png, +// .txt, .xml) never match, and /404.html is served as it is. +export const config = { + pattern: ['^/.+/$', '^/.+\\.html$', '^/(docs|license|licence|licensing|licensing-and-terms)$'], + excludedPattern: ['^/404\\.html$', '^/\\.netlify/'], +}; diff --git a/website/netlify/edge-functions/markdown-negotiation.ts b/website/netlify/edge-functions/markdown-negotiation.ts index 756b72c..3f0857f 100644 --- a/website/netlify/edge-functions/markdown-negotiation.ts +++ b/website/netlify/edge-functions/markdown-negotiation.ts @@ -1,25 +1,40 @@ // Serves the Markdown version of a page to clients that ask for it (Accept: text/markdown), -// such as AI agents. Everyone else gets the HTML page. +// such as AI agents. Everyone else gets the HTML page, which announces the Markdown version in a +// Link header. Both vary by Accept, so caches keep them apart. type Context = { next: () => Promise<Response> }; -const MARKDOWN: Record<string, string> = { - '/': '/index.md', - '/documentation': '/documentation.md', - '/documentation/': '/documentation.md', -}; +/** The Markdown file of a page: / -> /index.md, /icons/menu -> /icons/menu.md. */ +const markdownPath = (pathname: string) => (pathname === '/' ? '/index.md' : `${pathname}.md`); export default async (request: Request, context: Context) => { - const markdown = MARKDOWN[new URL(request.url).pathname]; - if (!markdown || !request.headers.get('accept')?.includes('text/markdown')) return context.next(); - const response = await fetch(new URL(markdown, request.url)); - if (!response.ok) return context.next(); - return new Response(await response.text(), { - headers: { - 'Content-Type': 'text/markdown; charset=utf-8', - 'Cache-Control': 'public, max-age=3600', - Vary: 'Accept', - }, - }); + const markdown = markdownPath(new URL(request.url).pathname); + if (request.headers.get('accept')?.includes('text/markdown')) { + const response = await fetch(new URL(markdown, request.url)); + if (response.ok) { + return new Response(await response.text(), { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + Vary: 'Accept', + }, + }); + } + } + const response = await context.next(); + // Only pages that exist have a Markdown version; a 404 stays as it is. + if (!response.ok) return response; + const headers = new Headers(response.headers); + headers.append('Link', `<${markdown}>; rel="alternate"; type="text/markdown"`); + headers.append('Vary', 'Accept'); + return new Response(response.body, { status: response.status, headers }); }; -export const config = { path: ['/', '/documentation', '/documentation/'] }; +// Exact pages only, so files such as /icons/menu.json, .zip or .svg never run this function. +export const config = { + pattern: [ + '^/$', + '^/(documentation|licencing-and-terms)$', + '^/icons/[a-z0-9-]+$', + '^/categories/[a-z0-9-]+$', + ], +}; diff --git a/website/public/_headers b/website/public/_headers index 9f688d8..ec82b34 100644 --- a/website/public/_headers +++ b/website/public/_headers @@ -1,18 +1,23 @@ +# The same HSTS policy on every response (Netlify's default omits includeSubDomains and preload). /* + Strict-Transport-Security: max-age=31536000; includeSubDomains; preload X-Frame-Options: SAMEORIGIN X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin +# The Markdown alternates of pages (Link and Vary: Accept) come from +# netlify/edge-functions/markdown-negotiation.ts. / Link: </robots.txt>; rel="robots" Link: </licencing-and-terms>; rel="license" Link: </icons.json>; rel="alternate"; type="application/json"; title="Icon catalog" - Link: </index.md>; rel="alternate"; type="text/markdown" - Vary: Accept -/documentation - Link: </documentation.md>; rel="alternate"; type="text/markdown" - Vary: Accept +# The social card template, rendered to og.png at build time; not a page. +/og + X-Robots-Tag: noindex, nofollow + +/og.html + X-Robots-Tag: noindex, nofollow /icons.json Access-Control-Allow-Origin: * @@ -35,11 +40,18 @@ Access-Control-Allow-Origin: * Cache-Control: public, max-age=3600 -/index.md +/*.md Content-Type: text/markdown; charset=utf-8 + X-Robots-Tag: noindex -/documentation.md - Content-Type: text/markdown; charset=utf-8 +/llms.txt + Content-Type: text/plain; charset=utf-8 + +/llms-full.txt + Content-Type: text/plain; charset=utf-8 + +/sitemap*.xml + Content-Type: application/xml; charset=utf-8 /_astro/* Cache-Control: public, max-age=31536000, immutable diff --git a/website/public/_redirects b/website/public/_redirects index 446c61d..55242db 100644 --- a/website/public/_redirects +++ b/website/public/_redirects @@ -1,9 +1,18 @@ -# Pages of the previous site (www and http are redirected by Netlify). +# Pages of the previous site (www and http are redirected by Netlify). Trailing slashes, .html +# pages and the aliases below are redirected first by netlify/edge-functions/canonical-url.ts; +# these rules are the fallback. /index.html / 301! /documentation.html /documentation 301! /licencing-and-terms.html /licencing-and-terms 301! /zh.html /zh 301! +# Other spellings of pages. +/docs /documentation 301! +/license /licencing-and-terms 301! +/licence /licencing-and-terms 301! +/licensing /licencing-and-terms 301! +/licensing-and-terms /licencing-and-terms 301! + # Images of the previous site. /images/OGuseAnimationsImage.jpg /og.png 301 /images/apple-touch-icon.png /apple-touch-icon.png 301 diff --git a/website/src/components/ColorRow.tsx b/website/src/components/ColorRow.tsx index 8e6e029..8b62a1a 100644 --- a/website/src/components/ColorRow.tsx +++ b/website/src/components/ColorRow.tsx @@ -1,31 +1,47 @@ -import { useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import UseAnimations, { type Interaction } from 'react-useanimations'; +import { loadIconData } from '../lib/icon-data'; -/** `lottie` is the Lottie JSON as a string. */ -type Icon = { title: string; color: string; lottie: string; interaction: Interaction }; +/** `id` is the icon id; its Lottie JSON loads from /icons/<id>.json once the row is visible. */ +type Icon = { id: string; title: string; color: string; interaction: Interaction }; + +const SIZE = 32; /** Icons in different colors: each plays when its tile is hovered or clicked. */ export default function ColorRow({ icons }: { icons: Icon[] }) { - const [data] = useState(() => icons.map((icon) => JSON.parse(icon.lottie) as object)); + const [data, setData] = useState<object[]>(); + useEffect(() => { + void Promise.all(icons.map((icon) => loadIconData(icon.id))).then(setData, () => undefined); + }, [icons]); + + const tile = (icon: Icon, eventProps: object, animationProps: object) => ( + <span + {...eventProps} + title={icon.title} + className="flex size-16 cursor-pointer items-center justify-center rounded-2xl bg-surface-2 transition hover:scale-105" + style={{ color: icon.color }} + > + <span {...animationProps} /> + </span> + ); + + // Until the animations load, empty boxes of the icons' size keep the layout still. return ( <div className="mt-8 flex flex-wrap gap-3"> - {icons.map((icon, i) => ( - <UseAnimations - key={icon.title} - animation={{ animationData: data[i], interaction: icon.interaction }} - size={32} - render={(eventProps, animationProps) => ( - <span - {...eventProps} - title={icon.title} - className="flex size-16 cursor-pointer items-center justify-center rounded-2xl bg-surface-2 transition hover:scale-105" - style={{ color: icon.color }} - > - <span {...animationProps} /> - </span> - )} - /> - ))} + {icons.map((icon, i) => + data ? ( + <UseAnimations + key={icon.id} + animation={{ animationData: data[i], interaction: icon.interaction }} + size={SIZE} + render={(eventProps, animationProps) => tile(icon, eventProps, animationProps)} + /> + ) : ( + <Fragment key={icon.id}> + {tile(icon, {}, { style: { display: 'inline-block', width: SIZE, height: SIZE } })} + </Fragment> + ) + )} </div> ); } diff --git a/website/src/components/IconCard.tsx b/website/src/components/IconCard.tsx index 88258fc..f8a44e5 100644 --- a/website/src/components/IconCard.tsx +++ b/website/src/components/IconCard.tsx @@ -18,27 +18,18 @@ const SIZE = 'var(--icon-size, 40px)'; export const openIcon = (id: string) => !window.dispatchEvent(new CustomEvent('ua:open-icon', { detail: id, cancelable: true })); +// The card's styles live in global.css (.icon-card): the home page renders 300 of them, and +// utility class lists repeated 300 times weighed more than the icons. const Frame = ({ children, icon, ...props }: HTMLAttributes<HTMLDivElement> & { icon: CardIcon }) => ( - <div - className="icon-card group relative flex aspect-[1/1.08] cursor-pointer flex-col rounded-2xl border border-line bg-surface transition duration-200 hover:border-line-strong hover:shadow-[0_18px_40px_-24px_var(--glow)]" - {...props} - > - <span className="pointer-events-none absolute top-2.5 right-2.5 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-medium text-subtle opacity-0 transition-opacity group-hover:opacity-100"> - {HINT[icon.interaction]} - </span> - <div className="flex flex-1 items-center justify-center text-[var(--icon-color,var(--fg))]"> - {children} - </div> + <div className="icon-card bg-surface" {...props}> + <span className="icon-card-hint">{HINT[icon.interaction]}</span> + <div className="icon-card-art">{children}</div> {/* The icon page, or the details dialog once it's ready; the whole card opens it (scripts/browser.ts). */} - <a - href={`/icons/${icon.id}`} - data-details - className="truncate rounded-b-2xl px-3 pb-3 text-center text-[13px] text-muted transition-colors group-hover:text-fg" - > + <a href={`/icons/${icon.id}`} data-details className="icon-card-title"> {icon.title} </a> </div> @@ -63,13 +54,7 @@ export default function IconCard({ <span aria-hidden="true" data-useanimations="" - style={{ - display: 'inline-block', - flexShrink: 0, - overflow: 'hidden', - width: SIZE, - height: SIZE, - }} + className="icon-card-frame" dangerouslySetInnerHTML={{ __html: firstFrame ?? '' }} /> </Frame> diff --git a/website/src/components/IconField.astro b/website/src/components/IconField.astro index a708cf0..57605c4 100644 --- a/website/src/components/IconField.astro +++ b/website/src/components/IconField.astro @@ -22,7 +22,13 @@ const { icons } = Astro.props; title={icon.title} class="field-cell relative flex aspect-square items-center justify-center rounded-2xl" > - <span data-slot data-useanimations="" class="block size-7 sm:size-8" set:html={icon.firstFrame} /> + {/* Own element ids: the same icons are prerendered again in the icon browser below. */} + <span + data-slot + data-useanimations="" + class="block size-7 sm:size-8" + set:html={icon.firstFrame.replaceAll('__uas_', '__uaf_')} + /> </div> )) } diff --git a/website/src/components/InstallCommand.astro b/website/src/components/InstallCommand.astro index 429130f..33ae547 100644 --- a/website/src/components/InstallCommand.astro +++ b/website/src/components/InstallCommand.astro @@ -15,9 +15,9 @@ const { command = 'npm i react-useanimations', class: className } = Astro.props; 'group inline-flex items-center gap-3 rounded-full border border-line-strong bg-surface/70 py-2.5 pr-3 pl-5 font-mono text-sm text-fg backdrop-blur transition hover:border-accent', className, ]} - aria-label={`Copy: ${command}`} > - <span class="text-subtle select-none">$</span> + <!-- No aria-label: the name is the visible text ('npm i react-useanimations Copy'). --> + <span class="text-subtle select-none" aria-hidden="true">$</span> <span>{command}</span> <span data-copy-label diff --git a/website/src/components/InteractionDemo.tsx b/website/src/components/InteractionDemo.tsx index e3c2baa..3684d3c 100644 --- a/website/src/components/InteractionDemo.tsx +++ b/website/src/components/InteractionDemo.tsx @@ -1,47 +1,60 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import UseAnimations, { type Interaction } from 'react-useanimations'; +import { loadIconData } from '../lib/icon-data'; type Props = { - /** Lottie JSON as a string. */ - lottie: string; + /** Icon id; its Lottie JSON loads from /icons/<id>.json once the card is visible. */ + id: string; interaction: Interaction; title: string; description: string; hint: string; }; +const SIZE = 56; + /** A card that plays its icon's interaction when the card itself is hovered, clicked or focused. */ -export default function InteractionDemo({ lottie, interaction, title, description, hint }: Props) { +export default function InteractionDemo({ id, interaction, title, description, hint }: Props) { const clickable = interaction.startsWith('click'); - const [animationData] = useState(() => JSON.parse(lottie) as object); - return ( - <UseAnimations - animation={{ animationData, interaction }} - size={56} - render={(eventProps, animationProps) => ( - <button - type="button" - className="demo-card group relative flex h-full w-full flex-col items-start rounded-3xl border border-line bg-surface p-6 text-left transition duration-300 hover:-translate-y-0.5 hover:border-line-strong hover:shadow-[0_24px_60px_-30px_var(--glow)]" - aria-label={`${title}: ${hint}`} - {...eventProps} - > - <span className="flex size-24 items-center justify-center rounded-2xl bg-surface-2 text-fg transition-colors group-hover:text-accent"> - <span {...animationProps} /> - </span> - <span className="mt-6 font-mono text-xs text-accent"> - interaction="{interaction}" - </span> - <span className="mt-2 text-lg font-semibold tracking-tight text-fg">{title}</span> - <span className="mt-1.5 text-sm leading-relaxed text-muted">{description}</span> - <span className="mt-auto pt-5 text-xs font-medium text-subtle"> - {clickable - ? 'Click the card' - : interaction === 'loop' - ? 'Plays on its own' - : 'Hover the card'} - </span> - </button> - )} - /> + const [animationData, setAnimationData] = useState<object>(); + useEffect(() => { + void loadIconData(id).then(setAnimationData, () => undefined); + }, [id]); + + const card = (eventProps: object, animationProps: object) => ( + <button + type="button" + className="demo-card group relative flex h-full w-full flex-col items-start rounded-3xl border border-line bg-surface p-6 text-left transition duration-300 hover:-translate-y-0.5 hover:border-line-strong hover:shadow-[0_24px_60px_-30px_var(--glow)]" + aria-label={`${title}: ${hint}`} + {...eventProps} + > + <span className="flex size-24 items-center justify-center rounded-2xl bg-surface-2 text-fg transition-colors group-hover:text-accent"> + <span {...animationProps} /> + </span> + <span className="mt-6 font-mono text-xs text-accent"> + interaction="{interaction}" + </span> + <span className="mt-2 text-lg font-semibold tracking-tight text-fg">{title}</span> + <span className="mt-1.5 text-sm leading-relaxed text-muted">{description}</span> + <span className="mt-auto pt-5 text-xs font-medium text-subtle"> + {clickable + ? 'Click the card' + : interaction === 'loop' + ? 'Plays on its own' + : 'Hover the card'} + </span> + </button> ); + + // Until the animation loads, an empty box of the icon's size keeps the layout still. + if (!animationData) { + return card( + {}, + { + 'aria-hidden': true, + style: { display: 'inline-block', flexShrink: 0, width: SIZE, height: SIZE }, + } + ); + } + return <UseAnimations animation={{ animationData, interaction }} size={SIZE} render={card} />; } diff --git a/website/src/lib/highlight.ts b/website/src/lib/highlight.ts index 301d06a..a3cf8a6 100644 --- a/website/src/lib/highlight.ts +++ b/website/src/lib/highlight.ts @@ -1,9 +1,12 @@ import { codeToHtml } from 'shiki'; -/** Code highlighted for both themes; global.css picks the colors of the current one. */ +/** + * Code highlighted for both themes; global.css picks the colors of the current one. The light theme + * is the high-contrast one: github-light's reds and orange fall below 4.5:1 on the code background. + */ export const highlight = (code: string, lang: string) => codeToHtml(code, { lang, - themes: { light: 'github-light', dark: 'github-dark-dimmed' }, + themes: { light: 'github-light-high-contrast', dark: 'github-dark-dimmed' }, defaultColor: false, }); diff --git a/website/src/lib/home.ts b/website/src/lib/home.ts new file mode 100644 index 0000000..f46b2f2 --- /dev/null +++ b/website/src/lib/home.ts @@ -0,0 +1,73 @@ +// Copy shared by the home page and its Markdown version (index.md): the FAQ and the hub links. +import { icons, VERSION } from './icons'; +import type { Faq } from './seo'; +import { CATEGORY_META, iconsByCategory, iconsByInteraction, INTERACTION_META } from './taxonomy'; + +export const HOME_TITLE = 'Micro Animations & Animated Icon Library — useAnimations'; +export const HOME_DESCRIPTION = `${icons.length} free micro animations for UI: an animated icon library for React, shadcn/ui and Lottie with hover, click and loop built in. MIT.`; + +/** Indexable categories with their page path and icon count, in site order. */ +export const categoryLinks = () => + Object.values(CATEGORY_META) + .filter((meta) => meta.indexable) + .map((meta) => ({ + ...meta, + href: `/categories/${meta.slug}`, + count: iconsByCategory(meta.name).length, + })); + +/** Every interaction with its page path and icon count. */ +export const interactionLinks = () => + Object.values(INTERACTION_META).map((meta) => ({ + ...meta, + href: `/interactions/${meta.slug}`, + count: iconsByInteraction(meta.interaction).length, + })); + +/** The collection pages, for "browse" link blocks. */ +export const HUBS = [ + { label: 'All animated icons', href: '/icons' }, + { label: 'Icon categories', href: '/categories' }, + { label: 'Icons by interaction', href: '/interactions' }, + { label: 'Lottie icons (JSON)', href: '/lottie-icons' }, + { label: 'shadcn/ui animated icons', href: '/shadcn-animated-icons' }, +]; + +/** Questions and answers; `link` points to the page that answers in depth. */ +export const HOME_FAQ: (Faq[number] & { link?: { href: string; label: string } })[] = [ + { + question: 'What are micro animations and animated icons?', + answer: + 'Micro animations are short, single-purpose motions that answer a user action: a menu icon that turns into a close button, a heart that fills when you like a post, a bell that rings for a new notification. Animated icons are the most common kind. They make an interface feel responsive and show state changes without extra text.', + }, + { + question: 'What is the difference between the Lottie and SVG files?', + answer: + 'The Lottie JSON file holds the whole animation and plays in any Lottie player, including the one built into react-useanimations. The SVG files are still images of the icon at rest, one per state (for example eye and eye-off), for places where nothing should move: emails, documents, design tools or a no-JavaScript fallback.', + }, + { + question: 'Does it work with Next.js and server rendering?', + answer: + "Yes. The component is marked 'use client' and draws the animation only in the browser, so it renders from server components and SSR frameworks such as Next.js, Remix and Astro without dynamic imports. Icon modules are plain data that server components can import and pass as props.", + link: { + href: '/documentation#nextjs-and-server-rendering', + label: 'Next.js and server rendering', + }, + }, + { + question: 'Can I use the icons in commercial projects?', + answer: + 'Yes. The icons and the react-useanimations package are MIT licensed: use, modify and redistribute them in personal and commercial work. Attribution is appreciated but not required.', + link: { href: '/licencing-and-terms', label: 'Licencing & Terms' }, + }, + { + question: 'Can I use the icons without React?', + answer: + 'Yes. Every icon is a standard Lottie JSON file, so it plays with lottie-web in plain JavaScript, Vue, Svelte, Angular or a CMS, and in Lottie players for iOS, Android and Flutter. The Lottie icons page has the file URLs and ready-made lottie-web snippets for each interaction.', + link: { href: '/lottie-icons', label: 'Lottie icons' }, + }, + { + question: 'How much do the icons add to my bundle?', + answer: `The player in react-useanimations ${VERSION} is about 8 kB gzipped with no dependencies, and each icon is its own module of 1 to 15 kB (2 kB median), so a bundle only includes the icons you import.`, + }, +]; diff --git a/website/src/lib/icons.ts b/website/src/lib/icons.ts index 142a2db..6a32a5b 100644 --- a/website/src/lib/icons.ts +++ b/website/src/lib/icons.ts @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Interaction } from 'react-useanimations'; import { createRenderer } from '../../../src/player/render'; +import { compactSvg } from './svg'; const read = (file: string) => readFileSync(join(import.meta.env.REPO_DIR, 'icons', file), 'utf8'); @@ -131,8 +132,9 @@ const firstFrame = (id: string, data: { fr: number; ip: number }) => { try { const { svg, render } = createRenderer(data as never, { frameRate: data.fr, warn: () => {} }); render(data.ip); - // Element ids must stay unique next to the live players on the same page. - return svg.outerHTML.replaceAll('__ua_', `__uas_${id}_`); + // Element ids must stay unique next to the live players on the same page, and the same between + // builds: the renderer's prefix is random ('__ua<random>_'). + return compactSvg(svg.outerHTML.replace(/__ua[a-z0-9]*_/g, `__uas_${id}_`)); } finally { (globalThis as { document?: unknown }).document = previous; } diff --git a/website/src/lib/sitemap.ts b/website/src/lib/sitemap.ts new file mode 100644 index 0000000..8e6a106 --- /dev/null +++ b/website/src/lib/sitemap.ts @@ -0,0 +1,106 @@ +// The sitemaps: an index (sitemap.xml) of three sitemaps for the pages, the collection hubs and the +// icon pages. Dates come from Git (lib/git.ts) and are left out when unknown, never the build time. +import { lastModifiedOf } from './git'; +import { icons, SITE, urls } from './icons'; +import { CATEGORY_META, INTERACTION_META } from './taxonomy'; + +export type Entry = { + path: string; + /** Repository paths whose last commit dates the page; a trailing '/' covers a directory. */ + sources: string[]; + /** hreflang -> path, for pages with translations. */ + alternates?: Record<string, string>; + images?: string[]; +}; + +const PAGES = 'website/src/pages'; +const CATALOG = 'icons/catalog.json'; +const TAXONOMY = 'website/src/lib/taxonomy.ts'; + +/** Source files of a static page at `path`, whichever of the usual file names it uses. */ +const pageFiles = (path: string) => [ + `${PAGES}${path}.astro`, + `${PAGES}${path}/index.astro`, + `${PAGES}${path}.md.ts`, +]; + +const LANGUAGES = { en: '/', 'zh-Hans': '/zh', 'x-default': '/' }; + +export const pageEntries = (): Entry[] => [ + { + path: '/', + sources: [`${PAGES}/index.astro`, 'website/src/lib/home.ts', CATALOG], + alternates: LANGUAGES, + }, + { path: '/zh', sources: [`${PAGES}/zh.astro`, CATALOG], alternates: LANGUAGES }, + { + path: '/documentation', + sources: [`${PAGES}/documentation.astro`, 'website/src/content/documentation.md'], + }, + { path: '/licencing-and-terms', sources: [`${PAGES}/licencing-and-terms.astro`, 'LICENSE'] }, +]; + +/** Collection pages; categories that aren't indexable (e.g. Other) stay out. */ +export const hubEntries = (): Entry[] => [ + ...['/icons', '/categories', '/interactions', '/lottie-icons', '/shadcn-animated-icons'].map( + (path) => ({ path, sources: [...pageFiles(path), CATALOG] }) + ), + ...Object.values(CATEGORY_META) + .filter((meta) => meta.indexable) + .map((meta) => ({ + path: `/categories/${meta.slug}`, + sources: [`${PAGES}/categories/`, TAXONOMY, CATALOG], + })), + ...Object.values(INTERACTION_META).map((meta) => ({ + path: `/interactions/${meta.slug}`, + sources: [`${PAGES}/interactions/`, TAXONOMY, CATALOG], + })), +]; + +/** Icon pages, with their static SVGs and social card as images. */ +export const iconEntries = (): Entry[] => + icons.map((icon) => ({ + path: `/icons/${icon.id}`, + sources: [`icons/${icon.name}.json`, `${PAGES}/icons/[id].astro`], + images: [...urls(icon).svg, `${SITE}/og/icons/${icon.id}.png`], + })); + +const escape = (text: string) => + text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + +const lastmod = (date: string | undefined) => (date ? `<lastmod>${date}</lastmod>` : ''); + +export const urlset = (entries: Entry[]) => { + const tags = entries.map((entry) => { + const parts = [ + `<loc>${escape(SITE + entry.path)}</loc>`, + lastmod(lastModifiedOf(entry.sources)), + ...Object.entries(entry.alternates ?? {}).map( + ([lang, path]) => + `<xhtml:link rel="alternate" hreflang="${lang}" href="${escape(SITE + path)}"/>` + ), + ...(entry.images ?? []).map( + (image) => `<image:image><image:loc>${escape(image)}</image:loc></image:image>` + ), + ]; + return ` <url>${parts.join('')}</url>`; + }); + return `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"> +${tags.join('\n')} +</urlset> +`; +}; + +/** The sitemap index; each sitemap is dated by its most recently changed page. */ +export const sitemapIndex = (sitemaps: { path: string; entries: Entry[] }[]) => { + const tags = sitemaps.map(({ path, entries }) => { + const date = lastModifiedOf(entries.flatMap((entry) => entry.sources)); + return ` <sitemap><loc>${SITE}${path}</loc>${lastmod(date)}</sitemap>`; + }); + return `<?xml version="1.0" encoding="UTF-8"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +${tags.join('\n')} +</sitemapindex> +`; +}; diff --git a/website/src/lib/svg.ts b/website/src/lib/svg.ts new file mode 100644 index 0000000..525c306 --- /dev/null +++ b/website/src/lib/svg.ts @@ -0,0 +1,98 @@ +// Smaller markup for the static first frames the renderer draws at build time. The renderer writes +// every attribute a live animation may change later (opacity="1", identity transforms, wrapper +// groups, a clip path the size of the artboard); a still frame needs none of them. The result +// draws the same pixels with a fraction of the bytes and DOM nodes, which matters on pages that +// show hundreds of icons. +import { DOMParser } from 'linkedom'; + +const DEFAULTS: Record<string, string[]> = { + opacity: ['1'], + 'fill-opacity': ['1'], + 'stroke-opacity': ['1'], + display: ['inline'], + transform: ['matrix(1,0,0,1,0,0)'], + preserveAspectRatio: ['xMidYMid meet'], +}; + +/** Rounds the decimals of numbers in text: round('16.743000000000002', 3) -> '16.743'. */ +const round = (text: string, digits: number) => + text.replace(/-?\d*\.\d+(?:e-?\d+)?/gi, (n) => String(Number(Number(n).toFixed(digits)))); + +const insideClipPath = (el: Element) => !!el.parentElement?.closest('clipPath'); + +export const compactSvg = (markup: string): string => { + // Parsed as XML: an HTML parser would lowercase clipPath and viewBox. + const svg = new DOMParser().parseFromString(markup, 'image/svg+xml').documentElement; + if (svg?.tagName !== 'svg') return markup; + + for (const el of [svg, ...svg.querySelectorAll('*')]) { + for (const [name, values] of Object.entries(DEFAULTS)) { + if (values.includes(el.getAttribute(name) ?? '')) el.removeAttribute(name); + } + const d = el.getAttribute('d'); + // Path data to 3 decimals, as drawn; transforms keep their precision (scales multiply errors). + if (d !== null) el.setAttribute('d', round(d.trim(), 3)); + const transform = el.getAttribute('transform'); + if (transform !== null) el.setAttribute('transform', round(transform, 6)); + } + + // Hidden on the first frame and never shown in a still image. Opacity is ignored in clip paths. + for (const el of svg.querySelectorAll('[display="none"], [opacity="0"]')) { + if (el.getAttribute('display') === 'none' || !insideClipPath(el)) el.remove(); + } + // Empty paths (shapes trimmed to nothing on this frame) draw nothing anywhere. + for (const el of svg.querySelectorAll('path[d="M0 0"]')) el.remove(); + + // Stroke settings that every stroked shape shares are inherited from the <svg> instead. + const stroked = [...svg.querySelectorAll('[stroke]')]; + for (const name of ['stroke-width', 'stroke-linejoin', 'stroke-linecap']) { + const values = new Set(stroked.map((el) => el.getAttribute(name))); + const [value] = values; + if (values.size !== 1 || !value) continue; + for (const el of stroked) el.removeAttribute(name); + svg.setAttribute(name, value); + } + + // A clip path that is exactly the artboard clips nothing: the <svg> already clips to it. + const [, , width, height] = (svg.getAttribute('viewBox') ?? '').split(/\s+/); + for (const clip of svg.querySelectorAll('clipPath')) { + const shapes = [...clip.children]; + const rect = shapes[0]; + const artboard = + shapes.length === 1 && + rect.tagName === 'rect' && + Number(rect.getAttribute('x') ?? 0) === 0 && + Number(rect.getAttribute('y') ?? 0) === 0 && + rect.getAttribute('width') === width && + rect.getAttribute('height') === height && + !rect.getAttribute('transform'); + // Only for the outermost groups: nested ones may sit under a transform. + const users = [...svg.querySelectorAll(`[clip-path="url(#${clip.id})"]`)]; + if (!artboard || users.some((user) => user.parentElement?.closest('[transform]'))) continue; + for (const user of users) user.removeAttribute('clip-path'); + clip.remove(); + } + for (const defs of svg.querySelectorAll('defs')) if (!defs.children.length) defs.remove(); + + // Groups without attributes are only wrappers; a group with nothing but a transform and one + // child that has none passes the transform down. Innermost first, so chains collapse. + for (const g of [...svg.querySelectorAll('g')].toReversed()) { + const names = g.getAttributeNames(); + const only = g.children.length === 1 ? g.children[0] : undefined; + if ( + names.length === 1 && + names[0] === 'transform' && + only && + !only.hasAttribute('transform') && + !only.hasAttribute('clip-path') && + !only.hasAttribute('mask') + ) { + only.setAttribute('transform', g.getAttribute('transform') ?? ''); + g.removeAttribute('transform'); + } + if (!g.getAttributeNames().length) g.replaceWith(...g.childNodes); + else if (!g.children.length) g.remove(); + } + + return svg.outerHTML.replace(/>\s+</g, '><').replace(/ \/>/g, '/>'); +}; diff --git a/website/src/pages/404.astro b/website/src/pages/404.astro index f0342fb..c27dbfa 100644 --- a/website/src/pages/404.astro +++ b/website/src/pages/404.astro @@ -12,7 +12,13 @@ const icon = icons.find((item) => item.name === 'alertTriangle')!; <p class="mt-4 text-muted">The page you're looking for doesn't exist or has moved.</p> <div class="mt-8 flex justify-center gap-3"> <a href="/" class="rounded-full bg-fg px-5 py-2.5 text-sm font-medium text-bg">Home</a> - <a href="/#icons" class="rounded-full border border-line-strong px-5 py-2.5 text-sm">Browse icons</a> + <a href="/icons" class="rounded-full border border-line-strong px-5 py-2.5 text-sm">Browse icons</a> </div> + <nav aria-label="Popular pages" class="mt-10 flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm text-muted"> + <a href="/icons" class="underline underline-offset-4 hover:text-fg">All animated icons</a> + <a href="/categories" class="underline underline-offset-4 hover:text-fg">Categories</a> + <a href="/interactions" class="underline underline-offset-4 hover:text-fg">Interactions</a> + <a href="/documentation" class="underline underline-offset-4 hover:text-fg">Documentation</a> + </nav> </section> </Layout> diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 27be640..3b9d8f7 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -8,6 +8,14 @@ import ColorRow from '../components/ColorRow'; import InteractionDemo from '../components/InteractionDemo'; import Layout from '../layouts/Layout.astro'; import { highlight } from '../lib/highlight'; +import { + categoryLinks, + HOME_DESCRIPTION, + HOME_FAQ, + HOME_TITLE, + HUBS, + interactionLinks, +} from '../lib/home'; import { CATEGORIES, filesExample, @@ -17,8 +25,11 @@ import { searchText, shadcnExample, SITE, + VERSION, } from '../lib/icons'; -import { GITHUB } from '../lib/site'; +import { faqNode, ORGANIZATION_ID, organizationNode, WEBSITE_ID, websiteNode } from '../lib/seo'; +import { GITHUB, NPM } from '../lib/site'; +import { CATEGORY_META } from '../lib/taxonomy'; // HERO FIELD: icons whose animation starts and ends at rest, taken from every category in turn. const replayable = new Set(['hover-replay', 'click-replay', 'loop', 'hover-loop']); @@ -42,7 +53,7 @@ const demos = [ { name: 'archive', interaction: 'hover' as const, title: 'Hover', description: 'Plays forward on enter and back on leave.' }, { name: 'github', interaction: 'hover-loop' as const, title: 'Hover loop', description: 'Loops while hovered and finishes its cycle on leave.' }, { name: 'trendingUp', interaction: 'hover-replay' as const, title: 'Hover replay', description: 'Plays once from the start on every enter.' }, -].map((demo) => ({ ...demo, lottie: byName(demo.name).lottie })); +].map((demo) => ({ ...demo, id: byName(demo.name).id })); // DIALOG: metadata of every icon and the code blocks, highlighted once with placeholders. const index: DialogIcon[] = icons.map((icon) => ({ @@ -115,33 +126,68 @@ icon.react.import; // "import menu from 'react-useanimations/lib/menu';"`, const colorRow = ['heart', 'star', 'bookmark', 'notification', 'settings', 'zap'].map(byName); const colorful = ['#e5484d', '#f59e0b', '#12a594', '#4b5cff', '#a24bff', 'var(--fg)']; +const categories = categoryLinks(); +const interactions = interactionLinks(); + const jsonLd = { '@context': 'https://schema.org', '@graph': [ - { '@type': 'WebSite', name: 'useAnimations', url: `${SITE}/` }, + websiteNode(), + organizationNode(), + { + '@type': 'WebPage', + '@id': `${SITE}/#webpage`, + url: `${SITE}/`, + name: HOME_TITLE, + description: HOME_DESCRIPTION, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + about: { '@id': `${SITE}/#software` }, + primaryImageOfPage: `${SITE}/og.png`, + }, { - '@type': 'SoftwareApplication', + '@type': ['SoftwareApplication', 'SoftwareSourceCode'], + '@id': `${SITE}/#software`, name: 'react-useanimations', + description: `React component and ${icons.length} animated icons (Lottie) with hover, click and loop interactions built in. No dependencies.`, applicationCategory: 'DeveloperApplication', operatingSystem: 'Web', - offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + softwareVersion: VERSION, + version: VERSION, + downloadUrl: NPM, + installUrl: NPM, + codeRepository: GITHUB, + programmingLanguage: ['TypeScript', 'JavaScript'], + runtimePlatform: 'React', license: 'https://opensource.org/license/mit/', - downloadUrl: `${SITE}/icons.json`, + isAccessibleForFree: true, + offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, + author: { '@id': ORGANIZATION_ID }, + publisher: { '@id': ORGANIZATION_ID }, }, { - '@type': 'Organization', - name: 'useAnimations', - url: `${SITE}/`, - logo: `${SITE}/apple-touch-icon.png`, - sameAs: ['https://twitter.com/useAnimations', 'https://www.instagram.com/getuseanimations/', GITHUB], + '@type': 'Dataset', + '@id': `${SITE}/#catalog`, + name: 'useAnimations icon catalog', + description: `Every one of the ${icons.length} useAnimations icons with id, title, category, keywords, interaction, Lottie JSON, SVG and ZIP URLs, React import and shadcn command.`, + url: `${SITE}/icons.json`, + license: 'https://opensource.org/license/mit/', + isAccessibleForFree: true, + creator: { '@id': ORGANIZATION_ID }, + keywords: ['animated icons', 'micro animations', 'Lottie', 'SVG', 'React', 'shadcn/ui'], + distribution: [ + { '@type': 'DataDownload', encodingFormat: 'application/json', contentUrl: `${SITE}/icons.json` }, + { '@type': 'DataDownload', encodingFormat: 'application/json', contentUrl: `${SITE}/r/registry.json` }, + ], }, + { ...faqNode(HOME_FAQ), '@id': `${SITE}/#faq` }, ], }; --- <Layout - title="useAnimations — Free animated icons for React" - description={`${icons.length} free animated icons with hover, click and loop interactions built in. A tiny React component with no dependencies and a shadcn registry. MIT licensed.`} + title={HOME_TITLE} + description={HOME_DESCRIPTION} path="/" alternates={{ en: '/', 'zh-Hans': '/zh', 'x-default': '/' }} head={[ @@ -167,12 +213,15 @@ const jsonLd = { {icons.length} icons · zero dependencies · MIT <span class="transition-transform group-hover:translate-x-0.5">→</span> </a> - <h1 class="mx-auto mt-8 max-w-4xl text-5xl font-semibold tracking-[-0.045em] text-balance sm:text-7xl lg:text-[5.5rem] lg:leading-[0.95]"> + <p class="mx-auto mt-8 max-w-4xl text-5xl font-semibold tracking-[-0.045em] text-balance sm:text-7xl lg:text-[5.5rem] lg:leading-[0.95]"> Icons that <span class="text-gradient">come alive.</span> + </p> + <h1 class="mx-auto mt-6 max-w-3xl text-xl font-semibold tracking-tight text-balance sm:text-2xl"> + Free animated icons for React, shadcn/ui and Lottie </h1> - <p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-pretty text-muted sm:text-xl"> - {icons.length} hand-crafted animated icons with hover, click and loop interactions built in. Free, for - React. + <p class="mx-auto mt-4 max-w-2xl text-lg leading-relaxed text-pretty text-muted"> + {icons.length} hand-crafted micro animations with hover, click and loop micro-interactions built in. Use them + as a React component, add them with shadcn, or download SVG and Lottie JSON. MIT licensed. </p> <div class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"> <a @@ -342,9 +391,17 @@ const jsonLd = { data-section id={category === 'Alerts' ? 'alerts' : undefined} aria-label={category} - class="mt-8 first:mt-4" + class="icon-section mt-8 first:mt-4" > - <h3 class="mb-4 text-sm font-medium text-muted">{category}</h3> + <h3 class="mb-4 text-sm font-medium text-muted"> + {CATEGORY_META[category].indexable ? ( + <a href={`/categories/${CATEGORY_META[category].slug}`} class="transition-colors hover:text-fg"> + {category} + </a> + ) : ( + category + )} + </h3> <ul class="grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-3"> {icons .filter((icon) => icon.category === category) @@ -447,9 +504,9 @@ const jsonLd = { <ColorRow client:visible icons={colorRow.map((icon, i) => ({ + id: icon.id, title: icon.title, color: colorful[i], - lottie: icon.lottie, interaction: icon.interaction, }))} /> @@ -484,6 +541,87 @@ const jsonLd = { </div> </section> + <!-- BROWSE --> + <section class="mx-auto mt-32 max-w-7xl px-5 sm:px-8" aria-labelledby="browse-title"> + <h2 id="browse-title" class="max-w-2xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl"> + Find the right micro animation. + </h2> + <div class="mt-12 grid gap-10 lg:grid-cols-[2fr_1fr]"> + <nav aria-labelledby="browse-categories"> + <h3 id="browse-categories" class="text-lg font-semibold tracking-tight">Browse by category</h3> + <ul class="mt-4 grid gap-x-6 gap-y-2 sm:grid-cols-2 md:grid-cols-3"> + { + categories.map((link) => ( + <li> + <a href={link.href} class="group flex items-baseline justify-between gap-3 py-1 text-muted hover:text-fg"> + <span>{link.title}</span> + <span class="text-xs text-subtle tabular-nums">{link.count}</span> + </a> + </li> + )) + } + </ul> + </nav> + <div class="grid gap-10"> + <nav aria-labelledby="browse-interactions"> + <h3 id="browse-interactions" class="text-lg font-semibold tracking-tight">Browse by interaction</h3> + <ul class="mt-4 grid gap-y-2"> + { + interactions.map((link) => ( + <li> + <a href={link.href} class="flex items-baseline justify-between gap-3 py-1 text-muted hover:text-fg"> + <span>{link.title}</span> + <span class="text-xs text-subtle tabular-nums">{link.count}</span> + </a> + </li> + )) + } + </ul> + </nav> + <nav aria-labelledby="browse-formats"> + <h3 id="browse-formats" class="text-lg font-semibold tracking-tight">All icons and formats</h3> + <ul class="mt-4 grid gap-y-2"> + { + HUBS.map((link) => ( + <li> + <a href={link.href} class="block py-1 text-muted hover:text-fg">{link.label}</a> + </li> + )) + } + </ul> + </nav> + </div> + </div> + </section> + + <!-- FAQ --> + <section class="mx-auto mt-32 max-w-3xl px-5 sm:px-8" aria-labelledby="faq-title"> + <h2 id="faq-title" class="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">Questions</h2> + <div class="mt-10 divide-y divide-line border-y border-line"> + { + HOME_FAQ.map((item) => ( + <details class="group py-5"> + <summary class="flex cursor-pointer list-none items-center justify-between gap-6 text-lg font-medium [&::-webkit-details-marker]:hidden"> + <h3>{item.question}</h3> + <span class="text-subtle transition-transform group-open:rotate-45" aria-hidden="true">+</span> + </summary> + <p class="mt-3 leading-relaxed text-muted"> + {item.answer} + {item.link && ( + <> + {' '} + <a href={item.link.href} class="text-fg underline underline-offset-4"> + {item.link.label} → + </a> + </> + )} + </p> + </details> + )) + } + </div> + </section> + <!-- CTA --> <section class="mx-auto mt-32 max-w-7xl px-5 sm:px-8"> <div class="cta relative overflow-hidden rounded-[2.5rem] border border-line px-6 py-20 text-center sm:px-16"> diff --git a/website/src/pages/index.md.ts b/website/src/pages/index.md.ts index 52317bf..f90300d 100644 --- a/website/src/pages/index.md.ts +++ b/website/src/pages/index.md.ts @@ -1,16 +1,33 @@ +import { categoryLinks, HOME_FAQ, HUBS, interactionLinks } from '../lib/home'; import { CATEGORIES, icons, INTERACTIONS, SITE } from '../lib/icons'; import { text } from '../lib/http'; +import { CATEGORY_META } from '../lib/taxonomy'; + +const heading = (category: string) => { + const meta = CATEGORY_META[category]; + return meta.indexable ? `### [${category}](${SITE}/categories/${meta.slug})` : `### ${category}`; +}; export const GET = () => text( - `# useAnimations + `# useAnimations: free animated icons for React, shadcn/ui and Lottie -> ${icons.length} free animated icons (Lottie) with hover, click and loop interactions built in, for React. MIT licensed. +> ${icons.length} free micro animations (Lottie) with hover, click and loop interactions built in, for React, shadcn/ui and any Lottie player. MIT licensed. - React: \`npm install react-useanimations\`, then \`import github from 'react-useanimations/lib/github'\` and \`<UseAnimations animation={github} size={32} />\`. - shadcn/ui: \`npx shadcn@latest add ${SITE}/r/<id>.json\`. - Lottie: \`${SITE}/icons/<id>.json\`, static SVGs at \`${SITE}/icons/<id>/<state>.svg\`, ZIP at \`${SITE}/icons/<id>.zip\`. -- Catalog of every icon: [icons.json](${SITE}/icons.json). Documentation: [Markdown](${SITE}/documentation.md), [HTML](${SITE}/documentation). +- Catalog of every icon: [icons.json](${SITE}/icons.json). Documentation: [Markdown](${SITE}/documentation.md), [HTML](${SITE}/documentation). Everything in one file: [llms-full.txt](${SITE}/llms-full.txt). + +## Browse + +${HUBS.map((hub) => `- [${hub.label}](${SITE}${hub.href})`).join('\n')} +- Categories: ${categoryLinks() + .map((link) => `[${link.name}](${SITE}${link.href})`) + .join(', ')} +- Interactions: ${interactionLinks() + .map((link) => `[${INTERACTIONS[link.interaction].label}](${SITE}${link.href})`) + .join(', ')} ## Interactions @@ -20,17 +37,23 @@ ${Object.entries(INTERACTIONS) ## Icons +Each icon links to its page; the Markdown version of a page is at the same URL plus \`.md\`. + ${CATEGORIES.map( (category) => - `### ${category}\n\n${icons + `${heading(category)}\n\n${icons .filter((icon) => icon.category === category) .map( (icon) => - `- [${icon.title}](${SITE}/icons/${icon.id}) (\`${icon.name}\`, ${icon.interaction})` + `- [${icon.title}](${SITE}/icons/${icon.id}) ([Markdown](${SITE}/icons/${icon.id}.md); \`${icon.name}\`, ${icon.interaction})` ) .join('\n')}` ).join('\n\n')} +## FAQ + +${HOME_FAQ.map((item) => `### ${item.question}\n\n${item.answer}${item.link ? ` See [${item.link.label}](${SITE}${item.link.href}).` : ''}`).join('\n\n')} + ## License MIT. Attribution is optional: [Licencing & Terms](${SITE}/licencing-and-terms). diff --git a/website/src/pages/licencing-and-terms.astro b/website/src/pages/licencing-and-terms.astro index 5545597..abd5563 100644 --- a/website/src/pages/licencing-and-terms.astro +++ b/website/src/pages/licencing-and-terms.astro @@ -22,9 +22,10 @@ const jsonLd = { <Layout title="Licencing & Terms — useAnimations" - description="useAnimations icons and the react-useanimations package are MIT licensed: free for personal and commercial use, modification and redistribution, with no attribution required." + description="useAnimations icons and the react-useanimations package are MIT licensed: free for personal and commercial use and redistribution, no attribution needed." path="/licencing-and-terms" jsonLd={jsonLd} + head={[{ rel: 'alternate', type: 'text/markdown', href: '/licencing-and-terms.md', title: 'Licencing & Terms (Markdown)' }]} > <article class="prose mx-auto max-w-3xl px-5 pt-16 sm:px-8"> <p class="mb-2 text-sm font-medium text-accent">Licencing & Terms</p> diff --git a/website/src/pages/licencing-and-terms.md.ts b/website/src/pages/licencing-and-terms.md.ts new file mode 100644 index 0000000..1fb0921 --- /dev/null +++ b/website/src/pages/licencing-and-terms.md.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SITE } from '../lib/icons'; +import { text } from '../lib/http'; + +const license = readFileSync(join(import.meta.env.REPO_DIR, 'LICENSE'), 'utf8').trim(); + +export const GET = () => + text( + `# Licencing & Terms: free to use, MIT licensed + +The useAnimations icons and the \`react-useanimations\` package are released under the [MIT license](https://opensource.org/license/mit/). + +## What you can do + +- **Use** the icons in personal and commercial websites, apps and products. +- **Modify** them: change colors, sizes, timing or the motion itself. +- **Redistribute** them, including inside icon packs, templates, themes, UI kits and component registries. + +## Attribution + +Not required, but appreciated. Keep the license notice when you redistribute the files themselves, and if you'd like to credit us: + +\`\`\`html +Animated icons by <a href="https://useanimations.com">useAnimations</a> +\`\`\` + +## No warranty + +The icons and the code are provided as-is, without warranty of any kind. + +## License text + +\`\`\` +${license} +\`\`\` + +HTML version: ${SITE}/licencing-and-terms +`, + 'text/markdown' + ); diff --git a/website/src/pages/llms-full.txt.ts b/website/src/pages/llms-full.txt.ts new file mode 100644 index 0000000..45008ff --- /dev/null +++ b/website/src/pages/llms-full.txt.ts @@ -0,0 +1,42 @@ +// Everything an AI agent needs in one file: the documentation, then a compact entry per icon. +import { CATEGORIES, icons, libImport, shadcnCommand, SITE, urls } from '../lib/icons'; +import { text } from '../lib/http'; +import { documentationMarkdown } from '../lib/markdown'; +import { synonyms } from '../lib/seo'; + +const entry = (icon: (typeof icons)[number]) => { + const u = urls(icon); + const states = Object.keys(icon.svgs); + const aka = synonyms(icon); + return [ + `### ${icon.title} (\`${icon.id}\`)`, + '', + `- Category: ${icon.category}; interaction: \`${icon.interaction}\`${states.length ? `; states: ${states.join(', ')}` : ''}`, + aka.length ? `- Also known as: ${aka.join(', ')}` : '', + `- React: \`${libImport(icon)}\``, + `- shadcn: \`${shadcnCommand(icon)}\``, + `- Lottie JSON: ${u.lottie}`, + `- Page: ${u.page} (Markdown: ${u.page}.md)`, + ] + .filter(Boolean) + .join('\n'); +}; + +export const GET = () => + text( + `${documentationMarkdown()} + +# Icons + +All ${icons.length} icons by category. Static SVGs: \`${SITE}/icons/<id>/<state>.svg\` (one per state listed); ZIP of everything: \`${SITE}/icons/<id>.zip\`. Full metadata: ${SITE}/icons.json + +${CATEGORIES.map( + (category) => + `## ${category}\n\n${icons + .filter((icon) => icon.category === category) + .map(entry) + .join('\n\n')}` +).join('\n\n')} +`, + 'text/markdown' + ); diff --git a/website/src/pages/llms.txt.ts b/website/src/pages/llms.txt.ts index 419201a..eb819b5 100644 --- a/website/src/pages/llms.txt.ts +++ b/website/src/pages/llms.txt.ts @@ -1,17 +1,20 @@ +import { HUBS, categoryLinks, interactionLinks } from '../lib/home'; import { icons, INTERACTIONS, SITE } from '../lib/icons'; import { text } from '../lib/http'; export const GET = () => text(`# useAnimations -> Free library of ${icons.length} handcrafted animated icons built with Lottie (24/32 px artboards), each with a built-in interaction. Every icon has a direct, CORS-enabled Lottie JSON URL plus static SVGs, a React import and a shadcn registry item, for React projects; no account needed. +> Free library of ${icons.length} handcrafted animated icons (micro animations) built with Lottie (24/32 px artboards), each with a built-in interaction. Every icon has a direct, CORS-enabled Lottie JSON URL plus static SVGs, a React import and a shadcn registry item, for React projects; no account needed. Author: useAnimations License: MIT — free for personal and commercial use, modification and redistribution, no attribution required. ## For AI agents +- [Full context (Markdown)](${SITE}/llms-full.txt): the documentation plus a compact entry for every icon (id, category, synonyms, interaction, states, React import, shadcn command, file URLs), in one file. - [Icon catalog (JSON)](${SITE}/icons.json): every icon with id, title, category, keywords, interaction type (${Object.keys(INTERACTIONS).join(', ')}), direct Lottie JSON URL, SVG URLs, ZIP URL, react-useanimations import, shadcn command, size, duration and a ready-to-use lottie-web snippet. +- Icon pages as Markdown: \`${SITE}/icons/<id>.md\` (e.g. ${SITE}/icons/menu.md), with usage, files, interaction and related icons. Pages also answer \`Accept: text/markdown\` with their Markdown version. - React: \`npm install react-useanimations\`, then \`import checkmark from 'react-useanimations/lib/checkmark'\` and \`<UseAnimations animation={checkmark} size={32} />\`. Several icons: \`import { github, heart } from 'react-useanimations/icons'\`. No dependencies; icons follow currentColor and respect reduced motion. - shadcn/ui: \`npx shadcn@latest add ${SITE}/r/<id>.json\` adds \`components/animations/<id>.tsx\` exporting \`Animated<PascalCaseId>\` (e.g. \`<AnimatedCheckmark size={24} />\`); registry index: ${SITE}/r/registry.json - Direct file URLs: \`${SITE}/icons/<id>.json\` (Lottie), \`${SITE}/icons/<id>/<state>.svg\` (static SVG), \`${SITE}/icons/<id>.zip\` (full package), \`${SITE}/icons/<id>\` (icon page). @@ -19,10 +22,33 @@ License: MIT — free for personal and commercial use, modification and redistri ## Docs - [Documentation (Markdown)](${SITE}/documentation.md): interactions, props, importing icons, size and color, server rendering, shadcn/ui, your own animations, files and icons.json, performance and accessibility, license and FAQ. HTML version: ${SITE}/documentation -- [Homepage summary (Markdown)](${SITE}/index.md): every icon by category. -- [Licencing & Terms](${SITE}/licencing-and-terms): full usage rights. +- [Homepage summary (Markdown)](${SITE}/index.md): every icon by category, with links to each icon's Markdown page. +- [Licencing & Terms (Markdown)](${SITE}/licencing-and-terms.md): full usage rights. HTML version: ${SITE}/licencing-and-terms + +## Collections + +${HUBS.map((hub) => `- [${hub.label}](${SITE}${hub.href})`).join('\n')} + +## Categories + +${categoryLinks() + .map( + (link) => + `- [${link.title}](${SITE}${link.href}) (${link.count} icons, Markdown: ${SITE}${link.href}.md)` + ) + .join('\n')} + +## Interactions + +${interactionLinks() + .map( + (link) => + `- [${link.title}](${SITE}${link.href}) (\`${link.interaction}\`, ${link.count} icons): ${INTERACTIONS[link.interaction].description}` + ) + .join('\n')} ## Optional - [react-useanimations on GitHub](https://github.com/useAnimations/react-useanimations): the React package and the source of the icon set. +- [react-useanimations on npm](https://www.npmjs.com/package/react-useanimations) `); diff --git a/website/src/pages/robots.txt.ts b/website/src/pages/robots.txt.ts index 98efe19..fe64fe4 100644 --- a/website/src/pages/robots.txt.ts +++ b/website/src/pages/robots.txt.ts @@ -1,10 +1,17 @@ import { SITE } from '../lib/icons'; import { text } from '../lib/http'; +// Search and AI crawlers are welcome, named so their access is explicit. The Content Signals +// preference (contentsignals.org) is a comment: Lighthouse flags it as an unknown directive. +const AI_CRAWLERS = ['GPTBot', 'OAI-SearchBot', 'ClaudeBot', 'PerplexityBot', 'Google-Extended']; + export const GET = () => - text(`User-agent: * + text(`# Content-Signal: ai-train=yes, search=yes, ai-input=yes +User-agent: * +Allow: / + +${AI_CRAWLERS.map((agent) => `User-agent: ${agent}`).join('\n')} Allow: / -Content-Signal: ai-train=yes, search=yes, ai-input=yes User-agent: Bytespider Disallow: / diff --git a/website/src/pages/sitemap-hubs.xml.ts b/website/src/pages/sitemap-hubs.xml.ts new file mode 100644 index 0000000..54a54fd --- /dev/null +++ b/website/src/pages/sitemap-hubs.xml.ts @@ -0,0 +1,4 @@ +import { text } from '../lib/http'; +import { hubEntries, urlset } from '../lib/sitemap'; + +export const GET = () => text(urlset(hubEntries()), 'application/xml'); diff --git a/website/src/pages/sitemap-icons.xml.ts b/website/src/pages/sitemap-icons.xml.ts new file mode 100644 index 0000000..f663cf1 --- /dev/null +++ b/website/src/pages/sitemap-icons.xml.ts @@ -0,0 +1,4 @@ +import { text } from '../lib/http'; +import { iconEntries, urlset } from '../lib/sitemap'; + +export const GET = () => text(urlset(iconEntries()), 'application/xml'); diff --git a/website/src/pages/sitemap-pages.xml.ts b/website/src/pages/sitemap-pages.xml.ts new file mode 100644 index 0000000..c2b865a --- /dev/null +++ b/website/src/pages/sitemap-pages.xml.ts @@ -0,0 +1,4 @@ +import { text } from '../lib/http'; +import { pageEntries, urlset } from '../lib/sitemap'; + +export const GET = () => text(urlset(pageEntries()), 'application/xml'); diff --git a/website/src/pages/sitemap.xml.ts b/website/src/pages/sitemap.xml.ts index 292f08e..2127b5b 100644 --- a/website/src/pages/sitemap.xml.ts +++ b/website/src/pages/sitemap.xml.ts @@ -1,6 +1,6 @@ -import { icons, SITE } from '../lib/icons'; import { text } from '../lib/http'; import { seoLint } from '../lib/seo'; +import { hubEntries, iconEntries, pageEntries, sitemapIndex } from '../lib/sitemap'; // Duplicate or over-long icon titles and descriptions, logged once per build. const issues = seoLint(); @@ -10,20 +10,12 @@ console.warn( : 'seoLint: no duplicate or over-long icon titles and descriptions' ); -const pages = [ - '/', - '/documentation', - '/licencing-and-terms', - '/zh', - ...icons.map((icon) => `/icons/${icon.id}`), -]; - export const GET = () => text( - `<?xml version="1.0" encoding="UTF-8"?> -<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> -${pages.map((path) => ` <url><loc>${SITE}${path}</loc></url>`).join('\n')} -</urlset> -`, + sitemapIndex([ + { path: '/sitemap-pages.xml', entries: pageEntries() }, + { path: '/sitemap-hubs.xml', entries: hubEntries() }, + { path: '/sitemap-icons.xml', entries: iconEntries() }, + ]), 'application/xml' ); diff --git a/website/src/pages/zh.astro b/website/src/pages/zh.astro index f36d5e2..7a98735 100644 --- a/website/src/pages/zh.astro +++ b/website/src/pages/zh.astro @@ -1,35 +1,142 @@ --- +import CodeBlock from '../components/CodeBlock.astro'; import InstallCommand from '../components/InstallCommand.astro'; import Layout from '../layouts/Layout.astro'; -import { icons, SITE } from '../lib/icons'; +import { categoryLinks, interactionLinks } from '../lib/home'; +import { icons, SITE, VERSION } from '../lib/icons'; +import { faqNode, WEBSITE_ID } from '../lib/seo'; const preview = ['heart', 'menu', 'github', 'download', 'settings', 'star', 'notification', 'visibility'] .map((name) => icons.find((icon) => icon.name === name)!); + const perks = [ - ['零依赖', '内置播放器,仅 8 kB,无其他依赖。'], - ['交互内置', '每个图标自带循环、点击或悬停交互,开箱即用。'], - ['跟随颜色', '图标使用 currentColor,自动适配文字颜色与深色模式。'], - ['完全免费', 'MIT 许可证,个人与商业项目均可免费使用,无需署名。'], + ['零依赖,体积小', `内置播放器压缩后约 8 kB,没有任何第三方依赖;每个图标都是独立模块(中位数约 2 kB),只打包您实际引入的图标。`], + ['交互内置', '每个图标都自带设计好的交互:循环、点击切换、点击重播、悬停、悬停重播或悬停循环,放进页面即可使用,也可以用一个属性换成其他交互。'], + ['跟随颜色与主题', '图标使用 currentColor,自动继承文字颜色、悬停状态和深色模式;也可以通过属性、类名或普通 CSS 修改颜色和线条粗细。'], + ['默认无障碍', '尊重系统的“减少动态效果”设置,键盘聚焦时同样播放悬停动画,并且在您添加标签之前不会干扰屏幕阅读器。'], + ['支持服务端渲染', '组件标记为 use client,只在浏览器中绘制动画,可以直接用于 Next.js、Remix 和 Astro 等框架,无需动态导入。'], + ['完全免费', 'MIT 许可证:个人和商业项目均可免费使用、修改和再分发,无需署名。'], ]; + +// Category names as on the English pages, in Chinese. +const CATEGORY_ZH: Record<string, string> = { + Alerts: '警告提示', + Notifications: '通知铃铛', + Navigation: '导航与箭头', + Action: '操作按钮', + Content: '文件、文件夹与用户', + Form: '表单控件', + Media: '媒体播放', + 'Text editing': '文本编辑', + Communication: '沟通交流', + Devices: '设备', + Weather: '天气', + 'Maps & location': '地图与位置', + Commerce: '电子商务', + Charts: '图表', + Development: '开发者', + 'Design & layout': '设计与布局', + 'Shapes & math': '形状与数学', + Loading: '加载动画', + 'Social media': '社交媒体', + Other: '其他', +}; + +const INTERACTION_ZH: Record<string, [string, string]> = { + loop: ['循环', '持续播放,适合加载中、警告等持续状态。'], + 'click-toggle': ['点击切换', '每次点击在两个状态之间切换,例如菜单与关闭、播放与暂停、静音与取消静音。'], + 'click-replay': ['点击重播', '每次点击都从头播放一次,用来确认发送、保存、下载等操作已经完成。'], + hover: ['悬停', '指针移入时正向播放,移出时反向播放,动画始终跟随光标。'], + 'hover-replay': ['悬停重播', '指针移入时播放一次并自然结束,快速划过工具栏时也不会抖动。'], + 'hover-loop': ['悬停循环', '悬停期间循环播放,离开后播放完当前一轮再停止。'], +}; + +const faq = [ + { + question: '什么是微动画和动态图标?', + answer: + '微动画是响应用户操作的简短动效,例如菜单图标变成关闭按钮、点赞时爱心被填满、有新消息时铃铛摇动。动态图标是最常见的微动画,它让界面反馈更及时,不需要额外的文字就能表达状态变化。', + }, + { + question: 'Lottie 文件和 SVG 文件有什么区别?', + answer: + 'Lottie JSON 文件包含完整的动画,可以在任何 Lottie 播放器中播放,包括 react-useanimations 内置的播放器。SVG 文件是图标静止时的图片,每个状态一张(例如 eye 和 eye-off),适合邮件、文档、设计工具或不运行 JavaScript 的场景。', + }, + { + question: '可以在 Next.js 和服务端渲染中使用吗?', + answer: + '可以。组件标记为 use client,只在浏览器中绘制动画,因此可以在服务端组件以及 Next.js、Remix、Astro 等 SSR 框架中直接渲染,不需要 dynamic(…, { ssr: false })。图标模块是普通数据,服务端组件可以直接导入并作为属性传递。', + }, + { + question: '可以用于商业项目吗?', + answer: + '可以。图标和 react-useanimations 软件包都采用 MIT 许可证,可以在个人和商业作品中使用、修改和再分发。署名不是必需的,但我们很欢迎。', + }, + { + question: '不使用 React 也能用吗?', + answer: + '可以。每个图标都是标准的 Lottie JSON 文件,可以通过 lottie-web 在原生 JavaScript、Vue、Svelte、Angular 或 CMS 中播放,也可以在 iOS、Android 和 Flutter 的 Lottie 播放器中使用。', + }, + { + question: '会让打包体积增加多少?', + answer: `react-useanimations ${VERSION} 的播放器压缩后约 8 kB,没有依赖;每个图标是 1 到 15 kB 的独立模块(中位数 2 kB),最终只打包您导入的图标。`, + }, +]; + +const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'WebPage', + '@id': `${SITE}/zh#webpage`, + name: 'useAnimations — 免费动态图标库', + url: `${SITE}/zh`, + inLanguage: 'zh-Hans', + isPartOf: { '@id': WEBSITE_ID }, + }, + { ...faqNode(faq), inLanguage: 'zh-Hans' }, + ], +}; + +const reactCode = `import UseAnimations from 'react-useanimations'; +import menu from 'react-useanimations/lib/menu'; + +export function MenuButton({ open, onToggle }) { + return ( + <UseAnimations + animation={menu} + active={open} + onClick={onToggle} + size={28} + /> + ); +}`; +const categories = categoryLinks(); +const interactions = interactionLinks(); +const link = 'underline decoration-line-strong underline-offset-4 hover:decoration-accent'; --- <Layout - title="useAnimations — 免费 React 动画图标库" - description={`${icons.length} 个手工制作的动画图标,内置交互,可直接用于 React 应用。免费、轻量,并提供 SVG 下载。`} + title="useAnimations — 免费动态图标库:React 动画图标与 Lottie 微动画" + description={`${icons.length} 个免费动态图标(微动画),内置悬停、点击与循环交互,支持 React、shadcn/ui 和 Lottie,提供 SVG 与 JSON 下载,MIT 许可。`} path="/zh" lang="zh-Hans" alternates={{ en: '/', 'zh-Hans': '/zh', 'x-default': '/' }} - jsonLd={{ '@context': 'https://schema.org', '@type': 'WebPage', name: 'useAnimations — 免费动画图标库', url: `${SITE}/zh`, inLanguage: 'zh-Hans' }} + jsonLd={jsonLd} > <section class="relative overflow-hidden"> <div class="hero-glow pointer-events-none absolute inset-0" aria-hidden="true"></div> <div class="relative mx-auto max-w-4xl px-5 pt-24 pb-16 text-center sm:px-8"> - <p class="text-sm font-medium text-accent">微动画图标库</p> - <h1 class="mt-5 text-5xl font-semibold tracking-tight sm:text-7xl"> + <p class="text-sm font-medium text-accent">微动画图标库 · {icons.length} 个图标 · MIT</p> + <p class="mt-5 text-5xl font-semibold tracking-tight sm:text-7xl"> 让图标<span class="text-gradient">动起来</span> + </p> + <h1 class="mx-auto mt-6 max-w-3xl text-xl font-semibold tracking-tight text-balance sm:text-2xl"> + 免费动态图标库:适用于 React、shadcn/ui 和 Lottie 的动画图标 </h1> - <p class="mx-auto mt-6 max-w-2xl text-lg text-muted"> - {icons.length} 个动画图标,内置悬停、点击与循环交互,可直接用于您的 React 应用。 + <p class="mx-auto mt-4 max-w-2xl text-lg leading-relaxed text-muted"> + useAnimations 提供 {icons.length} 个手工制作的动态图标,每个图标都内置悬停、点击或循环交互。您可以把它们作为 React + 组件使用,通过 shadcn 一条命令添加到项目中,或者直接下载 SVG 和 Lottie JSON 文件。全部免费,采用 MIT 许可证。 </p> <div class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"> <a href="/#icons" class="rounded-full bg-fg px-6 py-3 text-sm font-medium text-bg hover:opacity-90">浏览全部图标</a> @@ -46,19 +153,128 @@ const perks = [ </div> </div> </section> - <section class="mx-auto grid max-w-5xl gap-4 px-5 sm:grid-cols-2 sm:px-8"> - { - perks.map(([title, text]) => ( - <article class="rounded-3xl border border-line bg-surface p-7"> - <h2 class="text-lg font-semibold">{title}</h2> - <p class="mt-2 text-muted">{text}</p> - </article> - )) - } + + <section class="mx-auto max-w-5xl px-5 sm:px-8" aria-labelledby="perks-title"> + <h2 id="perks-title" class="text-3xl font-semibold tracking-tight sm:text-4xl">为什么选择 useAnimations</h2> + <div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> + { + perks.map(([title, text]) => ( + <article class="rounded-3xl border border-line bg-surface p-7"> + <h3 class="text-lg font-semibold">{title}</h3> + <p class="mt-2 text-muted">{text}</p> + </article> + )) + } + </div> + </section> + + <section class="mx-auto mt-24 max-w-5xl px-5 sm:px-8" aria-labelledby="install-title"> + <h2 id="install-title" class="text-3xl font-semibold tracking-tight sm:text-4xl">安装与使用</h2> + <div class="mt-8 grid gap-10 lg:grid-cols-2"> + <div class="min-w-0"> + <h3 class="text-lg font-semibold">React</h3> + <p class="mt-2 text-muted"> + 先安装软件包,再从 <code class="font-mono text-sm text-fg">react-useanimations/lib/<名称></code> + 导入需要的图标。下面的菜单按钮在打开和关闭之间切换,<code class="font-mono text-sm text-fg">active</code> + 属性让图标与您的状态保持同步: + </p> + <CodeBlock code="npm install react-useanimations" lang="bash" class="mt-4" /> + <CodeBlock code={reactCode} lang="jsx" class="mt-3" /> + </div> + <div class="min-w-0"> + <h3 class="text-lg font-semibold">shadcn/ui</h3> + <p class="mt-2 text-muted"> + 每个图标也在 shadcn 注册表中。CLI 会在 <code class="font-mono text-sm text-fg">components/animations/</code> + 下生成一个现成的组件,并自动安装依赖: + </p> + <CodeBlock code={`npx shadcn@latest add ${SITE}/r/menu.json`} lang="bash" class="mt-4" /> + <h3 class="mt-8 text-lg font-semibold">Lottie、SVG 与 ZIP 文件</h3> + <p class="mt-2 text-muted"> + 不使用 React 时,可以直接下载文件:每个图标都有 Lottie JSON 动画、每个状态一张静态 SVG,以及包含全部文件的 ZIP + 压缩包。所有文件都有固定网址并支持跨域访问,可用于 lottie-web、Vue、Svelte、移动应用或设计工具。 + </p> + <CodeBlock + code={`${SITE}/icons/menu.json # Lottie JSON\n${SITE}/icons/menu/menu.svg # 静态 SVG\n${SITE}/icons/menu.zip # 全部文件`} + lang="bash" + class="mt-4" + /> + </div> + </div> + <p class="mt-8 text-muted"> + 完整的属性说明、受控切换、颜色与尺寸、服务端渲染和从 v2 升级的步骤,请参阅<a class={link} href="/documentation" + >英文文档</a + >。 + </p> + </section> + + <section class="mx-auto mt-24 max-w-5xl px-5 sm:px-8" aria-labelledby="interactions-title"> + <h2 id="interactions-title" class="text-3xl font-semibold tracking-tight sm:text-4xl">六种内置交互</h2> + <p class="mt-4 text-muted"> + 每个图标都带有为它设计的交互,放进页面就能正确播放;需要时也可以通过 <code class="font-mono text-sm text-fg" + >interaction</code + > 属性改成其他方式。 + </p> + <ul class="mt-8 grid gap-4 sm:grid-cols-2"> + { + interactions.map((item) => ( + <li class="rounded-3xl border border-line bg-surface p-6"> + <a href={item.href} class="text-lg font-semibold hover:text-accent"> + {INTERACTION_ZH[item.interaction][0]} + <span class="ml-2 font-mono text-xs font-normal text-subtle">{item.interaction}</span> + </a> + <p class="mt-2 text-muted"> + {INTERACTION_ZH[item.interaction][1]}({item.count} 个图标) + </p> + </li> + )) + } + </ul> + </section> + + <section class="mx-auto mt-24 max-w-5xl px-5 sm:px-8" aria-labelledby="categories-title"> + <h2 id="categories-title" class="text-3xl font-semibold tracking-tight sm:text-4xl">按类别浏览</h2> + <p class="mt-4 text-muted"> + 图标按用途分为 {categories.length} 个类别,从导航菜单、通知铃铛、媒体播放到电子商务和开发者工具。类别页面目前为英文,包含每个图标的预览和用法。 + </p> + <ul class="mt-8 grid gap-x-6 gap-y-2 sm:grid-cols-2 md:grid-cols-3"> + { + categories.map((item) => ( + <li> + <a href={item.href} class="flex items-baseline justify-between gap-3 py-1 text-muted hover:text-fg"> + <span> + {CATEGORY_ZH[item.name] ?? item.name} + <span class="ml-1.5 text-xs text-subtle" lang="en">{item.name}</span> + </span> + <span class="text-xs text-subtle tabular-nums">{item.count}</span> + </a> + </li> + )) + } + </ul> + <p class="mt-6 text-muted"> + 也可以查看<a class={link} href="/icons">全部动画图标</a>、<a class={link} href="/lottie-icons">Lottie 图标</a + >和<a class={link} href="/shadcn-animated-icons">shadcn/ui 动画图标</a>。 + </p> + </section> + + <section class="mx-auto mt-24 max-w-3xl px-5 sm:px-8" aria-labelledby="faq-title"> + <h2 id="faq-title" class="text-3xl font-semibold tracking-tight sm:text-4xl">常见问题</h2> + <div class="mt-8 divide-y divide-line border-y border-line"> + { + faq.map((item) => ( + <details class="group py-5"> + <summary class="flex cursor-pointer list-none items-center justify-between gap-6 text-lg font-medium [&::-webkit-details-marker]:hidden"> + <h3>{item.question}</h3> + <span class="text-subtle transition-transform group-open:rotate-45" aria-hidden="true">+</span> + </summary> + <p class="mt-3 leading-relaxed text-muted">{item.answer}</p> + </details> + )) + } + </div> + <p class="mt-8 text-center text-sm text-muted"> + <a class={link} href="/#icons">全部图标</a> · <a class={link} href="/documentation">文档</a> · + <a class={link} href="/licencing-and-terms">许可与条款</a> · <a class={link} href="/" hreflang="en">English</a> + </p> </section> - <p class="mx-auto mt-12 max-w-5xl px-5 text-center text-sm text-muted sm:px-8"> - 完整的图标库、下载与文档目前提供英文版:<a class="underline underline-offset-4" href="/#icons">图标</a> · - <a class="underline underline-offset-4" href="/documentation">文档</a> · - <a class="underline underline-offset-4" href="/licencing-and-terms">许可</a> - </p> </Layout> diff --git a/website/src/styles/global.css b/website/src/styles/global.css index 2a97157..b27baef 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -9,7 +9,9 @@ --bg: #fbfbfd; --fg: #0d0f14; --muted: #5b6170; - --subtle: #8b90a0; + --subtle: #676c7b; + /* Decorative only (the hero field), below text contrast on purpose. */ + --faint: #8b90a0; --surface: #ffffff; --surface-2: #f3f4f8; --line: rgb(13 15 20 / 0.08); @@ -25,7 +27,8 @@ --bg: #08090c; --fg: #eef0f6; --muted: #9aa0b2; - --subtle: #6b7183; + --subtle: #8a90a2; + --faint: #6b7183; --surface: #101218; --surface-2: #161922; --line: rgb(255 255 255 / 0.07); @@ -310,7 +313,7 @@ .icon-field { --mx: 50%; --my: 40%; - color: var(--subtle); + color: var(--faint); } /* A soft spotlight under the pointer. */ .icon-field::before { @@ -348,6 +351,76 @@ } } +/* ICON BROWSER: one card per icon (components/IconCard.tsx). Plain classes instead of utility lists, + which repeated 300 times on the home page. */ +.icon-card { + position: relative; + display: flex; + flex-direction: column; + aspect-ratio: 1 / 1.08; + cursor: pointer; + border: 1px solid var(--line); + border-radius: var(--radius-2xl); + transition-property: border-color, box-shadow; + transition-duration: 200ms; + transition-timing-function: var(--default-transition-timing-function); +} +.icon-card:hover { + border-color: var(--line-strong); + box-shadow: 0 18px 40px -24px var(--glow); +} +.icon-card-hint { + pointer-events: none; + position: absolute; + top: 0.625rem; + right: 0.625rem; + border-radius: 9999px; + background: var(--surface-2); + padding: 0.125rem 0.5rem; + font-size: 10px; + font-weight: 500; + color: var(--subtle); + opacity: 0; + transition: opacity 150ms; +} +.icon-card:hover .icon-card-hint { + opacity: 1; +} +.icon-card-art { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + color: var(--icon-color, var(--fg)); +} +.icon-card-frame { + display: inline-block; + flex-shrink: 0; + overflow: hidden; + width: var(--icon-size, 40px); + height: var(--icon-size, 40px); +} +.icon-card-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 0 0 var(--radius-2xl) var(--radius-2xl); + padding: 0 0.75rem 0.75rem; + text-align: center; + font-size: 13px; + color: var(--muted); + transition: color 150ms; +} +.icon-card:hover .icon-card-title { + color: var(--fg); +} +/* Sections below the fold skip layout and paint until they near the viewport; the size is a + placeholder per row of cards so the scrollbar doesn't jump. Links stay in the DOM for crawlers. */ +.icon-section { + content-visibility: auto; + contain-intrinsic-size: auto 40rem; +} + /* The dialog itself takes focus when it opens; only its controls show a focus ring. */ .icon-dialog:focus-visible { outline: none; From 4d9b5827fb073d4329aaf3e2b0a029a50de04041 Mon Sep 17 00:00:00 2001 From: Tuan Phung <tuan@phung.cz> Date: Sun, 11 Oct 2026 12:20:13 +0200 Subject: [PATCH 5/5] Website: painted stills for static icon tiles, interaction demo fix, less templated icon pages - Build a still per icon: the first frame, or the fullest of eight later frames when the first draws nothing (checkmark, loading, loading-v2, loading-v4, arrow-up, arrow-down). Static grids, related and version lists, the home cards and OG cards use it; reduced-motion home cards keep it. - Interaction pages passed the Lottie JSON instead of the icon id to InteractionDemo, which then fetched /icons/undefined.json. - Icon FAQ: two or three questions on interaction edge cases, accessibility and colors or an alternative interaction, instead of four templated answers repeating facts and code. - Related icons count synonyms and fall back to same-category icons with the same interaction in a per-icon order; the intro leaves out the other names shown as chips. - Keep the OG self-closing SVG fix, the docs FAQ answer and the /icons rewrite. --- website/public/_redirects | 3 + website/src/components/IconCard.tsx | 8 +- website/src/components/IconGrid.astro | 4 +- website/src/lib/icons.ts | 45 ++++- website/src/lib/og-image.ts | 43 ++--- website/src/lib/seo.ts | 187 +++++++++++++++----- website/src/pages/404.astro | 2 +- website/src/pages/categories/index.astro | 2 +- website/src/pages/documentation.astro | 2 +- website/src/pages/icons/[id].astro | 6 +- website/src/pages/index.astro | 9 +- website/src/pages/interactions/[slug].astro | 2 +- website/src/pages/interactions/index.astro | 2 +- website/src/pages/og.astro | 2 +- website/src/pages/zh.astro | 2 +- website/src/scripts/browser.ts | 3 + 16 files changed, 223 insertions(+), 99 deletions(-) diff --git a/website/public/_redirects b/website/public/_redirects index 55242db..cadb386 100644 --- a/website/public/_redirects +++ b/website/public/_redirects @@ -6,6 +6,9 @@ /licencing-and-terms.html /licencing-and-terms 301! /zh.html /zh 301! +# The all-icons index: icons/ is also the folder of the icon files, so serve icons.html explicitly. +/icons /icons.html 200! + # Other spellings of pages. /docs /documentation 301! /license /licencing-and-terms 301! diff --git a/website/src/components/IconCard.tsx b/website/src/components/IconCard.tsx index f8a44e5..e1e9c85 100644 --- a/website/src/components/IconCard.tsx +++ b/website/src/components/IconCard.tsx @@ -36,16 +36,16 @@ const Frame = ({ ); /** - * One icon of the browser. Without `animationData` it shows the prerendered first frame; with it, + * One icon of the browser. Without `animationData` it shows the prerendered still; with it, * the live icon, which plays when the card is hovered. A click on the card opens the details. */ export default function IconCard({ icon, - firstFrame, + still, animationData, }: { icon: CardIcon; - firstFrame?: string; + still?: string; animationData?: object; }) { if (!animationData) { @@ -55,7 +55,7 @@ export default function IconCard({ aria-hidden="true" data-useanimations="" className="icon-card-frame" - dangerouslySetInnerHTML={{ __html: firstFrame ?? '' }} + dangerouslySetInnerHTML={{ __html: still ?? '' }} /> </Frame> ); diff --git a/website/src/components/IconGrid.astro b/website/src/components/IconGrid.astro index 1da25cc..1f671f4 100644 --- a/website/src/components/IconGrid.astro +++ b/website/src/components/IconGrid.astro @@ -6,7 +6,7 @@ interface Props { class?: string; } -// Static cards: a link to the icon page with its first frame, so every icon is crawlable without +// Static cards: a link to the icon page with its still (lib/icons.ts), so every icon is crawlable without // scripts. Same markup as the related icons on icon pages. const { icons, class: className } = Astro.props; --- @@ -19,7 +19,7 @@ const { icons, class: className } = Astro.props; href={`/icons/${icon.id}`} class="flex aspect-[1/1.08] flex-col items-center justify-center gap-3 rounded-2xl border border-line bg-surface px-2 text-center text-[13px] text-muted transition hover:border-line-strong hover:text-fg" > - <span data-useanimations="" class="block size-10 text-fg" set:html={icon.firstFrame} /> + <span data-useanimations="" class="block size-10 text-fg" set:html={icon.preview} /> {icon.title} </a> </li> diff --git a/website/src/lib/icons.ts b/website/src/lib/icons.ts index 0436e47..4604681 100644 --- a/website/src/lib/icons.ts +++ b/website/src/lib/icons.ts @@ -1,4 +1,5 @@ // The icon set from ../icons, with everything the site derives from it at build time. +import { Resvg } from '@resvg/resvg-js'; import { parseHTML } from 'linkedom'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -42,6 +43,11 @@ export type Icon = CatalogEntry & { requiresFullPlayer: boolean; /** The first frame, drawn by the library's renderer, so the page shows icons before any script. */ firstFrame: string; + /** + * A still for static tiles: the first frame, or for icons whose first frame draws nothing + * (loaders that start empty, checkmarks that draw in) the fullest of a few later frames. + */ + preview: string; }; /** The order of the sections on the site. */ @@ -126,20 +132,47 @@ const hasExpressions = (value: unknown): boolean => const { document } = parseHTML('<!doctype html><html><body></body></html>'); -const firstFrame = (id: string, data: { fr: number; ip: number }) => { +/** Frames of the animation at `progress` (0–1 each), drawn by the library's renderer. */ +const framesAt = (id: string, data: { fr: number; ip: number; op: number }, progress: number[]) => { const previous = (globalThis as { document?: unknown }).document; (globalThis as { document?: unknown }).document = document; try { const { svg, render } = createRenderer(data as never, { frameRate: data.fr, warn: () => {} }); - render(data.ip); - // Element ids must stay unique next to the live players on the same page, and the same between - // builds: the renderer's prefix is random ('__ua<random>_'). - return compactSvg(svg.outerHTML.replace(/__ua[a-z0-9]*_/g, `__uas_${id}_`)); + return progress.map((p) => { + render(data.ip + (data.op - 1 - data.ip) * p); + // Element ids must stay unique next to the live players on the same page, and the same + // between builds: the renderer's prefix is random ('__ua<random>_'). + return compactSvg(svg.outerHTML.replace(/__ua[a-z0-9]*_/g, `__uas_${id}_`)); + }); } finally { (globalThis as { document?: unknown }).document = previous; } }; +/** How many pixels a drawing paints at 48 px (trimmed or transparent frames paint none). */ +const painted = (svg: string) => { + const markup = svg + .replace(/^(<svg[^>]*?)\s*\/>$/, '$1></svg>') + .replace(/^<svg(?![^>]*xmlns=)/, '<svg xmlns="http://www.w3.org/2000/svg"'); + const { pixels } = new Resvg(markup, { + fitTo: { mode: 'width', value: 48 }, + font: { loadSystemFonts: false }, + }).render(); + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count += 1; + return count; +}; + +const LATER = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]; + +const stills = (id: string, data: { fr: number; ip: number; op: number }) => { + const [first] = framesAt(id, data, [0]); + if (painted(first) > 20) return { firstFrame: first, preview: first }; + const later = framesAt(id, data, LATER).map((svg) => ({ svg, count: painted(svg) })); + const fullest = later.reduce((best, frame) => (frame.count > best.count ? frame : best)); + return { firstFrame: first, preview: fullest.count > 20 ? fullest.svg : first }; +}; + const load = (): Icon[] => { const catalog: CatalogEntry[] = JSON.parse(read('catalog.json')); const svgs: Record<string, Record<string, string>> = JSON.parse(read('svg.json')); @@ -160,7 +193,7 @@ const load = (): Icon[] => { fps: data.fr, durationMs: Math.round(((data.op - data.ip) / data.fr) * 1000), requiresFullPlayer: hasExpressions(data.layers), - firstFrame: firstFrame(entry.id, data), + ...stills(entry.id, data), }; }); }; diff --git a/website/src/lib/og-image.ts b/website/src/lib/og-image.ts index 6ee630c..4d54ce3 100644 --- a/website/src/lib/og-image.ts +++ b/website/src/lib/og-image.ts @@ -6,8 +6,6 @@ import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { inflateSync } from 'node:zlib'; -import { parseHTML } from 'linkedom'; -import { createRenderer } from '../../../src/player/render'; import { type Icon, INTERACTIONS, SITE } from './icons'; export const OG_WIDTH = 1200; @@ -92,22 +90,6 @@ const escape = (text: string) => const LOGO_MARK = '<path fill="url(#accent)" fill-opacity=".55" d="M11.29 33.89c.28 0 1.3.18 1.3-1.1V14.96a3 3 0 0 0-3-3H3a3 3 0 0 0-3 3v7.97c0 6.05 5.05 10.96 11.29 10.96"/><path fill="url(#accent)" d="M32.61.01c.43 0 2.02-.27 2.02 1.71V31a3 3 0 0 1-3 3H18.11a3 3 0 0 1-3-3V17c0-9.38 7.84-16.99 17.5-16.99"/>'; -const { document } = parseHTML('<!doctype html><html><body></body></html>'); - -/** The icon at `progress` (0–1) of its animation, drawn by the library's renderer. */ -const frameAt = (icon: Icon, progress: number) => { - const data = JSON.parse(icon.lottie) as { fr: number; ip: number; op: number }; - const previous = (globalThis as { document?: unknown }).document; - (globalThis as { document?: unknown }).document = document; - try { - const { svg, render } = createRenderer(data as never, { frameRate: data.fr, warn: () => {} }); - render(data.ip + (data.op - 1 - data.ip) * progress); - return svg.outerHTML.replace(/__ua[a-z0-9]*_/g, `__og_${icon.id}_`); - } finally { - (globalThis as { document?: unknown }).document = previous; - } -}; - const recolor = (svg: string) => svg .replace(/(stroke|fill)="rgb\(255,\s*255,\s*255\)"/g, `$1="${SURFACE}"`) @@ -118,10 +100,13 @@ const recolor = (svg: string) => const place = (svg: string, x: number, y: number, size: number) => { const viewBox = /viewBox="([^"]+)"/.exec(svg)?.[1]; if (!viewBox) return undefined; - return svg.replace( - /^<svg[^>]*>/, - `<svg xmlns="http://www.w3.org/2000/svg" x="${x}" y="${y}" width="${size}" height="${size}" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" fill="none">` - ); + // compactSvg serializes a frame that draws nothing as a self-closing <svg/>. + return svg + .replace(/^(<svg[^>]*?)\s*\/>$/, '$1></svg>') + .replace( + /^<svg[^>]*>/, + `<svg xmlns="http://www.w3.org/2000/svg" x="${x}" y="${y}" width="${size}" height="${size}" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" fill="none">` + ); }; /** True when the drawing paints at least a few pixels (trimmed or transparent frames don't). */ @@ -136,18 +121,12 @@ const paints = (svg: string) => { }; /** - * The icon in the accent color: the first frame, as on the page before any script; for frames that - * draw nothing (loaders that start empty) a later frame, then a static SVG, then the logo mark. + * The icon in the accent color: its still (lib/icons.ts), as on the page before any script, then a + * static SVG, then the logo mark. */ const drawing = (icon: Icon, x: number, y: number, size: number) => { - const candidates = [ - () => icon.firstFrame, - () => frameAt(icon, 0.5), - () => frameAt(icon, 0.25), - ...Object.values(icon.svgs).map((svg) => () => svg), - ]; - for (const candidate of candidates) { - const svg = recolor(candidate()); + for (const candidate of [icon.preview, ...Object.values(icon.svgs)]) { + const svg = recolor(candidate); const preview = place(svg, 0, 0, 48); if (preview && paints(preview)) return place(svg, x, y, size)!; } diff --git a/website/src/lib/seo.ts b/website/src/lib/seo.ts index 061e9bf..da0aa2b 100644 --- a/website/src/lib/seo.ts +++ b/website/src/lib/seo.ts @@ -1,16 +1,7 @@ // Per-icon search copy (titles, descriptions, intros, FAQs), related icons and shared JSON-LD // nodes. Everything is derived from catalog data, and the template for an icon is picked by a // stable hash of its id, so pages differ from each other and stay the same between builds. -import { - CATEGORIES, - type Icon, - icons, - INTERACTIONS, - libImport, - shadcnCommand, - SITE, - urls, -} from './icons'; +import { CATEGORIES, type Icon, icons, INTERACTIONS, SITE } from './icons'; import { GITHUB, NPM } from './site'; import { CATEGORY_META, INTERACTION_META } from './taxonomy'; @@ -168,11 +159,12 @@ export const variantFamily = (icon: Icon): Icon[] => // Words that tie icons together: meaningful keywords plus title words, weighted by rarity (IDF) // so 'arrow' or 'file' counts for less than 'password'. +// Synonyms count too, so 'heart' (like, favorite) meets 'thumb up' (like) and 'star' (favorite). const terms = new Map( icons.map((icon) => [ icon.id, new Set( - [...meaningfulKeywords(icon), ...words(icon.title)].filter( + [...meaningfulKeywords(icon), ...words(icon.title), ...synonyms(icon)].filter( (t) => !isNoise(t) && !categoryNames.has(t) ) ), @@ -184,7 +176,11 @@ for (const set of terms.values()) { } const idf = (term: string) => Math.log(icons.length / (documentFrequency.get(term) ?? 1)); -/** Up to n related icons: the variant family, then the most shared terms, then the category. */ +/** + * Up to n related icons: the variant family, then the most shared terms, then other icons of the + * category with the same interaction, in an order of their own per icon (not the site order, which + * would give every icon of a category the same fallback list). + */ export const related = (icon: Icon, n = 12): Icon[] => { const mine = terms.get(icon.id) ?? new Set<string>(); const scored = icons @@ -201,7 +197,14 @@ export const related = (icon: Icon, n = 12): Icon[] => { Number(b.other.category === icon.category) - Number(a.other.category === icon.category) ) .map(({ other }) => other); - const sameCategory = icons.filter((other) => other.category === icon.category); + const shuffle = (other: Icon) => hash(icon.id + other.id); + const sameCategory = icons + .filter((other) => other.category === icon.category) + .toSorted( + (a, b) => + Number(b.interaction === icon.interaction) - Number(a.interaction === icon.interaction) || + shuffle(a) - shuffle(b) + ); const result = new Set<Icon>(); for (const other of [...variantFamily(icon), ...scored, ...sameCategory]) { if (result.size >= n) break; @@ -348,8 +351,11 @@ const bestUseCase = (icon: Icon): string | undefined => { ); }; -/** Two or three sentences for the top of an icon page, specific to the icon. */ -export const iconIntro = (icon: Icon): string => { +/** + * Two or three sentences for the top of an icon page, specific to the icon. `aka: false` leaves out + * the other names, for pages that list them anyway. + */ +export const iconIntro = (icon: Icon, { aka: withAka = true } = {}): string => { const t = icon.title; const plays = PLAYS[icon.interaction]; const time = seconds(icon.durationMs); @@ -371,38 +377,137 @@ export const iconIntro = (icon: Icon): string => { const second = s.length ? `Its static SVGs cover the ${list(s)} states, so the resting icon still shows which one is active.` : INTERACTION_META[icon.interaction].example; - const aka = synonyms(icon).slice(0, 4); + const aka = withAka ? synonyms(icon).slice(0, 4) : []; return [first, second, aka.length ? `Also known as ${list(aka)}.` : ''].filter(Boolean).join(' '); }; export type Faq = { question: string; answer: string }[]; -/** Four questions and answers built from the icon's real data. */ +// The interaction a user may want instead, for the "can it play on…" question. +const ALTERNATIVE: Partial<Record<Icon['interaction'], [Icon['interaction'], string]>> = { + loop: ['hover-loop', 'loop only while hovered'], + 'click-replay': ['hover-replay', 'play on hover'], + hover: ['click-replay', 'play on click'], + 'hover-replay': ['click-replay', 'play on click'], + 'hover-loop': ['loop', 'loop all the time'], +}; + +/** How the interaction behaves at its edges: what the page's facts and code don't say. */ +const behaviourFaq = (icon: Icon): Faq[number] => { + const t = icon.title; + const time = seconds(icon.durationMs); + const touch = pick( + icon, + [ + 'Keyboard focus plays it as well; a tap on a touch screen does not.', + 'Taps on touch screens are ignored, but keyboard focus plays it.', + ], + 'touch' + ); + switch (icon.interaction) { + case 'click-toggle': { + const s = states(icon); + return { + question: s.length + ? `Can my app decide whether ${t} shows ${s[0]} or ${s[1]}?` + : pick(icon, [ + `How do I keep the ${t} icon in sync with my own state?`, + `Can ${t} be a controlled toggle?`, + ]), + answer: `Make it controlled: pass active and flip it in onClick, e.g. active={on} onClick={() => setOn(!on)}. true plays to the last frame and false back to the first; an icon that mounts with active already true starts there without animating, and with reduced motion it jumps instead of playing.`, + }; + } + case 'click-replay': + return { + question: pick(icon, [ + `What happens if ${t} is clicked again before it finishes?`, + `Do fast clicks on ${t} queue up?`, + ]), + answer: `No queue: every click restarts the ${time} run from the first frame. With reduced motion preferred, clicks still reach your onClick but the icon stays still.`, + }; + case 'hover': + return { + question: pick(icon, [ + `What does ${t} do when the pointer leaves halfway?`, + `Does ${t} finish its animation after mouse leave?`, + ]), + answer: `It reverses from the frame it reached back to the start, so a quick pass of the pointer gives a short nudge rather than the full ${time}. ${touch}`, + }; + case 'hover-replay': + return { + question: pick(icon, [ + `Does ${t} restart when the pointer comes back mid-animation?`, + `Why doesn't ${t} restart on every mouse enter?`, + ]), + answer: `A run that has started plays its ${time} to the end, so moving in and out quickly doesn't make it stutter; the next enter after that plays it again. ${touch}`, + }; + case 'hover-loop': + return { + question: pick(icon, [ + `Does ${t} stop as soon as the pointer leaves?`, + `Where does ${t} stop after hover?`, + ]), + answer: `It finishes the cycle it is in (at most ${time}) and then rests on its first frame, so it never freezes mid-motion. ${touch}`, + }; + } + // loop + const blank = icon.preview !== icon.firstFrame; + return { + question: pick(icon, [`Does the ${t} animation ever stop?`, `Can I pause the ${t} loop?`]), + answer: `It pauses by itself while scrolled out of view and stays on its first frame when reduced motion is preferred or autoplay={false} is set${ + blank + ? '. That first frame is empty for this icon, so keep a text label such as "Loading…" next to it' + : '' + }. To show it only while something is pending, render it conditionally.`, + }; +}; + +/** Labels and roles, which differ between buttons, decorations and loaders. */ +const accessibilityFaq = (icon: Icon): Faq[number] => { + const t = icon.title; + if (icon.interaction.startsWith('click')) { + const pressed = icon.interaction === 'click-toggle' ? ' aria-pressed={on}' : ''; + return { + question: pick(icon, [ + `How do I make ${t} an accessible button?`, + `Should the ${t} icon be a <button>?`, + ]), + answer: `Put its events on a real button with the render prop: render={(events, icon) => <button aria-label="${t}"${pressed} {...events}><span {...icon} /></button>}. The animation itself stays aria-hidden${pressed ? ', and aria-pressed tells screen readers which state is on' : ''}.`, + }; + } + if (icon.interaction === 'loop' && icon.category === 'Loading') { + return { + question: `How do screen readers announce the ${t} loader?`, + answer: `They don't unless you pass aria-label="Loading…", which gives it role="img". Often better: keep it hidden and set aria-busy="true" on the region that is loading.`, + }; + } + return { + question: pick(icon, [`Will screen readers read the ${t} icon?`, `Is ${t} decorative?`]), + answer: `By default it is aria-hidden. Pass aria-label="${t}" to announce it as an image, or leave it hidden when the link or button it sits in has its own text.`, + }; +}; + +/** Two or three questions picked by the icon's interaction, category, states and drawing. */ export const iconFaq = (icon: Icon): Faq => { - const interaction = INTERACTIONS[icon.interaction]; - const u = urls(icon); - const kb = (Buffer.byteLength(icon.lottie) / 1024).toFixed(1); - const svgs = u.svg.length - ? ` and ${u.svg.length === 1 ? 'a static SVG' : `${u.svg.length} static SVGs (${list(Object.keys(icon.svgs))})`}` - : ''; - return [ - { - question: `What triggers the ${icon.title} animation?`, - answer: `${interaction.label}: ${lowerFirst(interaction.description)} One cycle takes ${seconds(icon.durationMs)} at ${Number(icon.fps.toFixed(2))} fps.`, - }, - { - question: `Which files and sizes does the ${icon.title} icon come in?`, - answer: `A Lottie JSON animation (${kb} kB, ${icon.width} × ${icon.height} px artboard)${svgs}, also bundled as a ZIP at ${u.zip}. It scales to any size; 24 or 32 px suits most interfaces.`, - }, - { - question: `How do I use the ${icon.title} icon in React?`, - answer: `Install react-useanimations, add ${libImport(icon).replace(/;$/, '')} and render <UseAnimations animation={${icon.exportName}} size={32} />. The icon follows the text color and respects prefers-reduced-motion.`, - }, - { - question: `Can I add it with shadcn, and do I need lottie-web?`, - answer: `Run ${shadcnCommand(icon)} to add the ${icon.component} component. React needs no extra player; outside React, ${icon.requiresFullPlayer ? "this icon needs lottie-web's full build because it uses expressions" : "lottie-web's light build is enough"}.`, - }, - ]; + const faq = [behaviourFaq(icon), accessibilityFaq(icon)]; + const twoTone = icon.lottie.includes('"ty":"fl"') && icon.lottie.includes('"ty":"st"'); + const alternative = ALTERNATIVE[icon.interaction]; + if (twoTone) { + faq.push({ + question: pick(icon, [ + `Can ${icon.title} use two colors?`, + `Can I color the fill of ${icon.title} separately?`, + ]), + answer: `Yes: strokeColor sets the outlines and fillColor the filled parts, e.g. strokeColor="#e5484d" fillColor="#ffd1d1"; both follow the text color by default.`, + }); + } else if (alternative) { + const [interaction, phrase] = alternative; + faq.push({ + question: `Can ${icon.title} ${phrase} instead?`, + answer: `Yes: pass interaction="${interaction}". It then ${lowerFirst(INTERACTIONS[interaction].description)}`, + }); + } + return faq; }; /** One sentence on how `other` differs from `icon`: interaction, duration and states. */ diff --git a/website/src/pages/404.astro b/website/src/pages/404.astro index c27dbfa..07b8911 100644 --- a/website/src/pages/404.astro +++ b/website/src/pages/404.astro @@ -7,7 +7,7 @@ const icon = icons.find((item) => item.name === 'alertTriangle')!; <Layout title="Page not found — useAnimations" description="This page doesn't exist." noindex> <section class="mx-auto max-w-xl px-5 py-32 text-center"> - <span data-useanimations="" class="mx-auto block size-14 text-accent" set:html={icon.firstFrame} /> + <span data-useanimations="" class="mx-auto block size-14 text-accent" set:html={icon.preview} /> <h1 class="mt-8 text-4xl font-semibold tracking-tight">Page not found</h1> <p class="mt-4 text-muted">The page you're looking for doesn't exist or has moved.</p> <div class="mt-8 flex justify-center gap-3"> diff --git a/website/src/pages/categories/index.astro b/website/src/pages/categories/index.astro index 058b62a..a2ce622 100644 --- a/website/src/pages/categories/index.astro +++ b/website/src/pages/categories/index.astro @@ -65,7 +65,7 @@ const jsonLd = { > <span class="flex gap-2 text-fg" aria-hidden="true"> {group.slice(0, 5).map((icon) => ( - <span data-useanimations="" class="block size-8" set:html={icon.firstFrame} /> + <span data-useanimations="" class="block size-8" set:html={icon.preview} /> ))} </span> <span class="mt-5 flex items-baseline justify-between gap-3"> diff --git a/website/src/pages/documentation.astro b/website/src/pages/documentation.astro index b2e166a..257af01 100644 --- a/website/src/pages/documentation.astro +++ b/website/src/pages/documentation.astro @@ -13,7 +13,7 @@ const faq = [ ['Is useAnimations free for commercial projects?', 'Yes. The icons and the React package are MIT-licensed, with no attribution required.'], ['How do I use useAnimations icons in React?', "Install react-useanimations, import UseAnimations and an icon (e.g. react-useanimations/lib/checkmark), then render <UseAnimations animation={checkmark} size={32} />."], ['How do I change the color of an icon?', 'Icons follow the text color. Change it with strokeColor and fillColor, CSS color, or by styling the paths.'], - ['Does it work without React?', 'The package is for React. Every icon can also be downloaded as an SVG or as its Lottie JSON animation.'], + ['Does it work without React?', 'The package is for React. Every icon is also a standard Lottie JSON file that any Lottie player can play (lottie-web, dotLottie, lottie-react-native, lottie-ios, lottie-android, Flutter), and a static SVG. See Use without React and useanimations.com/lottie-icons.'], ]; const jsonLd = { '@context': 'https://schema.org', diff --git a/website/src/pages/icons/[id].astro b/website/src/pages/icons/[id].astro index dc8f17a..d70a560 100644 --- a/website/src/pages/icons/[id].astro +++ b/website/src/pages/icons/[id].astro @@ -169,7 +169,7 @@ const jsonLd = { <div class="min-w-0"> <h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{icon.title}</h1> <p class="mt-2 text-lg text-subtle">Animated {icon.title.toLowerCase()} icon</p> - <p class="mt-4 max-w-xl text-lg text-muted">{iconIntro(icon)}</p> + <p class="mt-4 max-w-xl text-lg text-muted">{iconIntro(icon, { aka: false })}</p> { aka.length > 0 && ( <div class="mt-6 flex flex-wrap items-center gap-2 text-sm"> @@ -272,7 +272,7 @@ const jsonLd = { href={`/icons/${other.id}`} class="flex items-center gap-4 rounded-2xl border border-line bg-surface p-4 transition hover:border-line-strong" > - <span data-useanimations="" class="block size-10 shrink-0 text-fg" set:html={other.firstFrame} /> + <span data-useanimations="" class="block size-10 shrink-0 text-fg" set:html={other.preview} /> <span> <span class="block text-sm font-medium text-fg">{other.title}</span> <span class="mt-0.5 block text-sm text-muted">{variantNote(icon, other)}</span> @@ -320,7 +320,7 @@ const jsonLd = { href={`/icons/${other.id}`} class="flex aspect-[1/1.08] flex-col items-center justify-center gap-3 rounded-2xl border border-line bg-surface text-[13px] text-muted transition hover:border-line-strong hover:text-fg" > - <span data-useanimations="" class="block size-10 text-fg" set:html={other.firstFrame} /> + <span data-useanimations="" class="block size-10 text-fg" set:html={other.preview} /> {other.title} </a> </li> diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 3b9d8f7..94cb889 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -33,11 +33,11 @@ import { CATEGORY_META } from '../lib/taxonomy'; // HERO FIELD: icons whose animation starts and ends at rest, taken from every category in turn. const replayable = new Set(['hover-replay', 'click-replay', 'loop', 'hover-loop']); -// Some icons start from an empty frame; the field shows only icons that are visible at rest. -const drawn = (svg: string) => /\sd="(?!M0 0")[^"]+"/.test(svg); +// Some icons start from an empty frame (their still is a later frame); the field shows only icons +// that are visible at rest, as its live players start on the first frame. const pools = CATEGORIES.map((category) => icons.filter( - (icon) => icon.category === category && replayable.has(icon.interaction) && drawn(icon.firstFrame) + (icon) => icon.category === category && replayable.has(icon.interaction) && icon.preview === icon.firstFrame ) ); const field: typeof icons = []; @@ -415,10 +415,11 @@ const jsonLd = { data-category={icon.category} data-interaction={icon.interaction} data-keywords={searchText(icon)} + data-blank-start={icon.preview === icon.firstFrame ? undefined : ''} > <IconCard icon={{ id: icon.id, title: icon.title, interaction: icon.interaction }} - firstFrame={icon.firstFrame} + still={icon.preview} /> </li> ))} diff --git a/website/src/pages/interactions/[slug].astro b/website/src/pages/interactions/[slug].astro index cab76c0..d32e00f 100644 --- a/website/src/pages/interactions/[slug].astro +++ b/website/src/pages/interactions/[slug].astro @@ -158,7 +158,7 @@ const jsonLd = { </header> <InteractionDemo client:visible - lottie={ex.lottie} + id={ex.id} interaction={interaction} title={ex.title} description={info.description} diff --git a/website/src/pages/interactions/index.astro b/website/src/pages/interactions/index.astro index a3aaeda..4d197c5 100644 --- a/website/src/pages/interactions/index.astro +++ b/website/src/pages/interactions/index.astro @@ -66,7 +66,7 @@ const jsonLd = { > <span class="flex gap-2 text-fg" aria-hidden="true"> {group.slice(0, 5).map((icon) => ( - <span data-useanimations="" class="block size-8" set:html={icon.firstFrame} /> + <span data-useanimations="" class="block size-8" set:html={icon.preview} /> ))} </span> <span class="mt-5 font-mono text-xs text-accent">interaction="{interaction}"</span> diff --git a/website/src/pages/og.astro b/website/src/pages/og.astro index 3c8cc7d..84b3fc1 100644 --- a/website/src/pages/og.astro +++ b/website/src/pages/og.astro @@ -35,7 +35,7 @@ const colors = ['#e5484d', '#f59e0b', '#12a594', '#7b8bff', '#c084fc', '#eef0f6' class="flex aspect-square items-center justify-center rounded-3xl border border-line bg-surface/80" style={{ color: i % 3 === 0 ? colors[(i / 3) % colors.length] : 'var(--muted)' }} > - <span data-useanimations="" class="block size-11" set:html={icon!.firstFrame} /> + <span data-useanimations="" class="block size-11" set:html={icon!.preview} /> </span> )) } diff --git a/website/src/pages/zh.astro b/website/src/pages/zh.astro index 7a98735..8e7685e 100644 --- a/website/src/pages/zh.astro +++ b/website/src/pages/zh.astro @@ -146,7 +146,7 @@ const link = 'underline decoration-line-strong underline-offset-4 hover:decorati { preview.map((icon) => ( <span class="flex size-16 items-center justify-center rounded-2xl border border-line bg-surface"> - <span data-useanimations="" class="block size-8" set:html={icon.firstFrame} /> + <span data-useanimations="" class="block size-8" set:html={icon.preview} /> </span> )) } diff --git a/website/src/scripts/browser.ts b/website/src/scripts/browser.ts index 4b43fda..f343c98 100644 --- a/website/src/scripts/browser.ts +++ b/website/src/scripts/browser.ts @@ -137,6 +137,9 @@ if (browser) { if (!entry.isIntersecting) continue; const card = entry.target as HTMLElement; observer.unobserve(card); + // Loops that start on an empty frame keep their still while they can't play. + if ('blankStart' in card.dataset && matchMedia('(prefers-reduced-motion: reduce)').matches) + continue; const icon: CardIcon = { id: card.dataset.id!, title: card.dataset.title!,