Skip to content

Shape Elevation and Motion

Karthik P edited this page Oct 24, 2026 · 1 revision

This page sets corner radii, borders, shadows and motion on the website, and the one animation the site is allowed to show off. The values are defined once on Design Tokens.

The direction is a printed timetable: flat surfaces separated by hairlines, square-ish corners, no depth on the page itself, and nothing that moves unless the reader caused it.

Radius

Token Value Tailwind Used for
radius.none 0 rounded-none Header, sidebar, tables, the current-page bar, images in prose
radius.sm 4 px rounded-sm Inline code, badges, <kbd>, week-strip bars
radius.md 6 px rounded-md Buttons, inputs, selects, callouts, the search trigger, tab buttons
radius.lg 8 px rounded-lg Code blocks, cards, menus
radius.xl 12 px rounded-xl The demo frame, the search dialog, dialogs
radius.full 9999 px rounded-full Pills, the due marker dot

Tailwind's default --radius-sm … --radius-xl already equal 4, 6, 8 and 12 px (tailwindcss 4.3.3). The brief assigns inline code, badges, buttons, inputs, callouts, code blocks, cards, the demo frame, dialogs and pills; the other items in "Used for" are Proposed.

Nested corners: inside a rounded box with padding p, a child's radius is (outer − p), never more than the outer. In the 12 px demo frame with 8 px padding, a child is 4 px.

Where Fumadocs differs

Component Fumadocs (fumadocs-ui 16.17.8) Ours
Code block rounded-xl (12 px) 8 px
Callout rounded-xl 6 px
Card rounded-xl 8 px
Prose image rounded-lg (8 px) 0 (Proposed)
Sidebar item rounded-lg 8 px; the current page's start edge is square so the amber bar is straight (Design Tokens)
Button, search dialog 6 px, 12 px unchanged
/* website/app/global.css — radius */
#nd-page figure.shiki {
  border-radius: 8px; /* radius.lg */
}
#nd-page .prose img {
  border-radius: 0;
}
// website/components/mdx.tsx
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Callout } from 'fumadocs-ui/components/callout';
import { Card } from 'fumadocs-ui/components/card';
import type { MDXComponents } from 'mdx/types';
import type { ComponentProps } from 'react';

export function getMDXComponents(components?: MDXComponents): MDXComponents {
  return {
    ...defaultMdxComponents,
    Callout: (props: ComponentProps<typeof Callout>) => (
      <Callout {...props} className={`rounded-md! ${props.className ?? ''}`} />
    ),
    Card: (props: ComponentProps<typeof Card>) => (
      <Card {...props} className={`rounded-lg! ${props.className ?? ''}`} />
    ),
    ...components,
  };
}

The trailing ! is Tailwind v4's important modifier; it beats the rounded-xl already on the component.

Borders

