Adamastor motion should feel editorial, quiet, and precise: continuity over spectacle. Use animation to explain state changes that would otherwise feel abrupt, not to decorate static content.
- Movement uses ease-in-out. If an element is already on screen and moves or morphs, use
220ms cubic-bezier(0.77,0,0.175,1). - Hover and color use ease. Small hover/color feedback should stay near
150ms ease. - Entrances use ease-out. Elements entering the viewport may use
150-250ms ease-out; avoid entrance motion on high-frequency editorial surfaces unless it clarifies state. - Prefer transform and opacity. Moving rails, indicators, and overlays should animate
transformand/oropacity, notwidth,height,margin, orpadding. - Honor reduced motion. Every animated element needs
motion-reduce:transition-noneor an equivalent no-motion path. - No bounce for editorial chrome. Bouncy motion reads too playful for Adamastor's publication surfaces.
- Route transitions crossfade on opacity. Page-to-page motion uses
opacityonly — nevertranslateYor any transform. See "Route content settle". - Reach for blur to mask a rough crossfade. When a state swap reads as two distinct frames rather than one motion, a small
filter: blur()(≤ 2px, small elements only) bridges the gap. See "Microtransitions".
| Token | Value | Use |
|---|---|---|
--ease-move |
cubic-bezier(0.77,0,0.175,1) |
Existing on-screen elements moving between states |
--duration-state |
220ms |
Standard tab/rail state movement |
--duration-feedback |
150ms |
Hover color, border, and background feedback |
Until these are promoted to CSS variables, inline Tailwind arbitrary transitions should use:
"[transition:transform_220ms_cubic-bezier(0.77,0,0.175,1)] motion-reduce:transition-none"The active rail pattern is used when a nav or scroll-spy needs a visible "you are here" line:
- Render semantic links normally.
- Keep inactive text and resting hairlines in the DOM.
- After measurement, make per-link active borders transparent.
- Render one
aria-hiddenabsolute rail. - For horizontal rails:
h-0.5 w-px origin-left+transform: translateX(x) scaleX(width). - For vertical rails:
h-px w-px origin-top+transform: translateY(y) scaleY(height). - Transition only
transform. - Keep a pre-measure active border fallback so the state is visible before layout metrics settle.
Current implementations:
| Surface | File | Pattern |
|---|---|---|
| Site section nav | components/navbar-sections.tsx |
Horizontal active rail + clipped active text layer |
| Events city tabs | app/(main)/events/EventsLayoutShell.tsx |
Horizontal active rail — lives in the route layout so it persists across filter navigations (see § Route content settle) |
| Post table of contents | app/(main)/posts/[id]/PostTOC.tsx |
Vertical active rail |
The site section nav uses a duplicated active text layer with clip-path to reveal active text cleanly between "Articles" and "Events". This is a deliberate exception to the transform-only preference because the effect is a mask, not a moving box.
Rules for text masks:
- Use only for small, stable tab sets.
- Pair mask timing with the rail timing:
220ms cubic-bezier(0.77,0,0.175,1). - Keep the real underline/rail outside the clipped layer so the mask cannot reveal old/new line fragments.
- Include
motion-reduce:transition-none.
Public page-to-page navigation uses a small content-only entrance animation. The goal is to soften the moment when server-rendered route content swaps in, without making the navbar, footer, or mobile tab bar feel like they reload.
Rules:
- Scope the animation to persistent route layouts with
RouteTransitionFrame: public<main>content, dashboard content below the topbar, and standalone auth pages. - Do not use native shared-element View Transitions for article titles until the source and destination layouts have been visually validated.
- Use
180ms cubic-bezier(0.215,0.61,0.355,1)(ease-out — content is entering). - Crossfade on
opacityonly:opacity: 0 → 1. NevertranslateY(or any transform) for route transitions. A vertical slide implies the page physically moved between two places; it didn't. A crossfade reads as the same editorial surface settling in. Opacity is the default — and the only — channel for page-to-page motion. - A crossfade can flash blank if the new route paints slowly. A
loading.tsxskeleton can fill that gap — but it is not mandatory, and on a cached route it backfires. See § "Skeletons are a bet" below. - A keyed
RouteTransitionFrameremounts its whole subtree on key change — including nested layouts. When a section has a persistent nested layout that must survive intra-section navigation (e.g./events, whose city-tab row lives inEventsLayoutShelland must not remount on every filter click), collapse that section to a single transition key so the frame doesn't tear the layout down.RouteTransitionFramekeys all of/events/*as one key; those in-section swaps run their own local crossfade (route-content-enterkeyed on the pathname insideEventsLayoutShell). - Do not animate the first direct page load; animate only client-side route changes after hydration.
- Disable entirely for
prefers-reduced-motion: reduce.
A route's loading.tsx skeleton trades a blank screen for a placeholder — a bet that the data is slow enough that something sooner beats the real thing slightly later. On a cached ISR route the bet is always a loss: the content is already in the cached response (served instantly, stale-while-revalidate), so the skeleton just streams a short placeholder that the taller real content then swaps in for — a layout shift, paid on every load.
/events reloaded at CLS 0.41 for exactly this reason. The route is ISR (cache HIT), but the async page + loading.tsx baked a "skeleton → content" stream into the cached HTML; the short skeleton painted first, then the real list replaced it and shoved the footer down (the footer was the sole CLS culprit). Removing loading.tsx (and the now-inert <Suspense fallback={null}> wrappers in the three events page.tsx files) made the page render its content in one shot — CLS 0.41 → 0, and LCP dropped sharply too (no streaming overhead).
Rules:
- Ship
loading.tsxonly for genuinely slow or uncached routes (auth-gated, dynamic, heavy first-byte work — e.g. the dashboard calendar, which additionally geometry-matches its skeleton; seereact-big-calendar-loading-stability.md). - Omit it for cached ISR content routes so the cached HTML is the final content and the footer sits at its real position from first paint.
- A skeleton whose height can't match the (variable) real content shifts something: a short skeleton → tall content pushes the footer down; a tall skeleton → short content pulls it up. No single skeleton height is right for every state, which is why "serve content directly" wins for fast routes.
- If you do keep a skeleton, its first-painted geometry must match the final UI, or you've only traded a blank frame for a layout shift.
Small, frequent interactions (button presses, state swaps). Keep them fast (100–180ms), ease-out, and reduced-motion-safe.
Every interactive Button scales to 0.97 on :active — instant, tactile confirmation that the press registered. Applied in components/tailwind/ui/button.tsx:
"transition-[color,background-color,border-color,transform] duration-150 ease-out active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100"Rules:
- Use
scale-[0.97], not a smaller value — a deep press (e.g.0.9) reads as a bug, not feedback. - Keep color transitions alongside transform; do not regress to
transition-all. - Always pair with
motion-reduce:active:scale-100so reduced-motion users get no scale.
When a small element swaps between two states (e.g. a copy button going Copy → Copied), an instant swap shows two distinct objects. A crossfade with a touch of blur "bridges the visual gap between the old and new states… it tricks the eye into seeing a smooth transition by blending the two states together" (Emil Kowalski, Animations on the Web).
Use the .state-crossfade utility (styles/globals.css) on a keyed wrapper so the animation re-fires on every state change:
<span key={copied ? "copied" : "idle"} className="state-crossfade inline-flex items-center gap-1.5">
{copied ? <Check … /> : <Copy … />}
{copied ? "Copied" : "Copy feed URL"}
</span>.state-crossfade { animation: stateCrossfade 180ms cubic-bezier(0.215,0.61,0.355,1) both; }
@keyframes stateCrossfade {
from { opacity: 0; filter: blur(2px); }
to { opacity: 1; filter: blur(0); }
}Rules:
- Blur only on small elements. Full-surface blur (a whole page, a large card) is expensive — especially in Safari — and reads as a focus glitch. This is why route transitions crossfade on opacity without blur.
- Keep blur ≤ 2px. More than that calls attention to itself.
- The
keyis load-bearing: without a changing key the element never remounts and the animation never re-fires.
Current implementations:
| Surface | File | Pattern |
|---|---|---|
| Copy feed URL / Slack command buttons | app/(main)/events/EventsPageClient.tsx |
.state-crossfade on keyed icon/label swap |
| All buttons | components/tailwind/ui/button.tsx |
active:scale-[0.97] press feedback |
Month / range navigation on the three calendars (events desktop grid, events mobile strip, dashboard big-calendar) shares one motion: the new content enters from the side it came from, with a crossfade and a small blur. This is the one place a directional slide is right — the user is moving through time (next / back), so spatial direction carries meaning. (Contrast route transitions, which are opacity-only — the page isn't moving anywhere.)
Rules:
- Direction maps to the gesture. "Next" → content enters from the right and settles left (
translateX(+14px) → 0, ±20px on the denser big-calendar). "Back" → enters from the left. "Today" (big-calendar) → straight crossfade, no slide. - Anchor the slide to the data that changed, not the whole grid. A calendar's static chrome — the month weekday-name header (
.rbc-month-header), the time gutter — must stay put; it's the fixed reference the eye measures the slide against. Sliding it too reads as noise and makes the data look like it starts from the wrong place. Target the changing rows (.rbc-month-row,.rbc-time-content,.rbc-time-header), never the whole.rbc-calendar. - Blur belongs here. A small
filter: blur(4px) → 0bridges the in-place content swap. The grids update without remounting, so without blur the eye sees two distinct frames — this is the textbook case for the blur trick. - Re-fire imperatively. react-day-picker and react-big-calendar both update the grid in place, so a CSS keyframe won't replay on its own. Track
{count, direction}in state, then in auseLayoutEffectset--cal-nav-from, remove the class, force a reflow (void el.offsetWidth), and re-add it. UseuseLayoutEffect(notuseEffect) so the animation applies before the browser paints the new grid — otherwise it jumps back and replays. - Use the shared
.cal-nav-enterutility +--cal-nav-from(styles/globals.css), 280ms ease-out. - The mobile strip pages by scrolling, not swapping, so it uses a lighter
.cal-strip-pagingblur pulse on the paged scroll instead of a slide. - View switches (month ↔ week ↔ day) fade; they don't slide. react-big-calendar reserves the vertical scrollbar gutter inside a
requestAnimationFrameafter the new view's first paint (measureGutter→checkOverflowinTimeGrid.js), so the day-header resizes 1–2 frames late — a visible width snap. The toolbar resizes too (its width tracks the card's content box). You can't smooth a width change (animating layout breaks the transform/opacity golden rule), so mask it:.cal-view-enterholds the whole card content — toolbar and view — atopacity: 0for the first ~90ms (covering rbc's late rAF reflow) and only then fades in, so the snap never reaches the screen. Three traps: (1) a plain immediate fade-in does NOT work — ease-out climbs too fast and the reflow shows through at ~30% opacity; (2) masking only the calendar viewport leaves the toolbar (a sibling) unmasked — the mask must wrap both; (3) do not reach forscrollbar-gutter: stableon.rbc-time-contentto stop the columns redistributing. It reserves a gutter the header row doesn't, so the content's day-columns sit one scrollbar-width off from the header's and the past-day wash (which paints both the header all-day cell and the content column) jogs at that seam — a visible 1px column misalignment. rbc already keeps header and content aligned via its own overflow-gutter margin; the opacity hold is the whole fix. (Seedocs/react-big-calendar.mdfor the full breakdown.) - Disable all of it for
prefers-reduced-motion: reduce.
Current implementations:
| Surface | File | Trigger |
|---|---|---|
| Events desktop month grid | components/event-calendar.tsx |
rdp onMonthChange → .cal-nav-enter on the day <table> |
| Events mobile day strip | components/event-calendar.tsx |
scrollByPage → .cal-strip-paging blur pulse |
| Dashboard big-calendar | app/(dashboard)/dashboard/calendar/CalendarTestClient.tsx |
toolbar PREV / NEXT / TODAY → .cal-nav-enter on the data rows (.rbc-month-row / .rbc-time-*); static weekday header held still |
| Big-calendar view switch | CalendarTestClient.tsx + calendar-custom.css |
onView → .cal-view-enter opacity hold/mask over rbc's gutter reflow (no scrollbar-gutter — it misaligns the columns) |
| Before | After |
|---|---|
Site nav underline animated transform + width |
Site nav underline animates transform only via translateX(...) scaleX(...) |
Events city underline animated transform + width |
Events city underline animates transform only via translateX(...) scaleX(...) |
Post TOC rail animated transform + height |
Post TOC rail animates transform only via translateY(...) scaleY(...) |
TOC used space-y-2, which also offset the absolute rail |
TOC uses [&>li+li]:mt-2, preserving item spacing without moving the rail |
| Clip-path text mask and underline lived too close conceptually | Text mask is documented as text-only; underline/rails stay separate measured spans |