Repository navigation
Shape Elevation and Motion
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.
| 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.
| 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.
| 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. |
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);
}| 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,transformand 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-themesruns withdisableTransitionOnChange(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 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>
);
}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.
- 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.
workclock is MIT licensed. The repository documentation is authoritative; this wiki summarises it.