Rule Value
Width 1 px. No 2 px borders except the focus ring, the current-page bar and the active tab underline.
Default colour rule (Fumadocs' --color-fd-border)
Form controls text-3, which reaches 3:1 (Color)
Dividers 1 px rule, full width of the container. No double rules.
Tables Proposed: rule lines between rows and under the header; no vertical lines, no outer box.
Dark popovers Keep their 1 px rule border; the shadow alone does not separate them from a dark page.

Shadows

None on page content: no shadow on cards, code blocks, callouts, buttons, the header or the demo frame. Edges come from rule borders and surface fills. Only floating layers get one: popovers, menus, the search dialog and the mobile drawer.

Mode shadow.popover
Light 0 1px 2px rgba(15,23,41,.06), 0 8px 24px rgba(15,23,41,.08)
Dark 0 8px 24px rgba(0,0,0,.5), plus the 1 px rule border

Fumadocs uses shadow-sm and shadow-md on a few in-page elements and shadow-lg to shadow-2xl on floating ones (fumadocs-ui 16.17.8). Proposed: remap Tailwind's shadow scale so both rules hold without touching Fumadocs' components, then check in DevTools that a card has no shadow and the search dialog has ours, in both modes.

/* website/app/global.css — shadows */
@theme inline {
  --shadow-sm: 0 0 #0000;
  --shadow-md: 0 0 #0000;
  --shadow-lg: var(--wc-shadow-popover);
  --shadow-xl: var(--wc-shadow-popover);
  --shadow-2xl: var(--wc-shadow-popover);
}

Motion

Token Value Tailwind Used for
duration.fast 100 ms duration-100 Hover and pressed colours, popover open and close
duration.base 160 ms duration-160 (the default) Accordions, tabs, menus, dialog close
duration.slow 240 ms duration-240 Dialog open, sidebar drawer, the due marker
easing.standard cubic-bezier(0.2, 0, 0, 1) ease-standard (the default) Everything

Rules:

  • Animate only opacity, transform and colours; never width, height, top or left, except the height of Fumadocs' own accordions.
  • Nothing moves on scroll or on page load: no entrances, no parallax, no looping animation, no animated gradients, no typing effects. The hero is still until the visitor touches the demo.
  • Theme changes are instant: next-themes runs with disableTransitionOnChange (Fumadocs' default), and Fumadocs' "d" hotkey, which cross-fades the theme, is off (Accessibility).

Fumadocs ships its own durations: 100 ms popovers, 150–200 ms accordions, 250 ms sidebar, 300 ms dialogs and fades (fumadocs-ui 16.17.8, css/lib/base.css). Proposed: bring them onto our three steps by redefining its animation variables after the import:

/* website/app/global.css — Fumadocs animations on our timing */
@theme {
  --animate-fd-fade-in: fd-fade-in 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-fade-out: fd-fade-out 100ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-dialog-in: fd-dialog-in 240ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-dialog-out: fd-dialog-out 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-popover-in: fd-popover-in 100ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-popover-out: fd-popover-out 100ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-sidebar-in: fd-sidebar-in 240ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-sidebar-out: fd-sidebar-out 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-collapsible-down: fd-collapsible-down 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-collapsible-up: fd-collapsible-up 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-accordion-down: fd-accordion-down 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-accordion-up: fd-accordion-up 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-nav-menu-in: fd-nav-menu-in 160ms cubic-bezier(0.2, 0, 0, 1);
  --animate-fd-nav-menu-out: fd-nav-menu-out 160ms cubic-bezier(0.2, 0, 0, 1);
}

The one orchestrated moment

The landing demo's due marker moving when the visitor changes an input. It is the only animation meant to be watched, because it shows what the library does.

Part Behaviour
Trigger A committed change: a typed value, a stepper click, a holiday toggled.
What moves The marker (line and dot), with transform. Bars, used fill and label redraw at once.
Duration and easing duration.slow (240 ms), easing.standard.
Crossing to another day Proposed: moves along x and y at once, the shortest path.
Text result Changes at once and is announced in a live region (Components).
Interrupted A new change starts from where the marker is now.
Reduced motion The marker jumps to its new place.

The marker layer spans the strip's track, so a percentage in translate is a percentage of the track. The line is 2 px wide and runs 4 px above and below the 24 px row, with a 6 px dot at its top (Components). It is aria-hidden because the result text says the same thing; the 2 px bg outline keeps it visible over work and used (Color).

// website/components/week-strip/due-marker.tsx
export function DueMarker({ xPercent, yPx }: { xPercent: number; yPx: number }) {
  return (
    <div
      aria-hidden="true"
      className="pointer-events-none absolute inset-x-0 top-0 transition-transform duration-240 ease-standard motion-reduce:transition-none"
      style={{ transform: `translate(${xPercent}%, ${yPx}px)` }}
    >
      <span className="absolute -top-1 left-0 h-8 w-0.5 -translate-x-1/2 bg-accent outline-2 outline-bg" />
      <span className="absolute -top-1.75 left-0 size-1.5 -translate-x-1/2 rounded-full bg-accent outline-2 outline-bg" />
    </div>
  );
}

Reduced motion

When the system asks for reduced motion (MDN: prefers-reduced-motion), nothing transitions or animates. Fumadocs honours the setting in only a few places (fumadocs-ui 16.17.8), so a global rule does it:

/* website/app/global.css — reduced motion (un-layered) */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

0.01 ms rather than none keeps animationend and transitionend firing for scripts that wait for them. Our components also add motion-reduce:transition-none where they transition, so the intent is visible in the markup.

Test: Chrome DevTools, Rendering panel, "Emulate CSS media feature prefers-reduced-motion: reduce"; change the demo inputs, open the search dialog and the mobile drawer. Nothing should slide or fade.

Open items

  • Proposed: radii for items the brief does not assign; square prose images; timetable-style table borders; the shadow remap; Fumadocs animation timings; the marker's path when it crosses to another day.

Clone this wiki locally