Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
21354f7
sync prod to main (#484)
sudhashrestha Jun 3, 2026
134c631
docs: add new badge labels to recent components (#485)
hari Jun 4, 2026
7a4fdc1
docs: add animated SMIL logo to README (#486)
sudhashrestha Jun 4, 2026
d1c7ffc
feat(background): shooting stars + animated beam refresh (#487)
hari Jun 4, 2026
42a2a30
chore(og): add og generation and backfilll old ones (#489)
sudhashrestha Jun 7, 2026
11d6c67
chore(site): point default OG image at 2026 R2 asset (#490)
sudhashrestha Jun 7, 2026
5e0a651
Merge branch 'production' into main
hari Jun 7, 2026
ba82518
feat(landing): use new header style (#494)
sudhashrestha Jun 9, 2026
09c544a
feat(resources): add curated design galleries page (#493)
sudhashrestha Jun 9, 2026
307c0c7
feat(landing): update footer (#492)
sudhashrestha Jun 9, 2026
b7fe650
fix(theme): footer theme, remove new labels (#495)
sudhashrestha Jun 9, 2026
7c2cd84
feat(docs): add dedicated index & components page (#496)
sudhashrestha Jun 11, 2026
d0f986f
feat(docs): improve sidebar nav with filter and scroll fades (#497)
sudhashrestha Jun 11, 2026
7649472
feat(site): switch display type to Instrument Sans (#499)
sudhashrestha Jun 11, 2026
df72b77
fix(text): glitch, roll text improvements (#498)
sudhashrestha Jun 11, 2026
f6836c1
fix(skeleton): restore category glyph hover animations (#500)
sudhashrestha Jun 11, 2026
f9e15dd
fix(docs): text animator demo and co located css (#501)
sudhashrestha Jun 11, 2026
9422a87
Merge branch 'production' into main
hari Jun 11, 2026
50f4c9d
fix(registry): bundle co-located css from primary component imports
sudhashrestha Jun 11, 2026
b304337
fix(docs): prevent Safari sidebar scroll fade black band
sudhashrestha Jun 12, 2026
dfd68cb
fix(perf): card spread layout & safari perf (#504)
sudhashrestha Jun 12, 2026
47b3438
fix(docs): restore table of contents layout and parsing (#505)
sudhashrestha Jun 12, 2026
b38a676
refactor(card-stack): bare-bones API (#506)
sudhashrestha Jun 12, 2026
1747e1c
fix(docs): restore category grids after TOC layout and trim ad slot (…
sudhashrestha Jun 12, 2026
c00e5dc
Merge branch 'production' into main
hari Jun 12, 2026
ca96973
chore: run react-doctor fixes (#510)
sudhashrestha Jun 13, 2026
de9aabb
refactor(flip-card): composable Front/Back API with motion-safe code …
sudhashrestha Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/react-doctor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: React Doctor

on:
# Scans the PR's changed files and posts a sticky summary comment listing only the new issues introduced relative to the merge base of the target branch.
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]

permissions:
contents: read
pull-requests: write
issues: write
statuses: write

# Cancels any in-flight scan for the same PR the moment a new commit arrives, so reviewers only ever see the latest run.
concurrency:
group: react-doctor-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
react-doctor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: millionco/react-doctor@v2
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ public/llms.txt
public/llms-full.txt
_internal/
.superset/

# react-doctor local report
react-doctor-report.json
53 changes: 26 additions & 27 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,54 +21,52 @@ Animata is a free, open-source library of animated React components built with N

## Changelog rule — ALWAYS update this

The changelog lives at `content/docs/changelog/` — one MDX file per month plus an index.
Release notes use **four tiers**. Full process, examples, and checklist: [contributing changelog](/docs/contributing/changelog).

### Structure
| Tier | Where | Length |
| --- | --- | --- |
| 1 — Site overview | `content/docs/changelog/index.mdx` | One line per month |
| 2 — Monthly release | `content/docs/changelog/YYYY-MM.mdx` | Paragraph + component cards |
| 3 — Category index | `content/docs/{category}/index.mdx` | Table: date · component · change |
| 4 — Component doc | `content/docs/{category}/{name}.mdx` → `## Changelog` | Dated bullets, most detail |

```text
content/docs/changelog/
index.mdx # overview page — shows 2 most recent months + table linking all others
2026-04.mdx # April 2026 (newest first)
2026-03.mdx
...
index.mdx # tier 1 — recent months + table of all months
YYYY-MM.mdx # tier 2 — one file per month (newest first)
content/docs/{category}/
index.mdx # tier 3 — Recent changes table
{name}.mdx # tier 4 — ## Changelog at bottom
```

### When adding a new month

1. Create `content/docs/changelog/YYYY-MM.mdx` with this frontmatter:
```yaml
---
title: Month YYYY
description: One-line summary of the main themes.
date: YYYY-MM-DD
---
```
2. Write the content as prose, not bullet lists. Each new component gets a short paragraph — what it is, when you'd use it, anything notable about the implementation.
3. Add the new month to `config/docs.ts` under the Changelog items array (newest first).
4. Update `content/docs/changelog/index.mdx`: promote the new month to "Recent releases" and move the oldest of the current two into the table.
1. Create `content/docs/changelog/YYYY-MM.mdx` with frontmatter (`title`, `description`, `date`).
2. Write tier-2 prose and `<ChangeLogEntry>` blocks — see [contributing changelog](/docs/contributing/changelog).
3. Add the month to `config/docs.ts` under Changelog (newest first).
4. Update tier 1 in `changelog/index.mdx`: promote to **Recent releases**, demote oldest to **All releases**.

### When updating an existing month

Add to the relevant `YYYY-MM.mdx` file. No need to touch the index unless the summary line there is now misleading.
Add to the relevant `YYYY-MM.mdx`. Update tier 3 (category index table) and tier 4 (component `## Changelog`). Touch tier 1 only if the month summary line is now misleading.

### What warrants a changelog entry

| Action | Update changelog? |
|---|---|
| New component | Yes — new section in the current month file |
| Landing/docs UI update | Yes — brief paragraph |
| Major dependency upgrade | Yes — explain what changed and why it matters |
| User-visible bug fix | Yes — one sentence is fine |
| Action | Update? |
| --- | --- |
| New component | All four tiers |
| User-visible update or fix | Tiers 2–4; tier 1 if it shapes the month |
| Landing/docs UI (site-wide) | Tiers 1–2 |
| Major dependency upgrade | Tiers 1–2 |
| Dependency patch bumps | No |
| CI / GitHub Actions only | No |
| Typo fix in docs | No |

### Writing style

- Past tense. "Added X" not "We're excited to announce X."
- No emojis, no prefix symbols (no ✨ 🛠 🐛).
- Each component gets a description of what it does and when you'd use it — not just its name.
- If a fix has a root cause worth knowing, say so briefly.
- No emojis, no prefix symbols.
- Detail increases down the tiers — one line at the top, bullets at the component doc.

## File map (quick reference)

Expand Down Expand Up @@ -97,6 +95,7 @@ content/docs/
contributing/
category-glyphs.mdx # Category tile SVG guide (published)
category-glyphs-spec.md # Full spec — keep in sync; not under animata/
changelog.mdx # Four-tier release notes process
config/
docs.ts # Sidebar nav config — register new component categories here
styles/globals.css # Tailwind v4 theme tokens
Expand Down
19 changes: 10 additions & 9 deletions animata/background/interactive-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";

import { cn } from "@/lib/utils";

function useGridLayout() {
const containerRef = useRef<HTMLDivElement>(null);
const [layout, setLayout] = useState({ vertical: 0, horizontal: 0 });

useEffect(() => {
useLayoutEffect(() => {
const updateLayout = () => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) {
Expand Down Expand Up @@ -72,7 +72,7 @@ function Grid() {
const [active, setActive] = useState(0);
const timerRef = useRef<NodeJS.Timeout>(undefined);

const onMouseEnter = useCallback(() => {
const onActivate = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
Expand Down Expand Up @@ -108,13 +108,15 @@ function Grid() {

const shouldHighlight = active - x === x || active - y === y;
return (
<div
<button
type="button"
aria-label="Highlight grid path"
key={`${x}-${y}`}
style={{
transform: `translate(${xPos}px, ${y}px)`,
}}
onMouseEnter={onMouseEnter}
onClick={onMouseEnter}
onMouseEnter={onActivate}
onFocus={onActivate}
className={boxClassName}
>
<div
Expand All @@ -129,15 +131,14 @@ function Grid() {
},
)}
/>
</div>
</button>
);
});
}, [squares, horizontal, active, onMouseEnter]);
}, [squares, horizontal, active, onActivate]);

return (
<div
ref={containerRef}
onClick={onMouseEnter}
className={cn("absolute inset-0 h-full max-h-96 w-full", {
"top-1/4": vertical > 96 * 4, // 96 * 4 is the height of the grid
})}
Expand Down
11 changes: 8 additions & 3 deletions animata/background/shooting-stars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,16 +120,21 @@ export default function ShootingStars({
setStars(Array.from({ length: starCount }, () => makeStar(nextId.current++, w, h, true)));
};

seed();
const seedFrame = requestAnimationFrame(() => seed());
const ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
dims.current = { w: r.width, h: r.height };
// If we mounted at 0×0 (hidden tab, collapsed/lazy panel), seed once the
// container actually has a size — otherwise the sky stays empty.
if (!seeded && r.width && r.height) seed();
if (!seeded && r.width && r.height) {
requestAnimationFrame(() => seed());
}
});
ro.observe(el);
return () => ro.disconnect();
return () => {
cancelAnimationFrame(seedFrame);
ro.disconnect();
};
}, []);

const recycle = useCallback((id: number) => {
Expand Down
1 change: 1 addition & 0 deletions animata/button/ai-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export default function AiButton() {

return (
<button
type="button"
className="group/ai relative my-8 rounded-full bg-linear-to-r from-blue-300/30 via-blue-500/30 via-40% to-purple-500/30 p-1 text-white transition-transform hover:scale-110 active:scale-105"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
Expand Down
5 changes: 4 additions & 1 deletion animata/button/algolia-blue-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import "./algolia-blue-button.css";

export default function AlgoliaBlueButton() {
return (
<button className="algolia-blue-btn relative box-border inline-flex h-12 cursor-pointer touch-manipulation items-center justify-center overflow-hidden whitespace-nowrap rounded-md border-0 bg-linear-to-r from-sky-500 to-blue-600 px-4 font-mono leading-none text-white no-underline transition-transform duration-150 ease-in-out hover:-translate-y-0.5 active:translate-y-0.5">
<button
type="button"
className="algolia-blue-btn relative box-border inline-flex h-12 cursor-pointer touch-manipulation items-center justify-center overflow-hidden whitespace-nowrap rounded-md border-0 bg-linear-to-r from-sky-500 to-blue-600 px-4 font-mono leading-none text-white no-underline transition-transform duration-150 ease-in-out hover:-translate-y-0.5 active:translate-y-0.5"
>
Algolia Blue
</button>
);
Expand Down
5 changes: 4 additions & 1 deletion animata/button/algolia-white-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import "./algolia-white-button.css";

export default function AlgoliaWhiteButton() {
return (
<button className="algolia-white-btn inline-flex h-12 cursor-pointer touch-manipulation items-center justify-center overflow-hidden whitespace-nowrap rounded border-0 bg-[#FCFCFD] px-4 font-mono leading-none text-slate-800 no-underline transition-transform duration-150 ease-in-out hover:-translate-y-0.5 active:translate-y-0.5">
<button
type="button"
className="algolia-white-btn inline-flex h-12 cursor-pointer touch-manipulation items-center justify-center overflow-hidden whitespace-nowrap rounded border-0 bg-[#FCFCFD] px-4 font-mono leading-none text-slate-800 no-underline transition-transform duration-150 ease-in-out hover:-translate-y-0.5 active:translate-y-0.5"
>
Algolia White
</button>
);
Expand Down
1 change: 1 addition & 0 deletions animata/button/arrow-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default function ArrowButton({
}: ArrowButtonProps) {
return (
<button
type="button"
style={{ borderColor: borderColor }}
{...props}
className={cn(
Expand Down
5 changes: 4 additions & 1 deletion animata/button/duolingo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export default function Duolingo() {
return (
<button className="box-border inline-block h-11 cursor-pointer touch-manipulation whitespace-nowrap rounded-lg border-b-4 border-solid border-transparent bg-sky-600 px-4 py-3 text-center text-sm font-bold uppercase leading-5 tracking-wider text-white outline-hidden transition-all duration-200 hover:brightness-110 active:border-b-0 active:border-t-4 active:bg-none disabled:cursor-auto">
<button
type="button"
className="box-border inline-block h-11 cursor-pointer touch-manipulation whitespace-nowrap rounded-lg border-b-4 border-solid border-transparent bg-sky-600 px-4 py-3 text-center text-sm font-bold uppercase leading-5 tracking-wider text-white outline-hidden transition-all duration-200 hover:brightness-110 active:border-b-0 active:border-t-4 active:bg-none disabled:cursor-auto"
>
Duolingo
<span className="absolute inset-0 -z-10 rounded-lg border-b-4 border-solid border-transparent bg-sky-500" />
</button>
Expand Down
5 changes: 4 additions & 1 deletion animata/button/external-link-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ interface ButtonTitleProps {

export default function ExternalLinkButton({ text = "Open Link" }: ButtonTitleProps) {
return (
<button className="text-md group/link flex items-center justify-center gap-1 rounded-md bg-pink-600 px-6 py-3 text-white hover:cursor-pointer hover:text-yellow-300">
<button
type="button"
className="text-md group/link flex items-center justify-center gap-1 rounded-md bg-pink-600 px-6 py-3 text-white hover:cursor-pointer hover:text-yellow-300"
>
<span>{text}</span>
<ArrowTopRightIcon
height={20}
Expand Down
1 change: 1 addition & 0 deletions animata/button/get-started-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function GetStartedButton({
return (
<div className="min-h-12 w-48">
<button
type="button"
className={cn(
"group/start flex h-12 w-40 items-center justify-center gap-3 rounded-lg bg-amber-100 p-2 font-bold transition-colors duration-100 ease-in-out hover:bg-orange-600",
className,
Expand Down
79 changes: 37 additions & 42 deletions animata/button/ripple-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useRef, useState } from "react";
import { useCallback, useRef } from "react";

import "./ripple-button.css";

Expand All @@ -11,35 +11,32 @@ interface RippleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement
export default function RippleButton({ children, ...props }: RippleButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const rippleRef = useRef<HTMLSpanElement>(null);
const [isHovered, setIsHovered] = useState(false);

const createRipple = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (isHovered || !buttonRef.current || !rippleRef.current) return;
setIsHovered(true);

const button = buttonRef.current;
const ripple = rippleRef.current;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;

ripple.style.width = `${size}px`;
ripple.style.height = `${size}px`;
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;

ripple.classList.remove("ripple-leave");
ripple.classList.add("ripple-enter");
},
[isHovered],
);
const isHoveredRef = useRef(false);

const createRipple = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
if (isHoveredRef.current || !buttonRef.current || !rippleRef.current) return;
isHoveredRef.current = true;

const button = buttonRef.current;
const ripple = rippleRef.current;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;

ripple.style.width = `${size}px`;
ripple.style.height = `${size}px`;
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;

ripple.classList.remove("ripple-leave");
ripple.classList.add("ripple-enter");
}, []);

const removeRipple = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
if (event.target !== event.currentTarget) return;
if (!buttonRef.current || !rippleRef.current) return;
setIsHovered(false);
isHoveredRef.current = false;

const button = buttonRef.current;
const ripple = rippleRef.current;
Expand All @@ -64,25 +61,23 @@ export default function RippleButton({ children, ...props }: RippleButtonProps)
ripple.addEventListener("animationend", handleAnimationEnd);
}, []);

const handleMouseMove = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (!buttonRef.current || !rippleRef.current || !isHovered) return;

const button = buttonRef.current;
const ripple = rippleRef.current;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;

ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
},
[isHovered],
);
const handleMouseMove = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
if (!buttonRef.current || !rippleRef.current || !isHoveredRef.current) return;

const button = buttonRef.current;
const ripple = rippleRef.current;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;

ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
}, []);

return (
<button
type="button"
ref={buttonRef}
className="font-jost duration-[300ms] relative flex items-center justify-center overflow-hidden rounded-full bg-[#cbfe7e] p-[1.3rem] text-[1.2rem] font-medium text-[#0e352e] transition-colors hover:text-white"
onMouseEnter={(e) => {
Expand Down
5 changes: 4 additions & 1 deletion animata/button/shining-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { cn } from "@/lib/utils";
export default function ShiningButton() {
const label = "See Calendar";
return (
<button className="group/shine cursor-pointer rounded-xl border-4 border-violet-800/0 bg-transparent p-1 transition-colors duration-500 hover:border-violet-800/100">
<button
type="button"
className="group/shine cursor-pointer rounded-xl border-4 border-violet-800/0 bg-transparent p-1 transition-colors duration-500 hover:border-violet-800/100"
>
<div className="relative flex items-center justify-center gap-4 overflow-hidden rounded-lg bg-violet-800 px-6 py-4 font-bold text-white">
{label}
<ArrowRight className="transition-all duration-700 ease-in-out group-hover/shine:translate-x-2 group-hover/shine:scale-125" />
Expand Down
Loading