From e789bef26c651bff26dfb33b5b8884b99fbf634d Mon Sep 17 00:00:00 2001 From: Tuan Phung <16448321+tuanphungcz@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:41:06 +0200 Subject: [PATCH] Website: animated icons across the whole site, not only the home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Icon stills (category, interaction, icon index, Lottie and shadcn grids, related icons, other versions, category and interaction cards) play on hover or keyboard focus. Each icon's Lottie JSON loads on the first hover (IconStill.astro, scripts/hover-icons.ts, HoverIcon.tsx); icons whose first frame is empty keep their still. - Copy buttons: the copy icon turns into a checkmark after a successful copy (install command, code blocks, icon dialog, documentation). copy.ts sets data-copied only on success. - Icon dialog: the close button is a plus that turns into an X on every open; download links get a download icon, "Icon page" an arrow. - Icon pages: breadcrumb chevrons, download icons, plus/X FAQ, arrows on "All … icons" and previous/next. - Arrows on the icon index category links, the Lottie and shadcn "All icons" links and the home FAQ links; the text arrows are gone from those labels in all 13 languages. - Language switcher and the documentation's "On this page" get a chevron, the language banner's close a plus/X, footer links that leave the site an external-link icon, the 404 page an alert that plays in and home/grid icons. - ControlIcon: new modes `screen` (plays in once on screen) and `copied`. --- website/src/components/CodeBlock.astro | 2 +- website/src/components/ControlIcon.tsx | 155 ++++++++++++++---- website/src/components/Footer.astro | 4 +- website/src/components/HoverIcon.tsx | 69 ++++++++ website/src/components/IconDialog.tsx | 28 ++-- website/src/components/IconGrid.astro | 3 +- website/src/components/IconStill.astro | 25 +++ website/src/components/InstallCommand.astro | 2 +- website/src/components/LanguageBanner.astro | 6 +- website/src/components/LanguageSwitcher.astro | 2 + website/src/i18n/messages/cs/islands.ts | 2 +- website/src/i18n/messages/cs/pages.ts | 6 +- website/src/i18n/messages/de/islands.ts | 2 +- website/src/i18n/messages/de/pages.ts | 6 +- website/src/i18n/messages/en/islands.ts | 2 +- website/src/i18n/messages/en/pages.ts | 6 +- website/src/i18n/messages/es/islands.ts | 2 +- website/src/i18n/messages/es/pages.ts | 6 +- website/src/i18n/messages/fr/islands.ts | 2 +- website/src/i18n/messages/fr/pages.ts | 6 +- website/src/i18n/messages/id/islands.ts | 2 +- website/src/i18n/messages/id/pages.ts | 6 +- website/src/i18n/messages/ja/islands.ts | 2 +- website/src/i18n/messages/ja/pages.ts | 6 +- website/src/i18n/messages/ko/islands.ts | 2 +- website/src/i18n/messages/ko/pages.ts | 6 +- website/src/i18n/messages/pt-br/islands.ts | 2 +- website/src/i18n/messages/pt-br/pages.ts | 6 +- website/src/i18n/messages/ru/islands.ts | 2 +- website/src/i18n/messages/ru/pages.ts | 6 +- website/src/i18n/messages/tr/islands.ts | 2 +- website/src/i18n/messages/tr/pages.ts | 6 +- website/src/i18n/messages/vi/islands.ts | 2 +- website/src/i18n/messages/vi/pages.ts | 6 +- website/src/i18n/messages/zh/islands.ts | 2 +- website/src/i18n/messages/zh/pages.ts | 6 +- website/src/pages/404.astro | 14 +- website/src/pages/icons/[id].astro | 50 ++++-- website/src/scripts/copy.ts | 9 +- website/src/scripts/hover-icons.ts | 34 ++++ website/src/views/CategoryIndexPage.astro | 3 +- website/src/views/DocumentationPage.astro | 18 +- website/src/views/HomePage.astro | 3 +- website/src/views/IconIndexPage.astro | 4 +- website/src/views/InteractionIndexPage.astro | 3 +- website/src/views/LottieIconsPage.astro | 3 +- website/src/views/ShadcnIconsPage.astro | 3 +- 47 files changed, 405 insertions(+), 139 deletions(-) create mode 100644 website/src/components/HoverIcon.tsx create mode 100644 website/src/components/IconStill.astro create mode 100644 website/src/scripts/hover-icons.ts diff --git a/website/src/components/CodeBlock.astro b/website/src/components/CodeBlock.astro index 304d9c6..b3a6e32 100644 --- a/website/src/components/CodeBlock.astro +++ b/website/src/components/CodeBlock.astro @@ -27,6 +27,6 @@ const html = await highlight(code, lang); data-track="copy" {...trackData} class="absolute top-2 right-2 inline-flex items-center gap-1 rounded-lg border border-line bg-surface px-2 py-1 text-xs text-muted opacity-0 transition group-hover/code:opacity-100 focus:opacity-100 data-[copied]:text-accent" - >{t(locale).common.copy}{t(locale).common.copy} diff --git a/website/src/components/ControlIcon.tsx b/website/src/components/ControlIcon.tsx index 6c2f526..72faead 100644 --- a/website/src/components/ControlIcon.tsx +++ b/website/src/components/ControlIcon.tsx @@ -1,58 +1,58 @@ import { useEffect, useState } from 'react'; import UseAnimations from 'react-useanimations'; +import alertTriangle from '../../../icons/alertTriangle.json'; +import arrowLeft from '../../../icons/arrowLeft.json'; import arrowRight from '../../../icons/arrowRight.json'; +import checkmark from '../../../icons/checkmark.json'; import chevronDown from '../../../icons/chevronDown.json'; +import chevronRight from '../../../icons/chevronRight.json'; import copy from '../../../icons/copy.json'; import cpu from '../../../icons/cpu.json'; +import download from '../../../icons/download.json'; import droplet from '../../../icons/droplet.json'; +import externalLink from '../../../icons/externalLink.json'; +import grid from '../../../icons/grid.json'; +import home from '../../../icons/home.json'; import packageIcon from '../../../icons/package.json'; import plusToX from '../../../icons/plusToX.json'; import server from '../../../icons/server.json'; import users from '../../../icons/users.json'; const ICONS = { + alertTriangle, + arrowLeft, arrowRight, + checkmark, chevronDown, + chevronRight, copy, cpu, + download, droplet, + externalLink, + grid, + home, package: packageIcon, plusToX, server, users, }; -/** - * An icon inside Astro markup, played by the element around it, which isn't React's. So the icon - * is a toggle driven from that element's events: - * - `hover`: forward while its closest link, button, label or `[data-icon-trigger]` is hovered or - * keyboard-focused, back on leave; - * - `open`: at its end state while its closest `
` is open (e.g. plus to X). - */ -export default function ControlIcon({ - icon, - on = 'hover', - size = 16, - className, -}: { - icon: keyof typeof ICONS; - on?: 'hover' | 'open'; - size?: number; - className?: string; -}) { - const [span, setSpan] = useState(null); - const [active, setActive] = useState(false); +type IconName = keyof typeof ICONS; +/** Whether the element around `wrapper` hovers, has keyboard focus, or (`open`) is an open `
`. */ +function useTrigger(wrapper: HTMLElement | null, on: 'hover' | 'open') { + const [active, setActive] = useState(false); useEffect(() => { if (on === 'open') { - const details = span?.closest('details'); + const details = wrapper?.closest('details'); if (!details) return undefined; const sync = () => setActive(details.open); sync(); details.addEventListener('toggle', sync); return () => details.removeEventListener('toggle', sync); } - const target = span?.closest('a, button, label, [data-icon-trigger]'); + const target = wrapper?.closest('a, button, label, [data-icon-trigger]'); if (!target) return undefined; // Touch devices fire pointer events on tap; only a mouse hovers. const enter = (event: PointerEvent) => event.pointerType === 'mouse' && setActive(true); @@ -70,19 +70,108 @@ export default function ControlIcon({ return () => { for (const [type, listener] of events) target.removeEventListener(type, listener); }; - }, [span, on]); + }, [wrapper, on]); + return active; +} + +/** Whether the icon in `wrapper` is on screen; not while a closed dialog or a hidden banner holds it. */ +function useOnScreen(wrapper: HTMLElement | null) { + const [visible, setVisible] = useState(false); + useEffect(() => { + const icon = wrapper?.firstElementChild; + if (!icon) return undefined; + const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); + observer.observe(icon); + return () => observer.disconnect(); + }, [wrapper]); + return visible; +} + +/** Whether the copy button around `wrapper` reports a copy (scripts/copy.ts sets `data-copied`). */ +function useCopied(wrapper: HTMLElement | null, enabled: boolean) { + const [copied, setCopied] = useState(false); + useEffect(() => { + const button = enabled && wrapper?.closest('[data-copy], [data-copy-from]'); + if (!button) return undefined; + const observer = new MutationObserver(() => setCopied(button.hasAttribute('data-copied'))); + observer.observe(button, { attributeFilter: ['data-copied'] }); + return () => observer.disconnect(); + }, [wrapper, enabled]); + return copied; +} + +type Look = { size: number; className?: string }; +/** The icon as a toggle: forward to its end state while `active`, back when not. */ +const Toggle = ({ icon, active, size, className }: Look & { icon: IconName; active: boolean }) => ( + } + /> +); + +/** Plays in as soon as it's on screen. */ +function Arriving({ icon, ...look }: Look & { icon: IconName }) { + const [wrapper, setWrapper] = useState(null); + const visible = useOnScreen(wrapper); + return ( + + + + ); +} + +/** + * An icon inside Astro markup, played by the element around it, which isn't React's. So the icon + * is a toggle driven from that element's events: + * - `hover`: forward while its closest link, button, label or `[data-icon-trigger]` is hovered or + * keyboard-focused, back on leave; + * - `open`: at its end state while its closest `
` is open (e.g. plus to X); + * - `screen`: at its end state while it's on screen, so it plays in on load or when the dialog or + * banner around it opens. + * + * With `copied`, a copy button around it shows that icon, played in, while it reports a copy. + */ +export default function ControlIcon({ + icon, + on = 'hover', + copied, + size = 16, + className, +}: { + icon: IconName; + on?: 'hover' | 'open' | 'screen'; + copied?: IconName; + size?: number; + className?: string; +}) { + const look = { size, className }; + return on === 'screen' ? ( + + ) : ( + + ); +} + +function Triggered({ + icon, + on, + copied, + ...look +}: Look & { icon: IconName; on: 'hover' | 'open'; copied?: IconName }) { + const [wrapper, setWrapper] = useState(null); + const active = useTrigger(wrapper, on); + const showCopied = useCopied(wrapper, copied !== undefined); return ( - ( - - - + + {copied && showCopied ? ( + + ) : ( + )} - /> + ); } diff --git a/website/src/components/Footer.astro b/website/src/components/Footer.astro index bf36c5e..ec62f18 100644 --- a/website/src/components/Footer.astro +++ b/website/src/components/Footer.astro @@ -1,4 +1,5 @@ --- +import ControlIcon from './ControlIcon'; import Logo from './Logo.astro'; import { BUILT, EN, type Locale, localize, t } from '../i18n'; import { footer } from '../lib/site'; @@ -28,8 +29,9 @@ const languages = BUILT.map((l) => ({ locale: l, href: alternates?.[l.hreflang]
    {column.links.map((link) => (
  • - + {link.label} + {link.href.startsWith('https:') && }
  • ))} diff --git a/website/src/components/HoverIcon.tsx b/website/src/components/HoverIcon.tsx new file mode 100644 index 0000000..22ff140 --- /dev/null +++ b/website/src/components/HoverIcon.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; +import UseAnimations, { type EventProps, type Interaction } from 'react-useanimations'; + +// Inside a link a click navigates, so every icon plays on hover, in its closest hover form. +const ON_HOVER: Record = { + hover: 'hover', + 'hover-loop': 'hover-loop', + 'hover-replay': 'hover-replay', + loop: 'hover-loop', + 'click-replay': 'hover-replay', + 'click-toggle': 'hover', +}; + +/** + * An icon that plays while `trigger`, a link in Astro markup (not React's), is hovered or has + * keyboard focus. It mounts on the first hover (scripts/hover-icons.ts), so it starts right away + * when that hover is still going on. + */ +export default function HoverIcon({ + animationData, + interaction, + trigger, +}: { + animationData: object; + interaction: Interaction; + trigger: HTMLElement; +}) { + const handlers = useRef(null); + const [wrapper, setWrapper] = useState(null); + + useEffect(() => { + if (!wrapper) return undefined; + // The component's own hover and focus handlers, fed from the trigger's native events. + const call = (name: 'onMouseEnter' | 'onMouseLeave' | 'onFocus' | 'onBlur') => + handlers.current?.[name]({ defaultPrevented: false, currentTarget: trigger } as never); + // Touch devices fire pointer events on tap; only a mouse hovers. + const enter = (event: PointerEvent) => event.pointerType === 'mouse' && call('onMouseEnter'); + const leave = (event: PointerEvent) => event.pointerType === 'mouse' && call('onMouseLeave'); + const focus = () => call('onFocus'); + const blur = () => call('onBlur'); + const events = [ + ['pointerenter', enter], + ['pointerleave', leave], + ['focusin', focus], + ['focusout', blur], + ] as [string, EventListener][]; + for (const [type, listener] of events) trigger.addEventListener(type, listener); + if (trigger.matches(':hover')) call('onMouseEnter'); + else if (trigger.matches(':focus-visible')) call('onFocus'); + return () => { + for (const [type, listener] of events) trigger.removeEventListener(type, listener); + }; + }, [wrapper, trigger]); + + return ( + { + handlers.current = eventProps; + return ( + + + + ); + }} + /> + ); +} diff --git a/website/src/components/IconDialog.tsx b/website/src/components/IconDialog.tsx index 0d04f38..778dd1a 100644 --- a/website/src/components/IconDialog.tsx +++ b/website/src/components/IconDialog.tsx @@ -3,6 +3,7 @@ import UseAnimations, { type Interaction } from 'react-useanimations'; import { islands } from '../i18n/messages/en/islands'; import { loadIconData } from '../lib/icon-data'; import { tabFromKey } from '../lib/tabs'; +import ControlIcon from './ControlIcon'; import { openIcon } from './IconCard'; export type DialogIcon = { @@ -59,8 +60,9 @@ function Code({ data-track="copy" data-track-icon={icon} data-track-code={code} - className="absolute top-2 right-2 rounded-lg border border-line bg-surface px-2 py-1 text-xs text-muted opacity-0 transition group-hover/code:opacity-100 focus:opacity-100 data-[copied]:text-accent" + className="absolute top-2 right-2 inline-flex items-center gap-1 rounded-lg border border-line bg-surface px-2 py-1 text-xs text-muted opacity-0 transition group-hover/code:opacity-100 focus:opacity-100 data-[copied]:text-accent" > + {label} @@ -200,16 +202,8 @@ export default function IconDialog({ aria-label={s.close} className="rounded-full p-2 text-muted hover:bg-surface-2 hover:text-fg" > - - - + {/* A plus that turns into an X each time the dialog opens. */} +
    + ZIP + Lottie JSON {icon.svg.map((state) => ( @@ -312,8 +308,9 @@ export default function IconDialog({ data-track-icon={icon.id} data-track-format="svg" data-track-file={`${state}.svg`} - className="rounded-full border border-line-strong px-4 py-2 text-sm hover:bg-surface-2" + className="inline-flex items-center gap-1.5 rounded-full border border-line-strong px-4 py-2 text-sm hover:bg-surface-2" > + {icon.svg.length > 1 ? `${state}.svg` : 'SVG'} ))} @@ -321,9 +318,10 @@ export default function IconDialog({ href={`/icons/${icon.id}`} data-track="icon-page" data-track-icon={icon.id} - className="ml-auto self-center text-sm text-muted underline-offset-4 hover:text-fg hover:underline" + className="ml-auto inline-flex items-center gap-1 self-center text-sm text-muted underline-offset-4 hover:text-fg hover:underline" > {s.iconPage} +
    diff --git a/website/src/components/IconGrid.astro b/website/src/components/IconGrid.astro index 024c9f0..968f865 100644 --- a/website/src/components/IconGrid.astro +++ b/website/src/components/IconGrid.astro @@ -1,5 +1,6 @@ --- import { EN, type Locale } from '../i18n'; +import IconStill from './IconStill.astro'; import type { Icon } from '../lib/icons'; interface Props { @@ -24,7 +25,7 @@ const lang = locale === EN ? undefined : 'en'; hreflang={lang} 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" > - + {lang ? {icon.title} : icon.title} diff --git a/website/src/components/IconStill.astro b/website/src/components/IconStill.astro new file mode 100644 index 0000000..49be5af --- /dev/null +++ b/website/src/components/IconStill.astro @@ -0,0 +1,25 @@ +--- +import type { Icon } from '../lib/icons'; + +interface Props { + icon: Icon; + class?: string; +} + +// An icon's still (lib/icons.ts) inside a link; it plays once the link is hovered or focused +// (scripts/hover-icons.ts). +const { icon, class: className } = Astro.props; +--- + + + + diff --git a/website/src/components/InstallCommand.astro b/website/src/components/InstallCommand.astro index dbb9cbe..dce1bce 100644 --- a/website/src/components/InstallCommand.astro +++ b/website/src/components/InstallCommand.astro @@ -25,7 +25,7 @@ const { command = 'npm i react-useanimations', class: className, locale = EN } = {command} - + {t(locale).common.copy} diff --git a/website/src/components/LanguageBanner.astro b/website/src/components/LanguageBanner.astro index 4aaa990..0bb29d0 100644 --- a/website/src/components/LanguageBanner.astro +++ b/website/src/components/LanguageBanner.astro @@ -3,6 +3,7 @@ // redirects (Google asks sites not to, and it would hide the other versions from crawlers and // people); it only suggests, in that language, and remembers a dismissal. import { BUILT, type Locale, t } from '../i18n'; +import ControlIcon from './ControlIcon'; interface Props { locale: Locale; @@ -25,8 +26,9 @@ const offers = BUILT.filter((l) => l !== locale && alternates[l.hreflang]).map(( >

    - diff --git a/website/src/views/HomePage.astro b/website/src/views/HomePage.astro index ab775d4..86d2c2c 100644 --- a/website/src/views/HomePage.astro +++ b/website/src/views/HomePage.astro @@ -639,7 +639,8 @@ const jsonLd = { <> {' '} - {item.link.label} → + {item.link.label} + )} diff --git a/website/src/views/IconIndexPage.astro b/website/src/views/IconIndexPage.astro index 5bb63dc..810396d 100644 --- a/website/src/views/IconIndexPage.astro +++ b/website/src/views/IconIndexPage.astro @@ -1,5 +1,6 @@ --- // The index of every icon page (/icons) in any language, grouped like the home page; plain links, no scripts. +import ControlIcon from '../components/ControlIcon'; import Breadcrumbs from '../components/Breadcrumbs.astro'; import IconGrid from '../components/IconGrid.astro'; import { alternatesOf, type Locale, localize, t } from '../i18n'; @@ -80,8 +81,9 @@ const jsonLd = {

    {m.taxonomy.categories[meta.name].name}

    - + {p.categoryLink(group.length, m.taxonomy.categories[meta.name].name)} + diff --git a/website/src/views/InteractionIndexPage.astro b/website/src/views/InteractionIndexPage.astro index b7417fd..c66f719 100644 --- a/website/src/views/InteractionIndexPage.astro +++ b/website/src/views/InteractionIndexPage.astro @@ -1,6 +1,7 @@ --- // The list of interactions (/interactions) in any language; src/pages routes render it. import Breadcrumbs from '../components/Breadcrumbs.astro'; +import IconStill from '../components/IconStill.astro'; import { alternatesOf, type Locale, localize, t } from '../i18n'; import Layout from '../layouts/Layout.astro'; import { breadcrumbNode, interactionPath, interactions, localDescription, localTitle } from '../lib/hubs'; @@ -70,7 +71,7 @@ const jsonLd = { >