DESIGN-RULES.md
121.6 KB
обновлён 03.11.2026 17:31
---
name: apple-modern-ui
description: "Apple-style anti-slop web UI ruleset: Onest typography, one-accent color tokens with verified contrast, functional glass, Lucide icons, Motion + anime.js recipes, components, accessibility, Delivery Gate. Use for any web UI work: landing pages, components, styling, animation, UI review."
---
# Apple-Modern UI Ruleset · anti-slop edition
**Version 1.0 (EN).** Rules for AI agents (Claude Code, Cursor, Codex, Windsurf, v0, etc.) and humans. Goal: the web UI looks like a modern Apple site, not a generated template.
**Default stack:** **Onest** typeface, **Lucide** icons, **Motion (motion.dev)** + **anime.js v4** for animation, CSS-first.
**Compiled from 17 skills and guidelines** (full list in §20): anthropics/frontend-design, taste-skill, anti-slop (antislop), impeccable, hallmark, ui-ux-pro-max, Emil Kowalski (design-eng + apple-design), GSAP skills, Motion AI Kit, anime.js skills, Vercel Web Interface Guidelines, make-interfaces-feel-better, ui-skills, Apple HIG / Liquid Glass (apple-design-skill), Apple DESIGN.md (awesome-design-md), LottieFiles motion-design, motion-dev-animations.
---
## Contents
0. How to use
1. Direction: Apple-modern
2. Resolving conflicts between skills
3. Hard bans (AI-slop registry)
4. Typography (Onest)
5. Color
6. Grid, spacing, rhythm, responsive
7. Surfaces, radii, shadows, glass
8. Icons (Lucide)
9. Motion (Motion + anime.js)
10. Components and states
11. Images and media
12. Copy (Russian UI)
13. Accessibility
14. Performance
15. Mobile layer
16. Tokens (CSS + Tailwind v4)
17. Hero skeleton (as in the reference)
18. Delivery Gate (pre-handoff check)
19. Installing the skills
20. Sources
---
## 0. How to use
### 0.1 Connecting to an agent (load on demand)
The file is large (~36k tokens). Load it only for UI work; never put it into always-on context.
- **As a skill (best):** copy the file to `.claude/skills/apple-modern-ui/SKILL.md` (Claude Code), `.agents/skills/apple-modern-ui/SKILL.md` (Codex) or `.cursor/skills/apple-modern-ui/SKILL.md` (Cursor). The frontmatter at the top is the skill header: until a UI task triggers the skill, only its `description` sits in context.
- **As a project file:** put it in the project root as `DESIGN-RULES.md` and add one line to `CLAUDE.md` / `AGENTS.md`: `Before any UI work, read DESIGN-RULES.md and follow it.` Write it without `@`: in Claude Code an `@DESIGN-RULES.md` import loads the whole file into every session.
- **Cursor rule:** `.cursor/rules/design.mdc`, attached by globs, not always:
```md
---
description: Apple-modern UI ruleset (anti-slop). Use for any web UI work.
globs: ["**/*.{tsx,jsx,vue,svelte,astro,html,css}"]
alwaysApply: false
---
Follow @DESIGN-RULES.md for every UI change.
```
- Installed skills (frontend-design, taste-skill, impeccable, etc.) may run alongside. §2 resolves their conflicts.
### 0.2 Modality
- **MUST / NEVER**: hard rule. Deviate only on the user's explicit request.
- **SHOULD**: default. Deviation is fine if the reason is recorded in one line (PR or `DESIGN.md`).
- **AVOID**: AI-slop tell. Use only with a recorded reason.
### 0.3 Source priority
1. The user's explicit words and the brief. Pinned fonts, palettes and eras are honored even against anti-slop warnings ("the brief wins", impeccable).
2. The project's `DESIGN.md` / brand guide.
3. This Ruleset.
4. Defaults of installed skills.
**Exception:** accessibility (§13) and honesty (§3.7) are never overridden by any brief.
### 0.4 Agent workflow
1. **Read the project:** stack, tokens, components, real content. Don't ask what the code can tell you.
2. **Write the Design Read and Dials** (§1.4) as the first line of the reply.
3. **Plan before code:** tokens (colors, font, scale), copy deck (real text), per-screen composition (ASCII sketch), one signature animation.
4. **Build the static version** without animation: content, grid, typography, all states.
5. **Add motion** within the budget (§9.4).
6. **Check the render** at 390, 768, 1280×800, 1440×900 and 1920 px, light and dark theme, keyboard, reduced motion. Pass the Delivery Gate (§18) and attach evidence.
---
## 1. Direction: Apple-modern
### 1.1 Seven pillars
1. **The hero is the product and the content; the UI steps back.** Color and emotion come from photos, renders and real screenshots. Chrome stays neutral and quiet.
2. **One accent.** One interactive color for everything "press me": links, primary CTA, focus, selected state. No second brand color.
3. **Big, dense grotesque.** Huge short headline with negative tracking, calm 17 px body. Hierarchy comes from size and weight, not color or decoration.
4. **Whitespace as structure.** One screen, one idea. Sections are separated by a surface change (white ↔ "parchment" #f5f5f7 ↔ black), not by lines and shadows.
5. **Materials by meaning.** Translucent glass only on floating chrome: navigation, panels, controls over media. Shadow only on what truly floats (menu, popover, dialog) and on a product standing on a surface.
6. **Physical, interruptible motion.** Instant press feedback, springs without bounce, animation explains cause and effect. One authored scene per page.
7. **Craft in the details.** Concentric radii, optical alignment, tabular numerals, styled `::selection` / `caret` / scrollbar, precise copy, all states.
### 1.2 Apple principles as questions for every screen
| Principle | Question |
|---|---|
| Purpose | Why does this screen exist, and does the design serve it? |
| Agency | Can the user skip, cancel, go back? Undo beats confirm. |
| Responsibility | Is it transparent about data and permissions? |
| Familiarity | Are the patterns familiar and consistent? |
| Flexibility | Does it work at any size, with any input, with enlarged text? |
| Simplicity ≠ minimalism | Has every element earned its place? |
| Craft | Are spacing, alignment, wording and motion finished? |
| Delight | Is there feeling, and is it the right one? "Don't confuse delight with decoration." |
### 1.3 Reference composition: cinematic hero
Breakdown of the provided example (antislop.ai):
- full-bleed cinematic illustration or photo filling the first screen (`min-height: 100svh`), dark vignette scrim at the bottom and edges for legibility;
- transparent header over the hero: wordmark left, 4–5 centered items with `ChevronDown` on dropdowns, on the right one text link and one glass pill CTA;
- centered huge dense grotesque (max 2 lines, tracking -0.03…-0.04em), below it 1–2 subtitle lines in ~80 % white (verify contrast!);
- glass pill field with a monospace `npx …` command and a round copy button (Lucide `Copy` → `Check`);
- small footnote at the bottom center.
This is the page's **only centered moment**. Everything after it uses left-aligned compositions with varied rhythm.
### 1.4 Design Read and Dials
Mandatory first line of the agent's reply, before any code:
```text
Design Read: <page type> for <audience>, Apple-modern language (<cinematic dark | gallery light>), signature: <one thing>. Dials: E2 R2 M2.
```
| Dial | 1 | 2 (default) | 3 |
|---|---|---|---|
| **ENERGY** (contrast, scale, color) | quiet: docs, app | confident: landing | poster: launch, promo |
| **RHYTHM** (density, composition changes) | even | breathing | dramatic shifts |
| **MOTION** | feedback + 1 reveal | + hero sequence and 2–3 reveals | + one scroll scene and a "living image" |
### 1.5 Surface mode (impeccable)
- **Persuade** (landing, marketing, pricing): bold, one strong idea, real imagery.
- **Operate** (app, dashboard, settings): scannability and stability over expression. Motion only for feedback.
- **Read** (docs, articles): reading comes first.
- **Experience** (portfolio, gallery): the artifact leads, the UI steps back.
Choose the mode by surface, not by product: a tool's landing page is still Persuade; a fashion house's docs are still Read.
---
## 2. Resolving conflicts between skills
| # | Topic | What the skills say | Ruleset decision |
|---|---|---|---|
| 1 | Glass / backdrop-blur | taste: banned. antislop: max 1–2 elements. impeccable: not as decoration. Apple HIG and Emil: material of the functional layer. | Only for floating chrome and controls over media: header, sticky bar, menus and popovers, sheet, pill CTA and command field over the hero. Max 2 glass roles per viewport. Never on content cards, never glass on glass. Fallbacks are mandatory (§7.5). |
| 2 | Floating pill nav | taste: warned. hallmark: reflexive "AI nav". | The header is a full-width bar like on apple.com, not a floating pill island. Pill shape only for buttons, fields and segments. |
| 3 | "Don't clone Apple" | antislop: don't copy Apple/Linear/Vercel unless asked. | Apple style was requested, so it is the direction. Transfer principles and proportions, not Apple's logos, copy or photos. The project's brand stays its own. |
| 4 | Lucide | antislop: "Lucide look" as a template tell. | Lucide is a deliberate choice. The anti-slop requirement shifts to usage: icons by meaning, stroke matched to text weight, no "icon in a coin" above every card, no Sparkles / Rocket / Zap. |
| 5 | Centered hero | taste: center in at most one place. hallmark 6b: center axis as a default. | One centered moment is allowed: the hero, as in the reference. Other sections are left-aligned or asymmetric. |
| 6 | Tracking | ui-skills: don't change letter-spacing. | Apple typography needs negative tracking at display sizes (table §4.4). Never loosen labels. |
| 7 | Custom curves and animation | ui-skills: animate only on request, no custom easing. | Animation was requested; use the §9.3 tokens. Don't animate actions repeated 100+ times a day. |
| 8 | Monospace | taste / hallmark / impeccable: mono as a costume is banned. | Mono only for real code, commands and measurements (the `npx …` field). No mono labels or captions. |
| 9 | Shadows | taste: no shadows at rest. Apple: one shadow under the product. | Shadow signals elevation only (menu, popover, dialog, glass sticky bar) plus one "product" shadow under a render. Cards separate by tone, not shadow. |
| 10 | Parallax and smooth scroll | hallmark: never parallax. taste: parallax under 8 %, Lenis optional. | Default: native scroll, no parallax. At MOTION 3, one scroll scene is allowed (travel up to 8 % or a sticky stage). Lenis only on marketing pages, disabled for touch and reduced motion. No scroll-jacking. |
| 11 | `text-wrap: balance` on display text | hallmark F3: against. impeccable / ui-skills: for. | `balance` for h2–h4 and short centered blocks. For h1, an authored break by meaning (`<br>` verified at all widths), or `balance` if the break is not authored. Always check visually. |
| 12 | Radii | taste: square or full round, pick once. impeccable: cards 12–16. Apple: cards 18, pill buttons. | Buttons, search and command fields, chips: pill (decided once and for all). Cards 22, panels 28, full-bleed media 0. Radii are concentric. |
| 13 | ease-in on exit | Emil: never ease-in in UI. taste / hallmark: ease-in for things "leaving for good". | Exit defaults to ease-out and is 25–40 % shorter than the enter. `--ease-exit` (ease-in) only for things leaving the screen for good, at most 200 ms. |
| 14 | Dark theme | antislop: not "because tech". impeccable: by usage scenario. | A cinematic hero may be dark, but the page theme follows the usage scenario. Without a reason, ship a working light / dark toggle, system default. |
| 15 | Pure #000 / #fff | hallmark: tint them. Apple: white canvas and black "void" under media. | Allowed only as tokens (`--bg`), never as literals in code. Neutrals come from Apple's palette (#1d1d1f, #f5f5f7, #6e6e73). |
| 16 | Hover effects | motion-dev-skill: `whileHover scale 1.05`, "magnetic" buttons. | Not in Apple style: no card scaling, no magnetic buttons. Hover is one signal (tone or contrast) and only under `(hover: hover) and (pointer: fine)`. |
| 17 | Dashes | antislop / hallmark: no em/en dashes in UI. | In UI copy (headings, buttons, labels, short captions) no dashes; rephrase. In long Russian text where the dash is grammatically required, use " — " with a non-breaking space before it, sparingly. |
| 18 | JS hero animation | Motion / anime: everything in JS. taste / hallmark: content visible without JS. | Animate the first screen with CSS keyframes (they start on first paint and don't wait for hydration). Use Motion and anime.js for interaction, scroll, layout and everything below the fold (§9.5). |
---
## 3. Hard bans (AI-slop registry)
Everything below is **NEVER**. Deviate only on the user's explicit request (§0.3). The fix follows the arrow.
### 3.1 Color and surfaces
- **Purple-indigo gradients** (purple→blue, purple→pink, cyan on dark), gradient text, aurora, mesh blobs, orbs, halos, glow around buttons and cards. → Atmosphere comes from photos and the product. A gradient is allowed only as a neutral scrim under text on a photo.
- **"Assistant" palettes:** cream #F4F1EA + terracotta #D97757 + a serif; near-black + acid green or vermilion + neon. → Apple neutrals and one accent (§5).
- **Stock Tailwind swatches** (`indigo-500`, `violet-600`, `slate-900`) as the brand. → Own tokens.
- **A shadow on every card** (soft gray `rgba(0,0,0,.1)`), colored halo without offset, hard offset shadows. → Cards are separated by surface tone (§7).
- **Card in a card**; 1px border plus a wide soft shadow on one surface ("ghost card"). → One means only: tone, border or shadow.
- **Decorative backgrounds:** grids, dots, `feTurbulence` noise, grain, diagonal stripes.
- **Colored left stripe** on a card or alert without a real status.
- **Pulsing status dots** and any infinite pulse.
- **Glass on content cards**, glass on glass, glass everywhere at once (header + cards + modals + sidebar). → §7.4.
- **`#000` / `#fff` / hex literals in components.** → Tokens only.
- **Accent on more than ~5 % of the screen area.** The accent is a signal, not a fill.
### 3.2 Typography
- **Inter, Roboto, Arial, system-ui as the headline voice**; Space Grotesk or Geist "by default" without a reason. → Onest (§4).
- **One "accent" word** in a headline set in italic, color or gradient.
- **Eyebrow / kicker above the headline:** letter-spaced caps, pill badge "New", "Beta", "✨ Introducing".
- **Small letter-spaced caps labels**, monospace labels, small caps. → A label is the same font one step smaller, sentence case, no letter-spacing.
- **Section numbering 01 / 02 / 03**, meta lines "A · B · C", "WORD — fragment" constructions.
- **text-shadow, outline or gradient** on display text.
- **Text under 12 px**; body under 16 px; inputs under 16 px on mobile (iOS zooms the page).
- **More than 3 weights** in use, more than one family (plus mono for code only).
- **h1 longer than 2 lines** at any width; 8–9 near-identical font sizes. → At most 5–6 steps, clearly distinct.
- **Justified text**, centered blocks longer than 3 lines.
- **Weight change on hover** (text "jumps").
- **Synthesized bold or italic.** → `font-synthesis: none`.
- `...` instead of `…`; straight quotes `"…"` instead of «guillemets» in Russian.
### 3.3 Composition
- **The landing template:** hero → "Trusted by" → 3 feature cards → "How it works" in 3 steps → testimonials → 3 pricing tiers → CTA → 4-column footer.
- **"Trusted by" logo bar** with made-up logos.
- **Three identical cards** "icon in a rounded square + title + two lines".
- **Bento by default.** Bento only if the content truly has different sizes and weights.
- **Same rhythm for every section**, same spacing, differing only by background color.
- **Everything centered.** Center is allowed once, in the hero (§2 #5).
- **A dashboard, terminal or browser window drawn in HTML** instead of a real screenshot; phone or laptop frames more than once.
- **A CSS box with a gradient** in place of an image; empty placeholder blocks.
- **"Hand-drawn" illustrations**, Undraw, Storyset, 3D blobs, abstract "neural networks".
- **Near-empty screens** (more than one intentional pause per page), "dead" bands over 160 px inside the hero.
- **A lone orphan item** in the last grid row.
- **Decorative text:** rotated captions, "Scroll ↓", "© 2026 · Scroll", local time, coordinates, crop marks.
- **The same fact** repeated 3–4 times on a page.
- **Floating pill-island navigation.** → Full-width header (§10.2).
### 3.4 Components and chrome
- **AI-cliché icons:** `Sparkles`, `WandSparkles`, `Zap`, `Rocket`, `Bot`, `BrainCircuit`, `Gem`, `Lightbulb`, decorative `Star`. Icon in a colored "coin". Emoji in the UI.
- **An "→" arrow on every button and link.** Arrows only where they say something (external link: `ArrowUpRight`; "next" in a step flow).
- **Two filled buttons side by side**, more than one primary per viewport.
- **Faceless CTAs:** «Начать», «Узнать больше», «Попробовать», «Подробнее», "Get Started", "Learn More", "Explore". → Verb + object: «Скачать для macOS» (Download for macOS), «Скопировать команду» (Copy command), «Посмотреть тарифы» (See pricing).
- **Glow as the main clickability cue.**
- **Hover ensemble:** lift + shadow + scale on every card, image zoom on hover, several effects on one element, hover on non-interactive elements.
- **Custom cursor**, cursor trail, magnetic buttons.
- **Capsule badges** with border, glow, dot and caps.
- **A modal by reflex** when the task can be done inline.
- **Celebratory toast** for a result the user already sees.
- **Links to nowhere** (`href="#"`), buttons without behavior, fake form submission.
- **Generic FAQ** of questions nobody asks.
### 3.5 Motion
- **The same fade-up on every section**; "everything moves" (fade + float + scale + bounce).
- **Infinite loops** (except a loading indicator), floating elements, autoplaying carousels and marquees without pause.
- **Bounce and overshoot on UI states**; `ease-in` on enter and on ordinary exits.
- **`transition: all`**; animating `width`, `height`, `top`, `left`, `margin`.
- **Starting from `scale(0)`**; a popover expanding from its center instead of from the trigger.
- **Preloaders, loading counters, "gates"**, scroll-jacking, wheel hijacking, mouse parallax, starfields.
- **Animating keyboard-initiated actions** and frequent actions (100+ times a day).
- **Content hidden until JS or until scroll.** The first screen doesn't wait for scroll; nothing in view stays hidden longer than 900 ms.
- **scrambleText and typewriter effects** on headlines; per-character splits of long headlines (characters only for 1–3 words).
- **Blur-in on body text**; animated large `blur` or `backdrop-filter` on full-screen surfaces.
- **Motion longer than 5 s without a pause control.**
- **`will-change` in a class "just in case".**
### 3.6 Copy
- **Filler words (RU):** революционный, инновационный, передовой, нового поколения, на базе ИИ, бесшовный, мощный, интеллектуальный, идеальный, уникальный, непревзойдённый, «откройте для себя», «погрузитесь», «раскройте потенциал», «на новом уровне», «всё, что нужно», «меняет правила игры», «будущее уже здесь», «путешествие», «экосистема» and «решение» as empty words.
- **(EN):** AI-powered, next-generation, revolutionary, seamless, cutting-edge, intelligent, ultimate, powerful, effortless, unlock, elevate, supercharge, reimagine, game-changer, journey, delve.
- **Patterns:** «Не просто X, а Y» (not just X, but Y), «Будь вы X или Y…» (whether you're X or Y…), «Забудьте о…» (forget about…), «Скажите „прощай“…» (say goodbye to…), adjective triads («быстрый, простой и надёжный»), colon headlines, «Нажмите здесь» (click here), «Упс!» (oops), apologies in errors, exclamation marks, emoji, dashes in UI.
### 3.7 Honesty
- **No invented** numbers, percentages, "10,000+ customers", ratings, testimonials, faces, client logos, awards, certificates.
- **Honest placeholders, visible on the page** (in the page language): `[LOGO]`, `[REAL DATA: number of active teams]`, `[TESTIMONIAL: name, role, company]`, `[PHOTO: product on a desk, daylight, 3:2]`, `[SCREENSHOT: report screen]`. Every placeholder goes into the TODO list in the agent's reply.
- **Demo data** is labeled as demo: `example.com`, `name@example.com`.
- **A form without a backend** honestly says "Form not connected yet" and doesn't fake success.
- **Don't claim what wasn't measured:** "Lighthouse 100", "loads in 0.5 s". Only with a measurement.
### 3.8 Accessibility (the floor)
- `outline: none` without a replacement; a focus ring that animates in; focus hidden under a sticky header.
- `user-scalable=no`, `maximum-scale=1`.
- Icon button without `aria-label`; `<div onClick>` instead of `<button>`; navigation not via `<a href>`.
- Information conveyed by color alone; disabled shown only by opacity.
- Contrast below 4.5:1 for text, below 3:1 for large text, icons, input borders and focus; text on photos without a scrim.
- No `prefers-reduced-motion`; paste blocked in inputs; autoplaying video with sound.
- Skipped heading levels, more than one `h1`, missing `lang` and `<title>`.
### 3.9 "Avoidance aesthetics" are slop too
Removing clichés isn't enough. A sterile page (white background, gray borders, small radius, default font, no images) fails just like a purple gradient. **Idea first:** what only this product has, one signature scene, real images. **Logo-swap test:** if a competitor's logo can be put on the page and nothing breaks, the page isn't done.
---
## 4. Typography (Onest)
### 4.1 Onest: what matters
- **Variable, `wght` axis 100–900**, one upright style. No italic: don't fake it (`font-synthesis: none`). Emphasis is weight 600, not italic.
- **Full Cyrillic**, including `« » „ “ ” — – … ₽ № ё Ё`.
- **OpenType:** `tnum`, `pnum`, `case`, `calt`, `locl`, `ordn`, `sups`, `salt`, `ss01`, `ss02`.
- `ss01` changes the forms of `a/а, f, g, l, t, y` and the figure `1`; `ss02` changes `a/а, l, t, y`. Enable only after a visual check at display size, and project-wide at once: `font-feature-settings: "ss01"`.
- `tnum` for prices, tables, timers, counters (`font-variant-numeric: tabular-nums`).
- **No `opsz` axis**, so size optics are set manually via tracking and leading (§4.4).
- **Onest has no non-breaking hyphen U+2011 and no ⌘ sign (U+2318):** the browser falls back to a system font for them. Write compounds with a regular hyphen and wrap in `<span class="nowrap">` (`white-space: nowrap`) when needed; show keys in `<kbd>` with the system font.
- **Don't embed SF Pro:** Apple's license doesn't allow web hosting. SF is allowed only as a system fallback via `-apple-system`.
```css
--font-sans: var(--font-onest, "Onest"), "Onest Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: var(--font-geist-mono, "Geist Mono"), "Geist Mono Variable", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
```
### 4.2 Loading
**Next.js (recommended):** self-hosting, subsetting and a metric fallback out of the box.
```ts
// app/fonts.ts
import { Onest, Geist_Mono } from "next/font/google";
export const onest = Onest({
subsets: ["latin", "cyrillic"],
display: "swap",
variable: "--font-onest",
});
export const geistMono = Geist_Mono({
subsets: ["latin", "cyrillic"],
display: "swap",
variable: "--font-geist-mono",
});
// app/layout.tsx → <html lang="ru" className={`${onest.variable} ${geistMono.variable}`}>
```
**Vite, Astro, SvelteKit, Nuxt:** Fontsource.
```bash
npm i @fontsource-variable/onest @fontsource-variable/geist-mono
```
```ts
import "@fontsource-variable/onest"; // font-family: "Onest Variable"
import "@fontsource-variable/geist-mono"; // font-family: "Geist Mono Variable"
```
**Static HTML:** Google Fonts CSS2 (self-hosting the files is better).
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@400..700&display=swap" rel="stylesheet">
```
Rules: load only the weight range in use; preload only the critical file (Cyrillic for `lang="ru"`); `font-display: swap`; a fallback of the same kind (grotesque → system grotesque).
### 4.3 Scale
| Token | Size (390 → 1440 px) | Weight | Leading | Tracking | Use |
|---|---|---|---|---|---|
| `--text-hero` | `clamp(2.5rem, 1.25rem + 5.2vw, 5.75rem)` · 40 → 92 | 600 | 1.0 | -0.035em | hero h1, once per page |
| `--text-display` | `clamp(2.25rem, 1.4rem + 3.2vw, 4rem)` · 36 → 64 | 600 | 1.04 | -0.03em | key section headings |
| `--text-title` | `clamp(2rem, 1.5rem + 1.9vw, 3rem)` · 32 → 48 | 600 | 1.08 | -0.025em | regular section h2 |
| `--text-headline` | `clamp(1.375rem, 1.2rem + 0.6vw, 1.75rem)` · 22 → 28 | 600 | 1.2 | -0.015em | h3, card titles |
| `--text-lead` | `clamp(1.1875rem, 1.05rem + 0.55vw, 1.5rem)` · 19 → 24 | 400 | 1.35 | -0.01em | hero subtitle, lead |
| `--text-body` | `1.0625rem` · 17 | 400 | 1.5 | 0 | body text (17, like Apple) |
| `--text-callout` | `0.9375rem` · 15 | 400 / 500 | 1.45 | 0 | navigation, buttons, field labels |
| `--text-caption` | `0.8125rem` · 13 | 400 | 1.38 | +0.005em | media captions, helper text |
| `--text-fine` | `0.75rem` · 12 | 400 | 1.33 | +0.01em | footnotes, legal text (minimum) |
- Hierarchy runs on **2–3 sizes per screen**: very large + regular. Hero-to-body ratio about 5 (87 / 17 at 1280 px).
- In `clamp()`, the max is at most 2.5 × the min, otherwise 200 % zoom suffers (WCAG 1.4.4).
- Sizes in `rem`, spacing around text in `rem`/`em`: the page must respect the user's font size.
### 4.4 Tracking and leading by size
| Size | Tracking | Leading |
|---|---|---|
| ≥ 72 px | -0.035…-0.04em (limit -0.04em) | 0.98–1.02 |
| 48–72 px | -0.03em | 1.02–1.06 |
| 32–48 px | -0.025em | 1.08–1.12 |
| 24–32 px | -0.015…-0.02em | 1.15–1.2 |
| 19–24 px | -0.01em | 1.3–1.4 |
| 15–18 px | 0 (leave as is) | 1.45–1.55 |
| 12–14 px | +0.005…+0.01em | 1.33–1.4 |
- **Light text on dark:** leading +0.03–0.05, tracking +0.005em, weight 20–40 axis units lighter (400 → 370, 600 → 570). Light on dark visually "gains weight".
- **Text on glass and over photos (vibrancy):** the opposite, one step denser (labels 500 instead of 400) and higher contrast.
- Capitals appear only in abbreviations: tracking 0…+0.03em.
- Optical alignment: a display heading starting with a round or diagonal letter («О», «А», "V") is shifted -0.02…-0.04em left when it is edge-aligned with text.
### 4.5 Weights
- **400** text, **500** controls (buttons, navigation, field labels), **600** headings. The hero may use 600–650 via the variable axis.
- At most three weights in use. 700+ only in a deliberate poster mode (ENERGY 3).
- Secondary text is distinguished by **ink-2 color**, not by a lighter weight or smaller size.
### 4.6 Line breaks and measure
- **h1:** at most 2 lines at any width, up to 7 words, measure about `20ch`. h2 up to `30ch`, h3 up to `40ch`.
- **Paragraph:** 45–75 characters (`max-inline-size: 65ch`), 35–60 on mobile.
- `text-wrap: balance` for h2–h4, short centered blocks and captions; `text-wrap: pretty` for paragraphs (no one-word last lines). h1: an authored `<br>` by meaning, verified at all widths, or `balance` (§2 #11).
- **Non-breaking spaces (RU)** after short words: в, к, с, о, у, и, а, но, на, по, за, из, от, до, не, ни; before «ли», «же», «бы»; between a number and its unit.
- `hyphens: manual` by default; never hyphenate headings. URLs and e-mails: `overflow-wrap: anywhere`. Display headings: `overflow-wrap: break-word` and `min-width: 0` on the grid/flex parent.
- Space above a heading is at least twice the space below it. Paragraphs are separated by spacing, not first-line indents (never both).
### 4.7 Typesetting details
```css
html {
font-family: var(--font-sans);
font-size: 100%; /* respect the user's setting */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-synthesis: none;
font-kerning: normal;
text-size-adjust: 100%;
}
.num, td, .price, time { font-variant-numeric: tabular-nums; }
.prose a {
color: var(--accent-text);
text-decoration-line: underline;
text-decoration-thickness: 0.07em;
text-underline-offset: 0.18em;
}
code, pre { font-family: var(--font-mono); font-size: 0.92em; letter-spacing: 0; }
kbd { font-family: system-ui, -apple-system, sans-serif; font-size: 0.92em; } /* ⌘ ⌥ ⇧ exist in system fonts */
```
- Mono only for real code, commands and measurements.
- Numbers in copy are written as digits («3 шага», not «три шага»), with a non-breaking space between number and unit.
### 4.8 Russian typography
- Guillemets «ёлочки», inner „лапки“; ellipsis `…`; number sign `№`; apostrophe `’`.
- Money and numbers: `1 290 ₽`, `12 500` (digit groups and currency sign with a non-breaking space; `Intl.NumberFormat("ru-RU")` inserts it). Minus in numbers: `−20 %` (U+2212), not a hyphen.
- Units with a non-breaking space: `10 МБ`, `5 мин`, `<kbd>⌘</kbd> <kbd>K</kbd>`. Write `%` consistently across the project.
- Dates and time: `12 марта 2026`, `18:30`, via `Intl.DateTimeFormat("ru-RU", …)`.
- Ranges in UI phrases as words («от 10 до 20 минут»); `10–20` (en dash, no spaces) is fine in tables and specs.
- Headings and buttons in sentence case («Скопировать команду», not «Скопировать Команду»). No all-caps lines.
- The letter «ё»: one rule for the whole project (using it is recommended).
### 4.9 Onest alternatives (Cyrillic verified)
| Font | Weights | Character | When |
|---|---|---|---|
| **Inter Tight** | 100–900, `tnum`, `cv01–11`, `zero` | dense display Inter | closest in feel to SF Pro Display |
| **Geist** | 100–900, `tnum`, `ss01–ss11` | neutral "Vercel grotesque" | dev tools; needs a recorded reason (antislop lists it as a default) |
| **Manrope** | 200–800, `tnum` | geometric, wider | friendly consumer products |
| **Golos Text** | 400–900, `tnum`, `onum` | ParaType's UI grotesque | fintech, services, long reads |
| **Unbounded** | 200–900 | wide, expressive | promo headlines only, not Apple style |
**Not for Russian (no Cyrillic):** Mona Sans, Schibsted Grotesk, Bricolage Grotesque, Instrument Sans / Serif, Figtree, Albert Sans, Host Grotesk, Funnel Display, Outfit, Space Grotesk, DM Sans, Sora, Urbanist, Lexend, Archivo, Familjen Grotesk, Public Sans, Fraunces.
Apple style needs no second display face: one grotesque plus mono for code.
---
## 5. Color
### 5.1 Proportions
- **Background 70–90 % of the screen, then text; accent under 5 %.** Color and emotion come from photos, video and the product, not the UI.
- **One accent** for everything interactive: links, primary button, focus, selected state. No second brand color in the UI. The brand lives in the logo, the media and the one primary action.
- **Sections differ by surface lightness,** not lines: white → "parchment" `#f5f5f7` → black. Adjacent fields always differ in lightness; the sequence reads like chapters.
- **Apple neutrals are already slightly cool** (blue channel 2–5 units higher: `#1d1d1f`, `#f5f5f7`, `#6e6e73`). That is the tinting; there are no "dead" zero-chroma grays.
### 5.2 Tokens and verified contrast (WCAG 2.x)
| Token | Light | Dark | Role | Contrast (light / dark) |
|---|---|---|---|---|
| `--bg` | `#ffffff` | `#000000` | canvas | — |
| `--bg-2` | `#f5f5f7` | `#161617` | alternate section | — |
| `--surface` | `#ffffff` | `#1d1d1f` | card, field, panel | — |
| `--surface-2` | `#fafafc` | `#272729` | nested surface, hover tone | — |
| `--ink` | `#1d1d1f` | `#f5f5f7` | primary text | 16.83 on #fff, 15.46 on #f5f5f7 / 19.29 on #000, 15.46 on #1d1d1f |
| `--ink-2` | `#6e6e73` | `#a1a1a6` | secondary text, placeholders | 5.07 on #fff, 4.66 on #f5f5f7 / 8.16 on #000, 6.54 on #1d1d1f, 5.80 on #272729 |
| `--ink-3` | `#86868b` | `#86868b` | disabled, large secondary figures | 3.62 on #fff (≥ 24 px only) / 5.80 on #000, 4.65 on #1d1d1f |
| `--hairline` | `rgb(0 0 0 / .08)` | `rgb(255 255 255 / .10)` | dividers (rare) | — |
| `--fill` | `rgb(0 0 0 / .04)` | `rgb(255 255 255 / .08)` | secondary buttons, chips, hover | — |
| `--fill-2` | `rgb(0 0 0 / .08)` | `rgb(255 255 255 / .14)` | pressed, selected segment | — |
| `--accent` | `#0071e3` | `#0071e3` | primary fill | white text 4.70 |
| `--accent-hover` | `#006edb` | `#006edb` | primary hover (darker, not lighter) | white text 4.94 |
| `--accent-pressed` | `#0066cc` | `#0066cc` | primary pressed | white text 5.57 |
| `--accent-text` | `#0066cc` | `#2997ff` | links, text buttons | 5.57 on #fff, 5.11 on #f5f5f7 / 6.96 on #000, 5.58 on #1d1d1f, 4.94 on #272729 |
| `--focus` | `#0071e3` | `#2997ff` | focus ring | 4.70 on #fff / 6.96 on #000 |
| `--danger` | `#d70015` | `#ff6961` | error, delete | 5.38 on #fff, 4.94 on #f5f5f7 / 7.45 on #000, 5.97 on #1d1d1f |
| `--success` | `#207a39` | `#30d158` | success | 5.38 / 10.39 |
| `--warning` | `#c93400` | `#ff9f0a` | warning | 5.28 / 10.22 |
| `--on-media` | `#ffffff` | `#ffffff` | text over photo and video | only with a scrim (§5.5) |
| `--on-media-2` | `rgb(255 255 255 / .8)` | same | subtitle over media | background beneath no lighter than `#555` |
**Traps found during verification:**
- Apple's hover `#0077ed` with white text gives **4.32** (fails AA). Make the primary hover **darker**: `#006edb`.
- In dark theme the `#0a84ff` fill with white text gives **3.65**. The primary fill stays `#0071e3` in both themes; `#2997ff` is for text and focus only.
- `--ink-2` on `--fill-2` (`#6e6e73` on `#e8e8ed`) gives **4.15**. Labels on filled controls are always `--ink`.
- `--ink-3` on a light background is only for text from 24 px or for disabled.
### 5.3 Rules
- Body text always in full `--ink`. Secondary only via `--ink-2`, not `opacity` (opacity without a contrast measurement is banned).
- Semantic colors only for states and always paired with an icon or text (never color alone).
- On a colored or photo surface, secondary text takes that surface's hue or `--on-media-2` with verified contrast. No gray on color.
- Hover, active and focus **increase** contrast, never decrease it.
- Don't recolor photos to the brand color. The UI around media stays neutral.
- `<meta name="theme-color">` matches the page's first field (with a separate one for dark).
### 5.4 Dark theme
- **Derived, not inverted.** `color-scheme: light dark` on `:root`, colors via `light-dark()` (§16).
- **Depth via lighter surfaces:** `#000` → `#161617` → `#1d1d1f` → `#272729`. Not via glow or shadows.
- Check `--ink-2` on every surface (min 4.5:1). Accent for text `#2997ff`, fill `#0071e3`.
- Product screenshots have dark versions: `<picture><source srcset="…-dark.avif" media="(prefers-color-scheme: dark)">`.
- A "Light / Dark / System" switch; the choice persists and applies before first paint (inline script in `<head>`), with no flash. The theme change may be softened with a 200–300 ms crossfade via View Transitions; no abrupt brightness jumps.
### 5.5 Text over photo and video
- The lightest point of the background under white text is no lighter than `#767676` (white on `#767676` = 4.54:1). Under `--on-media-2` (80 % white), no lighter than `#555`.
- If the media is lighter, add a neutral scrim. This is the only allowed gradient.
```css
.media-scrim {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(120% 85% at 50% 42%, transparent 35%, rgb(0 0 0 / .55) 100%),
linear-gradient(to bottom, rgb(0 0 0 / .35), transparent 28%, transparent 58%, rgb(0 0 0 / .6));
}
```
- While media loads, the container's background is the image's average color (`background-color` sampled from the photo), so loading looks intentional.
- Measure contrast across the whole area under the text (at its lightest spot), not at the center.
### 5.6 Browser surfaces
```css
:root {
accent-color: var(--accent);
caret-color: var(--accent);
scrollbar-color: color-mix(in srgb, var(--ink) 28%, transparent) transparent;
}
::selection { background: color-mix(in srgb, var(--accent) 24%, transparent); color: inherit; }
```
Also style the focus ring (§13), link underlines (§4.7), `<meta name="theme-color">` and the favicon in both themes.
---
## 6. Grid, spacing, rhythm, responsive
### 6.1 Spacing scale (base 8, step 4 for small things)
| Token | rem | px | Use |
|---|---|---|---|
| `--space-1` | 0.25 | 4 | icon ↔ text gap in tight spots |
| `--space-2` | 0.5 | 8 | icon ↔ text gap, between chips |
| `--space-3` | 0.75 | 12 | inside compact controls |
| `--space-4` | 1 | 16 | field padding, gap within groups |
| `--space-5` | 1.5 | 24 | card padding (mobile), between paragraph and CTA |
| `--space-6` | 2 | 32 | card padding (desktop) |
| `--space-7` | 3 | 48 | section heading ↔ content |
| `--space-8` | 4 | 64 | air above a heading |
| `--space-9` | 5 | 80 | tile padding |
| `--space-10` | 6 | 96 | small section rhythm |
| `--space-11` | 7.5 | 120 | section rhythm |
| `--space-12` | 10 | 160 | large rhythm |
| `--space-13` | 12.5 | 200 | dramatic pause (at most once per page) |
No values outside the scale. Spacing within a group is smaller than between groups (proximity).
### 6.2 Containers
```css
--container: 80rem; /* 1280: main grid */
--container-wide: 90rem; /* 1440: media, tiles */
--measure: 65ch; /* text */
--gutter: clamp(1.25rem, 4vw, 4rem); /* 20 → 64 px */
--grid-gap: clamp(1rem, 2vw, 1.5rem); /* 16 → 24 px */
```
- Outer margins are equal left and right on all screens.
- At 1920 px the content stays centered within `--container-wide`; grids **don't add** columns.
- One container width, one column gap, one primary button and one radius per nesting level for the whole page.
### 6.3 Section rhythm
```css
--section-sm: clamp(4rem, 3rem + 4vw, 7.5rem); /* 64 → 120 */
--section: clamp(5rem, 3rem + 7vw, 10rem); /* 80 → 160 */
--section-lg: clamp(6rem, 3rem + 10vw, 12.5rem); /* 96 → 200 */
```
- Adjacent sections use **different** values: the rhythm breathes, it doesn't tick.
- If a surface change separates the section, `--section-sm` is enough: the color already works as a divider.
- Air above a heading is at least twice the air below it (§4.6).
- Every section has a heading. One `h1` per page.
### 6.4 Screen and budget
- **One screen, one idea, about 80 words.**
- **Hero:** headline, one sentence and the primary action are visible without scrolling at 1280×800 and 390×844.
- In a product hero, media takes at least 40–50 % of the first screen.
- No near-empty screens, except one intentional pause per page.
### 6.5 Hero patterns
| Pattern | When | Composition |
|---|---|---|
| **A. Cinematic** (the reference) | launch, brand, a tool with a strong image | full-bleed media, scrim, centered h1 + lead + command field or CTA, footnote (§17) |
| **B. Product tile** (apple.com) | physical or digital product | white or black field, centered name + one phrase + pill CTA + text link, large render below with a product shadow |
| **C. Split** | apps, dev tools | text on the left across 5–6 columns, real screenshot or video on the right across 6–7, left-aligned |
### 6.6 Grid
```css
.grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
column-gap: var(--grid-gap);
}
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); gap: var(--grid-gap); }
```
- Tracks are always `minmax(0, 1fr)`: long words and images don't blow out the grid.
- No lone item in the last row: change the column count or make the item wider.
- Asymmetry over symmetry: 7/5, 8/4, "image wider than text".
### 6.7 Responsive
- Mobile-first, standard breakpoints (Tailwind: 640 / 768 / 1024 / 1280 / 1536). Check at **320, 390, 768, 1024, 1280×800, 1440×900, 1920**.
- Mobile content is the same as desktop (no "cut-down" version).
- Hero: `min-height: 100svh`; full-screen overlays: `100dvh`. Never bare `100vh`.
- `html, body { overflow-x: clip; }`. Not `hidden` (breaks `position: sticky`) and never `width: 100vw` (horizontal scroll because of the scrollbar).
- `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`; fixed elements respect `env(safe-area-inset-*)`.
### 6.8 Layers and sticky
```css
--z-raised: 10; --z-sticky: 20; --z-header: 40; --z-popover: 60; --z-overlay: 100; --z-toast: 1000;
```
- Only one `position: sticky; top: 0` element at a time. Two sticky elements at the same offset conflict.
- `html { scroll-padding-top: calc(var(--header-h) + 1rem); }`: anchors and focus don't hide under the header (WCAG 2.4.11).
---
## 7. Surfaces, radii, shadows, glass
### 7.1 Surface ladder
Air → tone change (`--bg-2`) → surface (`--surface` + radius) → floating layer (shadow, glass if needed).
- A card is separated by **tone**, not by a border or shadow. A `--hairline` border only for structure: tables, lists, inputs.
- One means per surface: tone, border **or** shadow, never two. Exception: floating layers in dark theme (1px ring + shadow, otherwise the edge is invisible).
- In light theme a raised surface is never darker than the canvas; in dark it is always lighter.
### 7.2 Radii
| Token | px | Use |
|---|---|---|
| `--radius-xs` | 6 | `kbd`, inline tags, small previews |
| `--radius-sm` | 10 | menu items, thumbnails, tooltips |
| `--radius-field` | 12 | form fields, `textarea`, `select` |
| `--radius-md` | 16 | menus, popovers |
| `--radius-card` | 22 | cards, media inside a grid |
| `--radius-panel` | 28 | large panels, dialogs, sheet top |
| `--radius-pill` | 999 | buttons, search and command field, chips, segments, toast |
- **Concentric:** inner radius = outer − padding. Menu 16 with padding 6 → item 10. Card 22 with padding 8 → image 14. With padding over 24 px the surfaces are independent.
- Full-bleed media and full-width tiles: radius 0.
- Pill shape only for controls. Cards and panels are never pills.
### 7.3 Shadows
```css
--shadow-float: 0 1px 1px rgb(0 0 0 / .04), 0 4px 12px rgb(0 0 0 / .06), 0 16px 40px rgb(0 0 0 / .10); /* menu, popover, glass bar */
--shadow-modal: 0 2px 4px rgb(0 0 0 / .04), 0 12px 32px rgb(0 0 0 / .12), 0 40px 80px rgb(0 0 0 / .16); /* dialog, sheet */
--shadow-product: drop-shadow(3px 5px 30px rgb(0 0 0 / .22)); /* filter: only under a product render */
/* dark theme */
--shadow-float-dark: 0 0 0 1px rgb(255 255 255 / .08), 0 16px 40px rgb(0 0 0 / .5);
```
- Shadow means elevation: menu, popover, dialog, sheet, floating bar. Plus one product shadow under a render.
- At rest, cards, buttons and text have no shadow. No colored shadows or glow.
- Shadows are layered (contact + ambient), not one big blur.
- Animating a shadow is expensive: animate the `opacity` of a pseudo-element carrying the shadow, or `filter: drop-shadow`.
- Images inside light surfaces get a thin outline so light edges don't dissolve:
`img { outline: 1px solid rgb(0 0 0 / .1); outline-offset: -1px; }` (in dark theme `rgb(255 255 255 / .1)`).
### 7.4 Glass: where it is allowed
| Allowed (functional layer) | Not allowed (content layer) |
|---|---|
| header after scrolling starts and over media | cards, sections, page backgrounds |
| sticky submenu, floating toolbar | text blocks, tables |
| dropdown menus and popovers over content | buttons on a flat background (use a solid fill) |
| non-modal sheet / drawer | modal dialog (it gets a scrim + solid panel) |
| controls over photo and video (play, mute, close) | glass on glass |
| pill CTA and command field over hero media | "everything at once" |
- At most **two glass roles** per viewport. Example: header + hero command field.
- **Regular** (header, menus; lots of text): fill 72–80 %, `blur(20–30px)`, `saturate(1.4–1.6)`.
- **Clear** (over media): white fill 12–20 %, `blur(8–16px)`; only over sufficiently dark media or with a ~35 % dimming layer.
- Labels on glass are monochrome and only in the primary color (`--ink` or `--on-media`). `--ink-2` on light glass fails contrast.
- Color highlights the background of the main action, not the text. At most 1–2 tinted glass buttons per screen, never adjacent.
### 7.5 Glass: recipes and fallbacks
```css
/* Regular: header, menus, toolbar */
.glass {
background-color: color-mix(in srgb, var(--bg) 72%, transparent);
-webkit-backdrop-filter: blur(24px) saturate(1.5);
backdrop-filter: blur(24px) saturate(1.5);
}
.glass--panel { /* standalone floating panel */
border: 1px solid var(--glass-border);
box-shadow: inset 0 1px 0 var(--glass-highlight), var(--shadow-float);
}
/* Clear: controls and command field over media */
.glass-media {
color: var(--on-media);
background-color: var(--glass-media-fill); /* rgb(255 255 255 / .14) */
border: 1px solid var(--glass-media-border); /* rgb(255 255 255 / .18) */
-webkit-backdrop-filter: blur(12px) saturate(1.4);
backdrop-filter: blur(12px) saturate(1.4);
}
/* Fallbacks: mandatory */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass { background-color: var(--surface); }
.glass-media { background-color: var(--media-solid); } /* #1d1d1f */
}
@media (prefers-reduced-transparency: reduce) {
.glass, .glass-media { -webkit-backdrop-filter: none; backdrop-filter: none; }
.glass { background-color: var(--surface); }
.glass-media { background-color: var(--media-solid); }
}
@media (prefers-contrast: more) {
.glass { background-color: var(--surface); border-color: var(--ink-3); }
.glass-media { background-color: var(--media-solid); border-color: rgb(255 255 255 / .6); }
}
@media (forced-colors: active) {
.glass, .glass-media { background: Canvas; color: CanvasText; border: 1px solid CanvasText; }
}
```
**Legibility thresholds (computed):**
- Dark glass with white text over white content needs a fill **≥ 60 %** (4.81:1; at 55 % only 4.06). 80 % white on dark glass needs **≥ 70 %** (5.11:1). Hence the 72 % default.
- Light glass at 60 %+ with `--ink` reads even over black (5.91:1). `--ink-2` on light glass never passes (≤ 3.16 even at 80 %).
- Clear glass (12 % white) with white text passes only over backgrounds darker than about `#555` (over `#666` it's 4.42).
**Performance:** glass areas are small and few; don't animate the blur radius over large areas; `-webkit-backdrop-filter` is mandatory for Safari; after the production build, check that the minifier didn't drop the prefix or the property itself.
### 7.6 Header edge on scroll (scroll-edge)
Instead of a permanent border, a soft edge appears under the header only when content is beneath it.
```css
.site-header::after {
content: "";
position: absolute;
inset-inline: 0;
top: 100%;
height: 1.5rem;
pointer-events: none;
background: linear-gradient(to bottom, color-mix(in srgb, var(--bg) 55%, transparent), transparent);
opacity: 0;
transition: opacity var(--dur-ui) var(--ease-out);
}
.site-header[data-scrolled="true"]::after { opacity: 1; }
```
Alternative: `border-bottom: 1px solid var(--hairline)` that appears only after scrolling starts.
---
## 8. Icons (Lucide)
### 8.1 Packages (current version 1.67)
| Stack | Package |
|---|---|
| React / Next.js | `lucide-react` |
| Vue | `@lucide/vue` (the old `lucide-vue-next` is stuck at 1.0.x) |
| Svelte | `@lucide/svelte` |
| Angular | `@lucide/angular` |
| Astro | `@lucide/astro` |
| Vanilla JS | `lucide` (`createIcons`) |
| Static SVG / sprite | `lucide-static` |
Lucide defaults: 24 px, `strokeWidth` 2, round caps and joins, `currentColor`. Lucide has no brand icons (GitHub, X, etc.): use the brands' official SVGs.
### 8.2 Size and stroke
| Context | Size | `strokeWidth` |
|---|---|---|
| Inside small text 13–15 px | 16 | 1.5 |
| Buttons, fields, 17 px text | 18–20 | 1.75 |
| Navigation, 44 px icon button | 20–24 | 1.75 (next to 600: 2) |
| Large 32–48 px | 32–48 | `absoluteStrokeWidth` + 1.5 |
- Match stroke to the weight of adjacent text: thinner next to 400, thicker next to 600.
- An inline icon is about 1–1.25 em; its center aligns with the center of the lowercase letters, not the baseline.
- One icon set for the whole product. Two libraries side by side are banned.
```tsx
import { LucideProvider } from "lucide-react";
export function Providers({ children }: { children: React.ReactNode }) {
return <LucideProvider size={20} strokeWidth={1.75}>{children}</LucideProvider>;
}
```
Without the provider, CSS is enough: `.lucide { stroke-width: 1.75; }` (CSS beats the attribute).
### 8.3 Imports
- Named imports only: `import { Copy, Check } from "lucide-react"`. `import * as Icons` breaks tree-shaking.
- `DynamicIcon` from `lucide-react/dynamic` only for names coming from a CMS (icons load lazily).
- In 1.x some names became aliases: `Loader2` → `LoaderCircle`, `Trash2` → `Trash`, `AlertTriangle` → `TriangleAlert`. New code uses the canonical names.
### 8.4 Accessibility
- `lucide-react` marks decorative icons `aria-hidden="true"` itself, as long as the icon has no `aria-*`, `title`, `role` or children.
- Icon-only button: `aria-label` on the `<button>`, not on the icon. The visible area may be 32–36 px; the hit area is at least 44×44 (pseudo-element `::before { inset: -6px }`).
- An icon is never the only carrier of meaning for an important action: add text, or a tooltip plus `aria-label`.
### 8.5 Choosing by meaning
| Meaning | Icon |
|---|---|
| Copy → copied | `Copy` → `Check` |
| External link | `ArrowUpRight` |
| Dropdown | `ChevronDown` (14–16 px) |
| Go to, expand a row | `ChevronRight` |
| Mobile menu → close | `Menu` → `X` |
| Search, download, share | `Search`, `Download`, `Share` |
| Media | `Play`, `Pause`, `Volume2`, `VolumeX` |
| Settings, filters | `Settings2`, `SlidersHorizontal` |
| States | `Info`, `TriangleAlert`, `CircleAlert`, `CircleCheck` |
| Theme | `Sun`, `Moon`, `Monitor` (system) |
| Terminal, command | `SquareTerminal` |
| Delete, add | `Trash`, `Plus` |
| Loading | `LoaderCircle` + CSS rotation (only while loading) |
### 8.6 Styling
- Color only via `currentColor` and CSS. Outline by default; a fill (`fill="currentColor"`) on closed-shape icons (`Heart`, `Bookmark`, `Star`) means the active state.
- Disabled: muted `--ink-3` color + the `disabled` attribute, not opacity alone.
- Optical alignment: a button with a leading icon gets 2 px less padding on the icon side; shift the `Play` triangle 1 px right.
- In RTL, mirror directional icons (`ChevronRight`, `ArrowUpRight`).
### 8.7 Icon motion
- **Icon swap** (Copy → Check, Menu → X, Play → Pause): `scale 0.25 → 1`, `opacity 0 → 1`, `blur(4px) → 0`, a no-bounce spring of ~0.3 s (recipe R5).
- **Menu chevron:** `rotate(180deg)` over `--dur-ui` when `aria-expanded="true"`. For nested `<path>` / `<g>` set `transform-box: fill-box; transform-origin: center`.
- **Stroke drawing** only for a rare success moment (payment went through, form sent): recipe R10.
- Icons never move at rest: no wiggles, pulses or spins.
---
## 9. Motion (Motion + anime.js)
### 9.1 Philosophy and the frequency filter
Motion explains: what changed, where it came from, what happens on press. If an animation explains nothing and gives no feedback, it doesn't exist.
| How often the user sees it | Example | Decision |
|---|---|---|
| 100+ times a day | hotkeys, command palette, in-app tab switching, typing | no animation (at most 120 ms on color or opacity) |
| dozens of times | hover, list navigation, toggles | minimal: 120–180 ms |
| occasionally | menus, modals, sheets, toasts | standard: 180–450 ms |
| rarely | hero, onboarding, successful purchase | "delight" allowed: 600–1200 ms, one scene |
- Keyboard-initiated actions are not animated.
- Feedback under 100 ms, on `pointerdown` (`:active`), not after `click`.
- Everything is interruptible: input isn't blocked during animation; a new animation starts from the current value.
- The exit retraces the enter path, 25–40 % faster, also `ease-out`.
- Motion is never the only carrier of information.
### 9.2 Choosing the tool: the cheapest one that does the job
| Task | Tool |
|---|---|
| hover, focus, press, color, background | CSS `transition` |
| Popover, dialog, toast entry | CSS `@starting-style` + `transition-behavior: allow-discrete` |
| Hero first load, loading indicators | CSS `@keyframes` |
| Programmatic animation without a library | WAAPI `element.animate()` |
| React: mount/unmount, layout, shared element, gestures, scroll-linked, springs | **Motion** (`motion/react`) |
| Vanilla: in-view entry, scroll, springs, stagger | **Motion** (`motion`: `animate`, `inView`, `scroll`, `stagger`, `hover`, `press`) |
| Splitting text into lines and words, SVG drawing and morphing, complex timelines, counters | **anime.js v4** (`splitText`, `createDrawable`, `morphTo`, `createTimeline`) |
| Page and state transitions | View Transitions API (CSS); Motion `animateView` |
| GSAP | only if the project already uses GSAP. Never mix three engines in one component |
- One element is animated by one engine.
- React: import only from `motion/react` (never `framer-motion`; don't keep both packages). In server components use `import * as motion from "motion/react-client"`.
- Bundle: in large apps use `LazyMotion` + `domAnimation` (or `domMax` for layout and drag) and the `m` component instead of `motion`.
- Import anime.js per module (`import { animate } from "animejs"` tree-shakes); for simple hardware-accelerated animations there is the light `waapi.animate`.
### 9.3 Motion tokens
```css
:root {
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* UI: enters, hover, menus (< 600 ms) */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* reveals, headline lines (≥ 600 ms) */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* on-screen movement, morphs, drawers */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* sheet, drawer (iOS-like) */
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1); /* slow photo scale and drift */
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19); /* only for "leaving for good", ≤ 200 ms */
--dur-press: 120ms; --dur-hover: 180ms; --dur-ui: 240ms; --dur-overlay: 320ms;
--dur-sheet: 450ms; --dur-reveal: 800ms; --dur-open: 1100ms;
--stagger-line: 90ms; --stagger-item: 60ms; --stagger-char: 24ms;
}
```
```ts
// motion-tokens.ts (Motion)
export const ease = {
out: [0.23, 1, 0.32, 1],
outExpo: [0.16, 1, 0.3, 1],
inOut: [0.77, 0, 0.175, 1],
drawer: [0.32, 0.72, 0, 1],
} as const;
export const spring = {
ui: { type: "spring", bounce: 0, visualDuration: 0.3 }, // icons, toggles, menus
move: { type: "spring", bounce: 0, visualDuration: 0.4 }, // layout, layoutId
sheet: { type: "spring", bounce: 0, visualDuration: 0.5 }, // sheet, drawer
flick: { type: "spring", bounce: 0.2, visualDuration: 0.45 }, // only after a fling with momentum
} as const;
```
```js
// anime-tokens.js (anime.js v4)
import { cubicBezier, spring } from "animejs";
export const animeEase = {
out: cubicBezier(0.23, 1, 0.32, 1),
outExpo: "outExpo",
inOut: cubicBezier(0.77, 0, 0.175, 1),
ui: spring({ bounce: 0, duration: 300 }), // duration: perceived duration, ms
};
```
**Curves:** enter and exit `ease-out`; moving or morphing an already visible element `ease-in-out`; color change `ease`; constant motion (progress) `linear`. **No `ease-in` in UI** (except `--ease-exit`, §2 #13). Don't use the stock `cubic-bezier(.4, 0, .2, 1)` or springs with overshoot.
**Springs:** no bounce by default (`bounce: 0`). `bounce` 0.15–0.2 only after a gesture with momentum (flinging a sheet, swiping a card). For computed values use `visualDuration`: it sets the time until the visual stop.
| What | Duration | Curve |
|---|---|---|
| Press (`scale(0.97)`) | 120 ms | `--ease-out` |
| Hover: color, background | 180 ms | `ease` |
| Tooltip | 120–180 ms, subsequent ones instant | `--ease-out` |
| Menu, popover, select | 180 enter / 120 exit | `--ease-out`, origin at the trigger |
| Icon swap, toggle, segment | `spring.ui` | — |
| Accordion (`grid-template-rows: 0fr → 1fr`) | 240–320 ms | `--ease-in-out` |
| Modal | 240–320 enter / 180–200 exit | `--ease-out` |
| Sheet, drawer | 450 ms or `spring.sheet` | `--ease-drawer` |
| Toast | 320 enter / 200 exit | `--ease-out` |
| Section reveal | 700–900 ms | `--ease-out-expo` |
| Hero sequence | ≤ 1200 ms in total | `--ease-out-expo` |
| Image "opening" (`clip-path`) | 1000–1300 ms | `--ease-in-out` + scale `--ease-soft` |
| Page transition | old 200 / new 380 (+60 ms) | `--ease-out` |
**Distances and steps:**
- Rise on entry 12–32 px (4–8 px in UI), never more than 40 px.
- Scale from 0.95–0.98, never from 0 and never below 0.9.
- Blur on entry at most 8 px (4 px for icons), never on body text.
- Stagger: lines 80–110 ms, items 50–70 ms, characters 20–40 ms (only for 1–3 words). At most 4–6 steps; the whole sequence lasts at most ~500 ms beyond the item duration. Don't stagger frequent elements.
- Opacity never lingers below 0.2 for more than a few frames; a scrim dims at least 40 %.
### 9.4 Motion budget
| Dial | Allowed |
|---|---|
| **M1** | control feedback (press, hover, focus), menus and dialogs, one entry pattern |
| **M2** (default) | + CSS hero sequence, 2–3 entry groups, icon swaps, segment indicator, glass header on scroll |
| **M3** | + one scroll scene (sticky scene or "opening window"), one "living image" (muted looping video with a poster), counters of real figures |
- Per page: **one** entry pattern and **one** scroll choreography. 1–2 key animated elements per viewport.
- Nothing moves across text the person is reading.
- The first screen speaks; the rest stays calm.
**Apple-style signature moves (pick one per page):**
1. **Masked headline:** lines slide up from under a mask (R2, R9).
2. **"Opening window":** framed media with a radius expands to full-bleed over one screen of scroll (R11).
3. **Sticky scene:** the product holds in place while text blocks replace each other.
4. **Phrase fill:** a large statement fills with color as you scroll (from 45 % to 100 % brightness).
5. **Running numbers:** real values only (R12).
6. **Carried image:** an image "moves" onto the next page (R13).
7. **Horizontal gallery** on `scroll-snap`, without scroll hijacking.
### 9.5 Recipes
**R1. JS gate and safety net.** Hide before animating only when JS is present and reduced motion is off. If scripts haven't loaded within 2.5 s, everything is shown.
```html
<html lang="ru" class="no-js">
<head>
<script>
document.documentElement.classList.replace("no-js", "js");
setTimeout(function () {
if (!window.__motionReady) document.documentElement.classList.add("motion-failsafe");
}, 2500);
</script>
```
```css
@media (prefers-reduced-motion: no-preference) {
.js [data-reveal] { opacity: 0; transform: translateY(24px); }
}
.motion-failsafe [data-reveal] { opacity: 1 !important; transform: none !important; }
```
After animations initialize: `window.__motionReady = true`. In Next.js, put the inline script in the root layout's `<head>`.
**R2. Hero load in pure CSS** (starts on first paint, doesn't wait for hydration; everything finishes by ~1.1 s).
```html
<h1 class="hero__title">
<span class="line" style="--i:0"><span>Интерфейсы</span></span>
<span class="line" style="--i:1"><span>без шаблонов</span></span>
</h1>
```
```css
.hero__title .line { display: block; overflow: clip; padding-block-end: .08em; margin-block-end: -.08em; }
.hero__title .line > span { display: inline-block; }
@media (prefers-reduced-motion: no-preference) {
.hero__title .line > span {
animation: line-rise var(--dur-reveal) var(--ease-out-expo) both;
animation-delay: calc(80ms + var(--i, 0) * var(--stagger-line));
}
.hero__lead { animation: fade-rise 600ms var(--ease-out) 380ms both; }
.hero__cta { animation: fade-rise 600ms var(--ease-out) 460ms both; }
.hero__media img { animation: media-settle var(--dur-open) var(--ease-soft) both; }
}
@keyframes line-rise { from { transform: translateY(105%); } }
@keyframes fade-rise { from { opacity: 0; transform: translateY(12px); } }
@keyframes media-settle { from { transform: scale(1.08); } }
```
- At most 4 elements in the sequence. Don't hide the hero image (LCP) with opacity and don't fully cover it with `clip-path`: soft scale only.
- Padding plus a negative margin on `.line` preserves descenders (у, р, д).
**R3. Reveal on scroll.**
Vanilla (Motion):
```js
import { animate, inView, stagger } from "motion";
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
inView("[data-reveal-group]", (group) => {
animate(
group.querySelectorAll("[data-reveal]"),
{ opacity: [0, 1], y: [24, 0] },
{ duration: 0.8, ease: [0.16, 1, 0.3, 1], delay: stagger(0.06) }
);
// callback returns nothing → fires once
}, { margin: "0px 0px -12% 0px" });
}
window.__motionReady = true;
```
React (SSR-safe: content is visible without JS because only CSS under `.js` hides it):
```tsx
"use client";
import { useRef } from "react";
import { useInView } from "motion/react";
export function Reveal({ children, index = 0 }: { children: React.ReactNode; index?: number }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -12% 0px" });
return (
<div ref={ref} data-reveal data-inview={inView || undefined} style={{ "--i": index } as React.CSSProperties}>
{children}
</div>
);
}
```
```css
@media (prefers-reduced-motion: no-preference) {
.js [data-reveal] {
transition: opacity var(--dur-reveal) var(--ease-out-expo), transform var(--dur-reveal) var(--ease-out-expo);
transition-delay: calc(var(--i, 0) * var(--stagger-item));
}
.js [data-reveal][data-inview] { opacity: 1; transform: none; }
}
```
`initial={{ opacity: 0 }}` on a server-rendered `motion.div` hides content if JS fails. Use it only in client components that appear after hydration (dialog contents, tabs).
**R4. Press and hover.**
```css
.btn {
transition: transform var(--dur-press) var(--ease-out),
background-color var(--dur-hover) ease, color var(--dur-hover) ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.btn:active:not(:disabled) { transform: scale(0.97); }
@media (hover: hover) and (pointer: fine) {
.btn--primary:hover { background-color: var(--accent-hover); }
.btn--secondary:hover { background-color: var(--fill-2); }
}
```
In Motion: `whileTap={{ scale: 0.97 }}` on elements that are already `motion`. No `whileHover` with scale.
**R5. Command field with copy** (as in the reference).
```tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, Copy } from "lucide-react";
const swap = {
initial: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
exit: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
};
export function CommandField({ command }: { command: string }) {
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
async function copy() {
try {
await navigator.clipboard.writeText(command);
setStatus("copied");
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setStatus("idle"), 1800);
} catch {
setStatus("error");
}
}
return (
<div className="command glass-media">
<code translate="no">{command}</code>
<button type="button" className="command__copy" onClick={copy} aria-label="Скопировать команду">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status === "copied" ? "check" : "copy"}
{...swap}
transition={{ type: "spring", bounce: 0, visualDuration: 0.3 }}
style={{ display: "inline-flex" }}
>
{status === "copied" ? <Check size={18} strokeWidth={2} /> : <Copy size={18} strokeWidth={1.75} />}
</motion.span>
</AnimatePresence>
</button>
<span role="status" aria-live="polite" className="sr-only">
{status === "copied" ? "Команда скопирована" : status === "error" ? "Не удалось скопировать. Выделите команду вручную" : ""}
</span>
</div>
);
}
```
**R6. Menu and popover** (origin at the trigger, 180 ms enter, 120 ms exit, interruptible).
```css
/* Native popover */
.menu {
transform-origin: top center;
transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out),
overlay 180ms allow-discrete, display 180ms allow-discrete;
}
.menu:not(:popover-open) { opacity: 0; transform: scale(.96) translateY(-4px); transition-duration: 120ms; }
@starting-style { .menu:popover-open { opacity: 0; transform: scale(.96) translateY(-4px); } }
/* Base UI */
.popup { transform-origin: var(--transform-origin); transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out); }
.popup[data-starting-style], .popup[data-ending-style] { opacity: 0; transform: scale(.96); }
.popup[data-ending-style] { transition-duration: 120ms; }
/* Radix */
.radix-menu { transform-origin: var(--radix-dropdown-menu-content-transform-origin); }
```
- Modal dialogs are the exception: they appear centered, origin not at the trigger.
- Hover navigation menus: open after 80–120 ms, close 200–300 ms after the pointer leaves, plus click, Enter and Space. Esc closes and returns focus to the trigger.
**R7. Segmented control** (shared element via `layoutId`, unique per instance).
```tsx
"use client";
import { useId } from "react";
import { motion } from "motion/react";
export function Segmented({ options, value, onChange }: {
options: { id: string; label: string }[]; value: string; onChange: (id: string) => void;
}) {
const uid = useId();
return (
<div role="radiogroup" className="segmented">
{options.map((o) => (
<button key={o.id} type="button" role="radio" aria-checked={value === o.id}
className="segmented__item" onClick={() => onChange(o.id)}>
{value === o.id && (
<motion.span layoutId={`${uid}-thumb`} className="segmented__thumb"
transition={{ type: "spring", bounce: 0, visualDuration: 0.3 }} />
)}
<span className="segmented__label">{o.label}</span>
</button>
))}
</div>
);
}
```
Take keyboard behavior (arrows, roving tabindex) from a primitive (Radix Toggle Group, Base UI, React Aria) and add only the "thumb".
**R8. Header turns to glass after scrolling starts** (cheap, no scroll handler).
```html
<div id="top-sentinel" aria-hidden="true" style="position:absolute;top:0;width:1px;height:1px"></div>
```
```js
const header = document.querySelector(".site-header");
new IntersectionObserver(([entry]) => {
header.dataset.scrolled = String(!entry.isIntersecting);
}).observe(document.getElementById("top-sentinel"));
```
```css
.site-header {
background-color: transparent;
-webkit-backdrop-filter: blur(0) saturate(1);
backdrop-filter: blur(0) saturate(1);
transition: background-color var(--dur-ui) var(--ease-out),
-webkit-backdrop-filter var(--dur-ui) var(--ease-out),
backdrop-filter var(--dur-ui) var(--ease-out);
}
.site-header[data-scrolled="true"] {
background-color: color-mix(in srgb, var(--bg) 72%, transparent);
-webkit-backdrop-filter: blur(24px) saturate(1.5);
backdrop-filter: blur(24px) saturate(1.5);
}
```
In React the same via `useScroll` + `useMotionValueEvent(scrollY, "change", (y) => setScrolled(y > 8))`: state changes only when the threshold is crossed.
**R9. Section heading lines from under a mask** (anime.js `splitText`, re-splits on resize).
```js
import { animate, createScope, onScroll, splitText, stagger } from "animejs";
const scope = createScope({
mediaQueries: { reduce: "(prefers-reduced-motion: reduce)" },
}).add((self) => {
if (self?.matches.reduce) return; // self is typed as optional in anime.js 4
const splits = [...document.querySelectorAll("[data-split-lines]")].map((el) => {
const split = splitText(el, { lines: { wrap: "clip" } });
split.addEffect(({ lines }) =>
animate(lines, {
y: ["105%", "0%"],
duration: 800,
ease: "outExpo",
delay: stagger(90),
autoplay: onScroll({ target: el, enter: "85% top", repeat: false }),
})
);
return split;
});
return () => splits.forEach((s) => s.revert());
});
// SPA: scope.revert() on unmount
```
```css
[data-split-lines] [data-line] { padding-block-end: .1em; }
[data-split-lines] span:has(> [data-line]) { margin-block-end: -.1em; }
```
`splitText` keeps accessible text for screen readers by default. `enter: "85% top"` means "the heading's top reached 85 % of the viewport height".
**R10. Drawing a Lucide icon** (rare success moment only).
```js
import { animate, createDrawable } from "animejs";
// <svg class="lucide lucide-check success-icon">…<path d="M20 6 9 17l-5-5"/></svg>
animate(createDrawable(".success-icon path"), { draw: ["0 0", "0 1"], duration: 420, ease: "outExpo" });
```
**R11. "Opening window" on scroll** (M3, one scene per page).
```tsx
"use client";
import { useRef } from "react";
import { motion, useMotionTemplate, useReducedMotion, useScroll, useTransform } from "motion/react";
export function OpeningWindow({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLElement>(null);
const reduce = useReducedMotion();
const { scrollYProgress } = useScroll({ target: ref, offset: ["start start", "end end"] });
const inset = useTransform(scrollYProgress, [0, 0.6], [12, 0]);
const radius = useTransform(scrollYProgress, [0, 0.6], [28, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [1.08, 1]);
const clipPath = useMotionTemplate`inset(${inset}% round ${radius}px)`;
return (
<section ref={ref} className="opening"> {/* height: 200svh */}
<div className="opening__stage"> {/* position: sticky; top: 0; height: 100svh; overflow: clip */}
<motion.div className="opening__frame" style={reduce ? undefined : { clipPath }}>
<motion.img src={src} alt={alt} width={2400} height={1350} style={reduce ? undefined : { scale }} />
</motion.div>
</div>
</section>
);
}
```
The scene runs at most one or two screens of scroll; no wheel hijacking.
**R12. Running numbers** (real data only, tabular figures).
```html
<span class="sr-only">1 280 команд</span>
<span class="num" aria-hidden="true" data-count="1280">1 280</span>
```
```js
import { animate, onScroll, utils } from "animejs";
const fmt = new Intl.NumberFormat("ru-RU");
document.querySelectorAll("[data-count]").forEach((el) => {
const state = { n: 0 };
animate(state, {
n: Number(el.dataset.count),
duration: 1200,
ease: "outExpo",
modifier: utils.round(0),
onUpdate: () => { el.textContent = fmt.format(state.n); },
autoplay: onScroll({ target: el, enter: "85% top", repeat: false }),
});
});
```
**R13. View Transitions.**
```css
@view-transition { navigation: auto; } /* MPA cross-page transitions */
::view-transition-old(root) { animation: 200ms var(--ease-out) both vt-out; }
::view-transition-new(root) { animation: 380ms var(--ease-out) 60ms both vt-in; }
@keyframes vt-out { to { opacity: 0; } }
@keyframes vt-in { from { opacity: 0; transform: translateY(8px); } }
.product-shot { view-transition-name: product-shot; } /* name must be unique on the page */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}
```
In an SPA: `document.startViewTransition ? document.startViewTransition(update) : update()`.
### 9.6 Reduced motion: "less and softer", not zero
- Remove movement, scale, parallax, scroll scenes and video autoplay (use a poster and a Play button instead). Keep opacity and color crossfades up to 200 ms.
- React: `<MotionConfig reducedMotion="user">` at the root (disables transform and layout, keeps opacity and color).
- anime.js: `createScope({ mediaQueries: { reduce: "(prefers-reduced-motion: reduce)" } })`.
- GSAP: `gsap.matchMedia()` with `(prefers-reduced-motion: no-preference)`.
- CSS: decorative animation goes inside `@media (prefers-reduced-motion: no-preference)`. Don't use a global kill switch `* { animation: none !important }`: it breaks loading indicators and necessary crossfades.
- `scroll-behavior: smooth` also only under `no-preference`.
- Anything moving longer than 5 s has a visible pause.
### 9.7 Motion performance
- **Animate cheap properties:** `transform` and `opacity`; `filter` and `clip-path` if smooth on target devices. `color`, `background-color`, `box-shadow`, `border-radius` repaint: only on small elements and briefly. Never animate layout properties (`width`, `height`, `padding`, `margin`, `top`, `left`); use Motion layout animations or FLIP instead.
- In Motion, `x` and `y` are hardware-accelerated. `layout`, `layoutId`, `onUpdate` and `useTransform` with non-composited values run on the main thread: use sparingly.
- Set `will-change` only for the duration of the animation and only for `transform`, `opacity`, `filter`.
- Don't update CSS variables on a parent every frame (restyles all children). Write per-frame values to `ref.style`, not React state.
- Blur in animations at most 20 px (Safari). Don't animate `backdrop-filter` on large surfaces.
- Infinite animations stop off-screen (IntersectionObserver). Long sections below the fold: `content-visibility: auto` + `contain-intrinsic-size`.
- Correctness doesn't depend on `animationend` and `transitionend`: without animation the state is the same.
- Test: 4× CPU slowdown, Safari on iPhone in Low Power Mode, 10 % playback speed in the Animations panel.
---
## 10. Components and states
### 10.1 States and base rules
- Every interactive element has designed **default, hover, focus-visible, active, disabled, loading, error, success** states; containers also have **empty**; navigation has **current** (`aria-current="page"`).
- Hover, active and focus never rely on color alone: background tone, contrast, underline or icon changes.
```css
button, [role="button"], a[href], summary, label[for], select, [role="tab"], [role="option"] { cursor: pointer; }
:disabled, [aria-disabled="true"] { cursor: not-allowed; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } /* instant, no transition */
```
- Actions are `<button>`, navigation is `<a href>`. Accessible primitives (Base UI, Radix, React Aria) from one system; don't mix.
### 10.2 Buttons
| Variant | Look | Where |
|---|---|---|
| **Primary** | pill, `--accent`, white text (`--on-accent`) | one per viewport |
| **Secondary** | pill, `--fill`, `--ink` text | next to primary |
| **Text** | `--accent-text`, no background, underline on hover | secondary navigation, «Посмотреть характеристики» (View specs) |
| **On a dark hero** | white pill (`--ink` on white) or `.glass-media` | over media |
| **Destructive** | `--danger` as text or fill | irreversible actions only |
- Height 44 px (compact in apps: 36 px with an extended hit area), hero CTA 52–56 px. Horizontal padding 20–28 px.
- Font 15 px / 500 (hero 17 px / 500), single-line label (`white-space: nowrap`), 1–3 words: verb + object.
- Never two filled buttons side by side: primary + secondary or primary + text link.
- Loading: the label stays, a spinner appears next to it, `aria-busy="true"`, the button is disabled only while submitting (no double submit).
```css
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
min-block-size: 44px; padding-inline: 1.25rem; border: 0; border-radius: var(--radius-pill);
font: 500 var(--text-callout) / 1 var(--font-sans); white-space: nowrap;
}
.btn--primary { background: var(--accent); color: var(--on-accent); }
.btn--secondary { background: var(--fill); color: var(--ink); }
.btn--lg { min-block-size: 52px; padding-inline: 1.75rem; font-size: var(--text-body); }
.btn:disabled { background: var(--fill); color: var(--ink-3); }
```
### 10.3 Header
- **Full-width bar**, 56 px tall (`--header-h: 3.5rem`). Not a floating island.
- Wordmark on the left (16–20 px tall), 4–6 centered items (15 px, 400–500; dropdowns get a 14 px `ChevronDown`), on the right one text link and one 36 px pill button (44 px hit area).
- **Over hero media**: transparent, `--on-media` colors. After scrolling starts it becomes glass with `--ink` colors (R8, §7.6).
- **Mobile** (< 1024 px): wordmark + a 44×44 `Menu` button → full-screen sheet (`100dvh`) with large items (28 px / 600, 56 px rows), 40 ms stagger for at most 6 items, focus trap, Esc, background scroll lock (`overscroll-behavior: contain`).
- The first focusable element is a "Skip to content" link.
- Current section: `aria-current="page"` and `--ink` instead of `--ink-2` (weight unchanged).
### 10.4 Menus and popovers
- Radius `--radius-md` (16), padding 6, items with radius 10, item height 36–40 px (44 on touch), `--shadow-float`; Regular glass optional.
- Animation R6, origin at the trigger. Keyboard opening: no animation or at most 120 ms.
- Keyboard: arrows, Home / End, first-letter typeahead, Esc returns focus to the trigger.
- Mega menu: item = title + one line of description. No icon coins.
### 10.5 Inputs
- Height 44–48 px, same as the adjacent button. Radius `--radius-field` (12); search and command fields are pills.
- `--surface` background with a 1px `--hairline` border, or a `--fill` background. **Border width never changes** between states; focus via `outline`, not `border`.
- Visible label above the field (15 px / 500). The placeholder is only an example (`name@example.com`, «Поиск…»), never a label replacement.
- Hint below the field, 13 px `--ink-2`; a line is reserved for it (`min-height: 1lh`) so an error doesn't shift the layout.
- Error: `CircleAlert` + `--danger` text below the field, `aria-invalid="true"`, `aria-describedby`. Validate on `blur`, not per keystroke; on submit, focus the first error; entered data is never lost.
- Input font at least 16 px (ours is 17), correct `type`, `inputmode`, `autocomplete`, `enterkeyhint`; `spellcheck="false"` for e-mails and codes.
- Never block paste, support password managers, passwords get a "Show" toggle (`Eye` / `EyeOff`).
### 10.6 Command field (as in the reference)
- Pill, 52–56 px tall. Over hero media `.glass-media`, on a flat background `--surface-2`.
- Mono 15 px, `translate="no"`; a `$` prefix in `--ink-2` with `user-select: none`.
- On the right a round 40 px button (44 px hit area) `Copy` → `Check` (R5). A copy failure is reported in text.
- Don't truncate a long command: horizontal scroll with a soft edge fade (`mask-image`).
### 10.7 Tooltips
- Only for unlabeled icons and truncated text. The first appears after 600–800 ms of hover, subsequent ones within a second appear instantly; instantly on keyboard focus.
- Short text, no interactive content. Esc hides it; the pointer can move onto the tooltip (WCAG 1.4.13).
### 10.8 Dialogs and sheets
- A modal only for a short task with an obvious exit; if it can be done inline, do it inline.
- Scrim `rgb(0 0 0 / .4–.5)`, **solid** panel (not glass), radius 28, padding 24–32 px, width up to 30–36 rem. Title, description, actions (on the right on desktop, full-width stacked on mobile).
- Closing: Esc, an `X` button with `aria-label="Закрыть"` (Close), scrim click (except with unsaved data). Focus trap and focus return; `<dialog>` + `showModal()` or a primitive; `overscroll-behavior: contain`.
- Irreversible action: «Удалить проект „Альфа“? Это нельзя отменить.» (Delete project "Alpha"? This can't be undone.) Buttons «Отмена» (Cancel) and «Удалить» (Delete, danger); default focus on Cancel. For reversible actions, offer «Отменить» (Undo) in a toast instead of a confirmation.
- Mobile sheet: from the bottom, up to 90 % height, top corners 28, a 36×5 grabber. Swipe down with fling velocity, `spring.sheet`; a close button duplicates the gesture.
### 10.9 Toast
- One location per product (bottom center or bottom right), at most 3 at once.
- 3–5 s (longer with an action), paused on hover and focus, `aria-live="polite"`, doesn't steal focus.
- No toast for a result that's already visible on screen.
### 10.10 Loading
- Something appears immediately: the skeleton mirrors the final layout (no jump on swap).
- Spinner with a 150–300 ms show delay and a 300–500 ms minimum display (no flicker).
- Optimistic updates with rollback on error. Longer than 2–3 s: progress with percentages if they can be computed.
- Skeleton shimmer is soft (opacity 0.6 ↔ 1, ~1.6 s), off under reduced motion, and stops right after loading.
### 10.11 Empty states and errors
- An empty state invites action: what will be here + one action («Здесь появятся ваши проекты» + «Создать проект»: "Your projects will appear here" + "Create project").
- Error: what happened, why (if known) and what to do. No "Oops", no apologies or exclamations. There is a "Retry", and input is never lost.
- 404: a short phrase, search and a link home.
### 10.12 Cards
- Background by tone (`--bg-2` on white or `--surface-2`), radius 22, padding 24–32 px, no border or shadow at rest.
- The whole card is clickable via a stretched link; one hover signal (tone change), no lift, shadow or image zoom. Focus is visible on the whole card.
```css
.card { position: relative; background: var(--bg-2); border-radius: var(--radius-card); transition: background-color var(--dur-hover) ease; }
.card a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
.card:has(a:focus-visible) { outline: 2px solid var(--focus); outline-offset: 2px; }
@media (hover: hover) and (pointer: fine) { .card:hover { background: var(--surface-2); } }
```
- Cards differ by hierarchy (size, media, order) instead of looking like three clones.
### 10.13 Tables and data (Operate mode)
- Figures are tabular and right-aligned, text left-aligned. Column headers 13 px / 500 `--ink-2`, sentence case.
- Rows 44–52 px, `--hairline` dividers between rows (never zebra and borders together). Sticky header with an edge (§7.6).
- A chart title states a question or a conclusion («Сбои по часам за 24 ч»: "Failures by hour, last 24 h"). No decorative sparklines. A chart has a table alternative and data contrast of at least 3:1.
### 10.14 Footer
- A designed ending on `--bg-2`: dense columns of real links (13 px, line height ~2.2–2.4), a legal links row, language and region selector, theme switch.
- Columns follow the real links, not a 4-column template. A large full-width return of the name as a final chord is allowed.
- If there is sign-up, there are "Terms" and "Privacy".
---
## 11. Images and media
- **Real photos, renders and screenshots.** A landing page has at least 4 real images (6–12 is better). One dominant image beats six small ones. A photo wall takes at most a third of the page.
- **Hero media:** `<img>` (not a CSS background), no `loading="lazy"`, with `fetchpriority="high"`. Everything else `loading="lazy" decoding="async"`.
- **Formats:** AVIF / WebP, `srcset` + `sizes`, always `width` and `height` or `aspect-ratio`. A different crop for mobile via `<picture>`.
- **Screenshots:** the real UI at 2×, light and dark versions, cropped to the meaningful part. A device frame at most once and only with real content.
- **Video:** `<video autoplay muted loop playsinline preload="metadata" poster="…">`, 6–15 s loop, a light file, a visible pause button. Under reduced motion and `Save-Data`, no autoplay (poster + Play). Speech has captions (`<track>`).
- **Treatment:** no brand-color washes over photos, consistent color grading. Media in a grid gets radius 22 / 28; full-bleed has none. One product shadow under a product render.
- **Alt:** describes meaning, not "image"; decorative ones get `alt=""`. No material yet: an honest placeholder `[PHOTO: …]` (§3.7).
- **Illustrations:** no Undraw, 3D blobs or "neural" abstractions. Generated images only with a single art direction and without artifacts (hands, text), and never posing as "customers".
- **Lottie** only where CSS, SVG and anime.js can't do the job.
---
## 12. Copy (Russian UI)
- **Copy first, layout second:** a copy deck with real wording before markup.
- **Swap test:** if a competitor could say the phrase, rewrite it around what only this product has (something you can picture and verify).
- **h1** up to 7 words, at most 2 lines, says what and for whom. **Lead**: one sentence up to ~20 words: what, for whom, how it differs.
- **Section headings** say what the reader gets («Установка одной командой»: "Install with one command"), not «Возможности» (Features) or «Преимущества» (Benefits). Up to 6 words.
- **Buttons:** verb + object; one verb through the whole flow (Опубликовать → Публикуется… → Опубликовано: Publish → Publishing… → Published).
- **Errors:** what, why, what to do. Never blame the user.
- **Tone:** calm, confident, concrete. Address as lowercase «вы», active voice. No bureaucratese: «осуществляется», «является», «данный», «в рамках», «в целях».
- Digits for numbers, non-breaking space before units, no exclamation marks, no emoji, sentence case, «guillemets», no dashes in UI (rephrase).
| Before | After |
|---|---|
| Начать | Создать первый проект |
| Узнать больше | Как устроены правила |
| Революционная платформа на базе ИИ | Правила, по которым ИИ-агент верстает без шаблонов |
| Быстро, просто и надёжно | Устанавливается одной командой |
| Упс! Что-то пошло не так | Не удалось сохранить. Проверьте подключение и попробуйте ещё раз |
| Отправить | Отправить заявку |
| Вы уверены? | Удалить проект «Альфа»? Это нельзя отменить |
| Нам доверяют тысячи компаний | `[REAL DATA: number and names of clients]` |
---
## 13. Accessibility (WCAG 2.2 AA)
- **Contrast:** text 4.5:1; large text (from 24 px, or from 18.66 px bold) 3:1; icons, input borders, focus and graphics 3:1 (1.4.11). Check on the real background, including photos and glass.
- **Focus:** `:focus-visible` 2 px `--focus` with a 2 px offset, appears instantly. Not covered by the sticky header (2.4.11, `scroll-padding-top`). `:focus-within` for groups.
- **Keyboard:** everything reachable in a logical order; Enter and Space activate; Esc closes; arrows in menus, tabs, radio groups; focus traps only in modals, with focus return.
- **Target size:** at least 24×24 px (2.5.8), 44×44 by default with an 8 px gap.
- **Semantics:** `header`, `nav`, `main`, `footer`; one `h1`, no skipped levels; lists as lists; labels on fields; `lang="ru"`, a unique `<title>`, `meta description`.
- **Alternatives:** `alt` on images, `aria-label` on icon buttons, `aria-hidden` on decorative SVGs; async statuses via `aria-live="polite"`.
- **Motion:** reduced motion (§9.6), pause for motion longer than 5 s (2.2.2), no flashes more than 3 times per second (2.3.1).
- **System settings:** `prefers-reduced-transparency`, `prefers-contrast: more`, `forced-colors: active` (system colors; outlines don't vanish: instead of `border: none` use a transparent border that shows up in forced colors).
- **Scaling:** 200 % zoom and text-only enlargement without loss; no horizontal scroll at 320 px (1.4.10); overriding letter and line spacing breaks nothing (1.4.12).
- **WCAG 2.2:** an alternative to dragging (2.5.7), accessible authentication: paste and password managers (3.3.8), no re-entering already entered data (3.3.7), help in a consistent place (3.2.6).
- **Testing:** keyboard-only pass, VoiceOver (Safari) and NVDA, axe DevTools, `prefers-*` emulation in DevTools.
---
## 14. Performance
- **Targets (p75, mobile):** LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1. Measured numbers only (PageSpeed Insights, CrUX, WebPageTest).
- **Fonts:** self-hosted, latin + cyrillic only, one variable file, preload one critical file, `font-display: swap`, a metric-adjusted fallback (next/font does it for you).
- **Images:** AVIF / WebP, `srcset` / `sizes`, dimensions set, the LCP image with `fetchpriority="high"` and not lazy, the rest lazy.
- **JS:** the first screen doesn't depend on JS; heavy animated sections load dynamically; `LazyMotion`; modular anime.js imports; third-party scripts `defer` / `async`, analytics after load. Landing page budget: up to ~150 KB JS (gzip) for the first screen.
- **INP:** light handlers, yield heavy work via `scheduler.yield()` / `setTimeout`, optimistic UI, debounced input, virtualize lists over 50–100 rows.
- **CSS:** glass on small areas, `content-visibility: auto` for long sections below the fold.
- **Video:** compressed, with a poster, `preload="metadata"`, lazy-loaded below the fold.
- **Testing:** iPhone Safari in Low Power Mode, a mid-range Android, 4× CPU, Slow 4G.
---
## 15. Mobile layer
- Mobile-first, base width 390, check at 320. Same content as desktop.
- Touch targets 44×44 with an 8 px gap. No hover-only features; hover effects inside `@media (hover: hover) and (pointer: fine)`.
- Inputs from 16 px with the right `type`, `inputmode`, `autocomplete`, `enterkeyhint`.
- `100svh` for the hero, `100dvh` for overlays, `viewport-fit=cover` + `env(safe-area-inset-*)` for fixed elements.
- The main action sits in the thumb zone; a bottom sticky CTA only in purchase and checkout flows, padded for the safe area.
- Horizontal galleries on `scroll-snap` with a visible edge of the next card (10–15 %) and `overscroll-behavior-x: contain`.
- Simplify on weak devices: less glass blur (12–16 px), no scroll scenes if they stutter.
- Gestures (sheet swipe, carousels) are always duplicated by buttons.
- Test on a real iPhone (Safari) and Android (Chrome), in landscape and with enlarged text.
---
## 16. Tokens (CSS + Tailwind v4)
### 16.1 `tokens.css` (any stack)
```css
/* tokens.css · Apple-modern Ruleset 1.0 */
:root {
color-scheme: light dark;
/* Color */
--bg: light-dark(#ffffff, #000000);
--bg-2: light-dark(#f5f5f7, #161617);
--surface: light-dark(#ffffff, #1d1d1f);
--surface-2: light-dark(#fafafc, #272729);
--ink: light-dark(#1d1d1f, #f5f5f7);
--ink-2: light-dark(#6e6e73, #a1a1a6);
--ink-3: #86868b;
--hairline: light-dark(rgb(0 0 0 / .08), rgb(255 255 255 / .10));
--fill: light-dark(rgb(0 0 0 / .04), rgb(255 255 255 / .08));
--fill-2: light-dark(rgb(0 0 0 / .08), rgb(255 255 255 / .14));
--accent: #0071e3;
--accent-hover: #006edb;
--accent-pressed: #0066cc;
--accent-text: light-dark(#0066cc, #2997ff);
--on-accent: #ffffff;
--focus: light-dark(#0071e3, #2997ff);
--danger: light-dark(#d70015, #ff6961);
--success: light-dark(#207a39, #30d158);
--warning: light-dark(#c93400, #ff9f0a);
--on-media: #ffffff;
--on-media-2: rgb(255 255 255 / .8);
--media-solid: #1d1d1f;
/* Glass */
--glass-border: light-dark(rgb(0 0 0 / .06), rgb(255 255 255 / .10));
--glass-highlight: light-dark(rgb(255 255 255 / .55), rgb(255 255 255 / .08));
--glass-media-fill: rgb(255 255 255 / .14);
--glass-media-border: rgb(255 255 255 / .18);
/* Fonts (next/font sets --font-onest and --font-geist-mono) */
--font-sans: var(--font-onest, "Onest"), "Onest Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: var(--font-geist-mono, "Geist Mono"), "Geist Mono Variable", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* Type scale */
--text-hero: clamp(2.5rem, 1.25rem + 5.2vw, 5.75rem);
--text-display: clamp(2.25rem, 1.4rem + 3.2vw, 4rem);
--text-title: clamp(2rem, 1.5rem + 1.9vw, 3rem);
--text-headline: clamp(1.375rem, 1.2rem + 0.6vw, 1.75rem);
--text-lead: clamp(1.1875rem, 1.05rem + 0.55vw, 1.5rem);
--text-body: 1.0625rem;
--text-callout: 0.9375rem;
--text-caption: 0.8125rem;
--text-fine: 0.75rem;
/* Spacing and rhythm */
--space-1: .25rem; --space-2: .5rem; --space-3: .75rem; --space-4: 1rem; --space-5: 1.5rem;
--space-6: 2rem; --space-7: 3rem; --space-8: 4rem; --space-9: 5rem; --space-10: 6rem;
--space-11: 7.5rem; --space-12: 10rem; --space-13: 12.5rem;
--section-sm: clamp(4rem, 3rem + 4vw, 7.5rem);
--section: clamp(5rem, 3rem + 7vw, 10rem);
--section-lg: clamp(6rem, 3rem + 10vw, 12.5rem);
/* Layout */
--container: 80rem; --container-wide: 90rem; --measure: 65ch;
--gutter: clamp(1.25rem, 4vw, 4rem); --grid-gap: clamp(1rem, 2vw, 1.5rem);
--header-h: 3.5rem;
/* Radii */
--radius-xs: 6px; --radius-sm: 10px; --radius-field: 12px; --radius-md: 16px;
--radius-card: 22px; --radius-panel: 28px; --radius-pill: 999px;
/* Shadows: each layer's color depends on the theme (the §7.3 variant in one token) */
--shadow-float:
0 0 0 1px light-dark(transparent, rgb(255 255 255 / .08)),
0 1px 1px light-dark(rgb(0 0 0 / .04), transparent),
0 4px 12px light-dark(rgb(0 0 0 / .06), rgb(0 0 0 / .3)),
0 16px 40px light-dark(rgb(0 0 0 / .10), rgb(0 0 0 / .5));
--shadow-modal:
0 0 0 1px light-dark(transparent, rgb(255 255 255 / .08)),
0 2px 4px rgb(0 0 0 / .04),
0 12px 32px light-dark(rgb(0 0 0 / .12), rgb(0 0 0 / .4)),
0 40px 80px light-dark(rgb(0 0 0 / .16), rgb(0 0 0 / .6));
--shadow-product: drop-shadow(3px 5px 30px rgb(0 0 0 / .22));
/* Motion */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--dur-press: 120ms; --dur-hover: 180ms; --dur-ui: 240ms; --dur-overlay: 320ms;
--dur-sheet: 450ms; --dur-reveal: 800ms; --dur-open: 1100ms;
--stagger-line: 90ms; --stagger-item: 60ms; --stagger-char: 24ms;
/* Layers */
--z-raised: 10; --z-sticky: 20; --z-header: 40; --z-popover: 60; --z-overlay: 100; --z-toast: 1000;
}
/* Manual theme choice over the system one */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
```
`light-dark()` is supported in Chrome 123+, Safari 17.5+, Firefox 120+. For older browsers, duplicate the colors in `@media (prefers-color-scheme: dark)`.
### 16.2 Base
```css
*, *::before, *::after { box-sizing: border-box; }
html {
font-size: 100%;
background: var(--bg);
color: var(--ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-synthesis: none;
text-size-adjust: 100%;
overflow-x: clip;
scroll-padding-top: calc(var(--header-h) + 1rem);
accent-color: var(--accent);
caret-color: var(--accent);
scrollbar-color: color-mix(in srgb, var(--ink) 28%, transparent) transparent;
}
body { margin: 0; min-height: 100svh; overflow-x: clip; font-size: var(--text-body); line-height: 1.5; }
h1, h2, h3, h4 { margin: 0; font-weight: 600; text-wrap: balance; overflow-wrap: break-word; }
p { margin: 0; text-wrap: pretty; }
img, video { display: block; max-width: 100%; height: auto; }
::selection { background: color-mix(in srgb, var(--accent) 24%, transparent); color: inherit; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.nowrap { white-space: nowrap; }
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
```
The 17 px font size is set on `body`, not `html`: otherwise `1rem` becomes 17 px and the whole scale shifts.
### 16.3 Tailwind v4
```css
/* app/globals.css */
@import "tailwindcss";
@import "./tokens.css"; /* shadows, glass, durations, section rhythm, header offsets */
@theme {
--color-*: initial; /* drop the stock palette (indigo-500 etc.) */
--color-bg: light-dark(#ffffff, #000000);
--color-bg-2: light-dark(#f5f5f7, #161617);
--color-surface: light-dark(#ffffff, #1d1d1f);
--color-surface-2: light-dark(#fafafc, #272729);
--color-ink: light-dark(#1d1d1f, #f5f5f7);
--color-ink-2: light-dark(#6e6e73, #a1a1a6);
--color-ink-3: #86868b;
--color-hairline: light-dark(rgb(0 0 0 / .08), rgb(255 255 255 / .10));
--color-fill: light-dark(rgb(0 0 0 / .04), rgb(255 255 255 / .08));
--color-fill-2: light-dark(rgb(0 0 0 / .08), rgb(255 255 255 / .14));
--color-accent: #0071e3;
--color-accent-hover: #006edb;
--color-accent-pressed: #0066cc;
--color-accent-text: light-dark(#0066cc, #2997ff);
--color-on-accent: #ffffff;
--color-focus: light-dark(#0071e3, #2997ff);
--color-danger: light-dark(#d70015, #ff6961);
--color-success: light-dark(#207a39, #30d158);
--color-warning: light-dark(#c93400, #ff9f0a);
--color-on-media: #ffffff;
--color-media-solid: #1d1d1f;
--text-*: initial; /* our scale only, no 9 near-identical sizes */
--text-hero: clamp(2.5rem, 1.25rem + 5.2vw, 5.75rem);
--text-hero--line-height: 1;
--text-hero--letter-spacing: -0.035em;
--text-hero--font-weight: 600;
--text-display: clamp(2.25rem, 1.4rem + 3.2vw, 4rem);
--text-display--line-height: 1.04;
--text-display--letter-spacing: -0.03em;
--text-display--font-weight: 600;
--text-title: clamp(2rem, 1.5rem + 1.9vw, 3rem);
--text-title--line-height: 1.08;
--text-title--letter-spacing: -0.025em;
--text-title--font-weight: 600;
--text-headline: clamp(1.375rem, 1.2rem + 0.6vw, 1.75rem);
--text-headline--line-height: 1.2;
--text-headline--letter-spacing: -0.015em;
--text-headline--font-weight: 600;
--text-lead: clamp(1.1875rem, 1.05rem + 0.55vw, 1.5rem);
--text-lead--line-height: 1.35;
--text-lead--letter-spacing: -0.01em;
--text-body: 1.0625rem;
--text-body--line-height: 1.5;
--text-callout: 0.9375rem;
--text-callout--line-height: 1.45;
--text-caption: 0.8125rem;
--text-caption--line-height: 1.38;
--text-caption--letter-spacing: 0.005em;
--text-fine: 0.75rem;
--text-fine--line-height: 1.33;
--text-fine--letter-spacing: 0.01em;
--radius-*: initial;
--radius-xs: 6px;
--radius-sm: 10px;
--radius-field: 12px;
--radius-md: 16px;
--radius-card: 22px;
--radius-panel: 28px;
--radius-pill: 999px;
--ease-*: initial;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
--ease-soft: cubic-bezier(0.33, 1, 0.68, 1);
--drop-shadow-product: 3px 5px 30px rgb(0 0 0 / .22);
--container-page: 80rem;
--container-wide: 90rem;
}
@theme inline {
--font-sans: var(--font-onest), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", Menlo, monospace;
}
@utility glass {
background-color: color-mix(in srgb, var(--color-bg) 72%, transparent);
-webkit-backdrop-filter: blur(24px) saturate(1.5);
backdrop-filter: blur(24px) saturate(1.5);
}
@utility glass-media {
color: var(--color-on-media);
background-color: var(--glass-media-fill);
border: 1px solid var(--glass-media-border);
-webkit-backdrop-filter: blur(12px) saturate(1.4);
backdrop-filter: blur(12px) saturate(1.4);
}
```
- Shadows and durations come from `tokens.css`: `shadow-(--shadow-float)`, `shadow-(--shadow-modal)`, `duration-(--dur-ui)`.
- One source of truth. In a Tailwind project, the scale, radii and curves live in `@theme`, while `tokens.css` keeps shadows, glass, durations, section rhythm and `--header-h` (identical names with identical values don't conflict, but must never diverge).
- Spacing: use only multiples of Tailwind's base unit (`--spacing: 0.25rem`) that match the §6.1 scale: `1, 2, 3, 4, 6, 8, 12, 16, 20, 24, 30, 40, 50`.
- Button example: `class="inline-flex h-11 items-center gap-2 rounded-pill bg-accent px-5 text-callout font-medium text-on-accent transition-[transform,background-color] duration-(--dur-press) ease-out hover:bg-accent-hover active:scale-[.97]"`. In Tailwind v4, hover styles apply only on hover-capable devices by default.
---
## 17. Hero skeleton (as in the reference)
Full-screen media, transparent header, huge centered headline, lead, glass command field, footnote. Load animation from R2, header from R8. The sample copy follows §3.6 and §12; replace everything in square brackets with real data.
```html
<!doctype html>
<html lang="ru" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Интерфейсы без шаблонов | Ruleset</title>
<meta name="description" content="[PRODUCT DESCRIPTION, one sentence]">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#000000">
<link rel="preload" href="/fonts/onest-cyrillic.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/tokens.css">
<link rel="stylesheet" href="/hero.css">
<script>document.documentElement.classList.replace("no-js", "js");</script>
</head>
<body>
<a class="skip-link" href="#main">Перейти к содержанию</a>
<div id="top-sentinel" aria-hidden="true"></div>
<header class="site-header" data-scrolled="false">
<div class="site-header__inner">
<a class="wordmark" href="/">/ruleset</a>
<nav class="nav" aria-label="Основная навигация">
<button class="nav__item" type="button" aria-expanded="false">Правила
<svg class="lucide lucide-chevron-down" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<button class="nav__item" type="button" aria-expanded="false">Скиллы
<svg class="lucide lucide-chevron-down" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<a class="nav__item" href="/docs">Документация</a>
<a class="nav__item" href="/roadmap">Планы</a>
<!-- dropdown panels: §10.4 + R6 -->
</nav>
<div class="site-header__actions">
<a class="nav__item nav__item--plain" href="https://github.com/[ORG]/[REPO]">GitHub</a>
<a class="btn btn--header glass-media" href="/docs/install">Установить</a>
<button class="menu-btn" type="button" aria-label="Открыть меню" aria-expanded="false">
<svg class="lucide lucide-menu" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/></svg>
</button>
</div>
</div>
</header>
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<!-- [PHOTO: cinematic 16:9 frame, dark bottom and edges under the text] -->
<picture class="hero__media">
<source type="image/avif" srcset="/hero-1600.avif 1600w, /hero-2560.avif 2560w" sizes="100vw">
<img src="/hero-1600.jpg" srcset="/hero-1600.jpg 1600w, /hero-2560.jpg 2560w" sizes="100vw"
width="2560" height="1440" alt="" fetchpriority="high">
</picture>
<div class="media-scrim" aria-hidden="true"></div>
<div class="hero__content">
<h1 class="hero__title" id="hero-title">
<span class="line" style="--i:0"><span>Интерфейсы</span></span>
<span class="line" style="--i:1"><span>без шаблонов</span></span>
</h1>
<p class="hero__lead">Один файл правил, после которого ИИ-агент собирает сайт в стиле Apple, а не из готовых клише.</p>
<div class="hero__cta">
<div class="command glass-media">
<code translate="no"><span class="command__prompt" aria-hidden="true">$</span>npx ruleset init</code><!-- [REAL COMMAND] -->
<button class="command__copy" type="button" data-copy="npx ruleset init" aria-label="Скопировать команду">
<svg class="lucide lucide-copy i-copy" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<svg class="lucide lucide-check i-check" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
</button>
<span class="sr-only" role="status" aria-live="polite"></span>
</div>
</div>
<p class="hero__note">Открытый код, лицензия MIT.<!-- [VERIFY FACT] --></p>
</div>
</section>
</main>
<script type="module" src="/hero.js"></script>
</body>
</html>
```
```css
/* hero.css (+ .btn from §10.2, load animation from R2, glass from §7.5, scrim from §5.5) */
.skip-link {
position: absolute; inset-inline-start: var(--space-4); inset-block-start: var(--space-4); z-index: var(--z-toast);
padding: var(--space-2) var(--space-4); border-radius: var(--radius-pill);
background: var(--surface); color: var(--ink); transform: translateY(-200%);
}
.skip-link:focus-visible { transform: none; }
#top-sentinel { position: absolute; top: 0; width: 1px; height: 1px; }
/* Header */
.site-header {
position: fixed; inset: 0 0 auto; z-index: var(--z-header); block-size: var(--header-h);
color: var(--on-media); background-color: transparent;
-webkit-backdrop-filter: blur(0) saturate(1); backdrop-filter: blur(0) saturate(1);
transition: background-color var(--dur-ui) var(--ease-out), color var(--dur-ui) ease,
-webkit-backdrop-filter var(--dur-ui) var(--ease-out), backdrop-filter var(--dur-ui) var(--ease-out);
}
.site-header[data-scrolled="true"] {
color: var(--ink);
background-color: color-mix(in srgb, var(--bg) 72%, transparent);
-webkit-backdrop-filter: blur(24px) saturate(1.5); backdrop-filter: blur(24px) saturate(1.5);
}
.site-header__inner {
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; block-size: 100%;
max-inline-size: var(--container-wide); margin-inline: auto; padding-inline: var(--gutter);
}
.wordmark { justify-self: start; color: inherit; text-decoration: none; font: 600 1.0625rem / 1 var(--font-sans); letter-spacing: -0.01em; }
.nav { display: flex; gap: var(--space-1); }
.nav__item {
display: inline-flex; align-items: center; gap: var(--space-1); min-block-size: 44px; padding-inline: var(--space-3);
border: 0; border-radius: var(--radius-pill); background: none; color: inherit; text-decoration: none;
font: 500 var(--text-callout) / 1 var(--font-sans); white-space: nowrap;
}
.nav__item svg { transition: transform var(--dur-ui) var(--ease-out); }
.nav__item[aria-expanded="true"] svg { transform: rotate(180deg); }
.site-header__actions { justify-self: end; display: flex; align-items: center; gap: var(--space-2); }
.btn--header { position: relative; min-block-size: 36px; padding-inline: var(--space-4); font-size: var(--text-callout); }
.btn--header::before { content: ""; position: absolute; inset: -4px 0; } /* 44 px hit area */
.site-header[data-scrolled="true"] .btn--header { /* on light glass: primary */
background: var(--accent); color: var(--on-accent); border-color: transparent;
-webkit-backdrop-filter: none; backdrop-filter: none;
}
.menu-btn { display: none; }
@media (max-width: 1023px) {
.nav, .nav__item--plain { display: none; }
.site-header__inner { grid-template-columns: 1fr auto; }
.menu-btn { display: inline-grid; place-items: center; inline-size: 44px; block-size: 44px; border: 0; border-radius: 50%; background: none; color: inherit; }
}
/* Hero */
.hero {
position: relative; isolation: isolate; display: grid; place-items: center; min-block-size: 100svh;
overflow: clip; text-align: center; color: var(--on-media); background-color: var(--media-solid);
}
.hero__media, .hero__media img { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; object-fit: cover; }
.hero__media { z-index: -2; }
.media-scrim { z-index: -1; }
.hero__content {
display: grid; justify-items: center; gap: var(--space-5); max-inline-size: 60rem;
padding: calc(var(--header-h) + var(--space-8)) var(--gutter) var(--space-10);
}
.hero__title { font-size: var(--text-hero); font-weight: 600; line-height: 1; letter-spacing: -0.035em; }
.hero__lead {
max-inline-size: 36ch; color: var(--on-media-2); text-wrap: balance;
font-size: var(--text-lead); line-height: 1.35; letter-spacing: -0.01em;
}
.hero__cta { margin-block-start: var(--space-3); max-inline-size: 100%; }
.hero__note { color: var(--on-media-2); font-size: var(--text-caption); line-height: 1.38; }
/* Command field */
.command {
display: inline-flex; align-items: center; gap: var(--space-3); max-inline-size: 100%;
min-block-size: 56px; padding-inline: var(--space-5) var(--space-2); border-radius: var(--radius-pill);
}
.command code { overflow-x: auto; white-space: nowrap; scrollbar-width: none; font: 400 var(--text-callout) / 1 var(--font-mono); }
.command__prompt { margin-inline-end: 1ch; color: var(--on-media-2); user-select: none; }
.command__copy {
position: relative; display: grid; place-items: center; flex: none; inline-size: 40px; block-size: 40px;
border: 0; border-radius: 50%; color: inherit; background: var(--glass-media-fill);
transition: transform var(--dur-press) var(--ease-out), background-color var(--dur-hover) ease;
}
.command__copy::before { content: ""; position: absolute; inset: -2px; border-radius: 50%; }
.command__copy:active { transform: scale(.95); }
.command__copy svg { grid-area: 1 / 1; transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out), filter 200ms var(--ease-out); }
.command__copy .i-check,
.command__copy[data-state="copied"] .i-copy { opacity: 0; transform: scale(.25); filter: blur(4px); }
.command__copy[data-state="copied"] .i-check { opacity: 1; transform: none; filter: none; }
@media (hover: hover) and (pointer: fine) {
.command__copy:hover { background: color-mix(in srgb, var(--on-media) 22%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.command__copy svg { transform: none !important; filter: none !important; transition: opacity 150ms ease; }
}
```
```js
// hero.js
// Header: glass after scrolling starts (R8)
const header = document.querySelector(".site-header");
new IntersectionObserver(([entry]) => {
header.dataset.scrolled = String(!entry.isIntersecting);
}).observe(document.getElementById("top-sentinel"));
// Copy command (R5 without React)
for (const button of document.querySelectorAll("[data-copy]")) {
const status = button.parentElement.querySelector('[role="status"]');
let timer;
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(button.dataset.copy);
button.dataset.state = "copied";
status.textContent = "Команда скопирована";
clearTimeout(timer);
timer = setTimeout(() => {
button.dataset.state = "";
status.textContent = "";
}, 1800);
} catch {
status.textContent = "Не удалось скопировать. Выделите команду вручную";
}
});
}
window.__motionReady = true;
```
**Skeleton check:** the headline stays at 2 lines from 320 to 1920 px (measured with Onest 600 metrics: «без шаблонов» = 6.44 em, i.e. 257 px of the 280 available at 320 px with 40 px type); lead and footnote contrast measured at the lightest spot of the photo; with the glass header and the command field there are 2 glass roles per viewport; with JS disabled everything is visible and works except copying.
---
## 18. Delivery Gate (pre-handoff check)
### 18.1 Format
The agent hands off with a table. **A FAIL blocks handoff. "Not checked" counts as FAIL.** Evidence: a screenshot path, a measured value, `file:line` or command output.
| # | Check | Status | Evidence |
|---|---|---|---|
| B2 | h1 at most 2 lines at 320–1920 px | PASS | `shots/hero-320.png` … `hero-1920.png` |
| C1 | `--ink-2` contrast on `--bg-2` | PASS | 4.66:1 |
### 18.2 Checklist
**A. Direction and anti-slop**
- A1 Design Read, Dials and surface mode are recorded (§1.4–1.5).
- A2 One signature, one centered moment, the logo-swap test passes.
- A3 Not a single item from §3 (autochecks §18.3 + screenshot review).
- A4 Glass only in the functional layer, at most 2 roles per viewport, fallbacks in place.
- A5 One accent, at most ~5 % of the area.
**B. Typography**
- B1 Onest loads with Cyrillic (Network tab), font swap without a jump.
- B2 h1 at most 2 lines at 320, 390, 768, 1024, 1280, 1440, 1920 px.
- B3 Tracking and leading per §4.4; at most 3 weights and 6 sizes per page.
- B4 Guillemets, `…`, non-breaking spaces, no dangling prepositions or one-word lines, no dashes in UI.
- B5 Figures in prices, tables and counters are tabular.
**C. Color and contrast**
- C1 Measurement table: text from 4.5:1, large text from 3:1, icons, input borders and focus from 3:1.
- C2 Text over media measured at the lightest point beneath it.
- C3 Dark theme: every surface checked, the toggle works without a flash.
- C4 Tokens only; no hex literals outside `tokens.css`.
- C5 `::selection`, `caret-color`, `scrollbar-color`, `theme-color` are styled.
**D. Grid and responsive**
- D1 No horizontal scroll at 320 px.
- D2 Hero headline, phrase and action visible without scrolling at 1280×800 and 390×844.
- D3 Section rhythm varies, all spacing comes from the scale, no orphans in grids.
- D4 At 1920 px content is bounded by the container; no extra columns.
**E. Components**
- E1 Every control has hover, focus, active, disabled, loading, error (state screenshots).
- E2 One primary per viewport; button labels don't wrap.
- E3 Forms: visible labels, error at the field, focus moves to the first error, paste allowed.
- E4 Empty, loading and error states exist and are written per §10.10–10.11.
- E5 No links to nowhere and no buttons without behavior.
**F. Motion**
- F1 The chosen MOTION budget is respected: one entry pattern, one scroll choreography.
- F2 Motion tokens only; no `transition: all`; transform and opacity animated (filter and clip-path deliberately).
- F3 Reduced motion (DevTools emulation): no movement, all content visible.
- F4 With JS disabled content is visible; if scripts fail, the safety net fires after 2.5 s.
- F5 Animations are interruptible, input isn't blocked, motion longer than 5 s can be paused.
- F6 At 10 % speed: no jank, origin at the trigger, exit faster than enter.
**G. Accessibility**
- G1 Full keyboard pass: order, visible focus, Esc, focus traps only in modals.
- G2 Screen reader: landmarks, one h1, no skipped levels, icon-button labels, live regions.
- G3 Touch targets from 44×44 (min 24×24).
- G4 200 % zoom, 320 px width, forced colors, reduced transparency, increased contrast.
**H. Performance**
- H1 LCP, INP, CLS measured and reported as numbers (lab; field data if available).
- H2 The LCP image is not lazy and has `fetchpriority="high"`; all media have dimensions; AVIF / WebP.
- H3 One variable font file, only the critical one preloaded.
- H4 Tested on iPhone Safari in Low Power Mode or with 4× CPU slowdown.
**I. Honesty**
- I1 No invented numbers, testimonials, logos or faces.
- I2 All placeholders listed as a TODO list in the reply.
- I3 A form without a backend says so.
**J. Code**
- J1 Imports: `motion/react` (no `framer-motion`), named Lucide icons, modular anime.js.
- J2 No `outline: none` without a replacement, no `100vh`, no `overflow-x: hidden` on html and body, no `user-scalable=no`.
- J3 Production build checked: `backdrop-filter` and the `-webkit-` prefix are in place.
### 18.3 Autochecks
```bash
# Banned patterns
rg -n "transition:\s*all|outline:\s*none|\b100vh\b|overflow-x:\s*hidden|user-scalable=no|maximum-scale=1|from ['\"]framer-motion['\"]" src
# Hex literals outside tokens
rg -n "#[0-9a-fA-F]{3,8}\b" src -g '!**/tokens.css' -g '!**/*.svg'
# AI-cliché icons and whole-library imports
rg -n "\b(Sparkles|WandSparkles|Rocket|BrainCircuit|Bot|Gem|Zap)\b|import \* as \w+ from ['\"]lucide" src
# Filler words (RU + EN)
rg -n -i "революц|инновац|бесшовн|нового поколения|на базе ии|раскройте|погрузитесь|seamless|cutting-edge|next-gen|revolutionary|supercharge|unlock" src
# Dashes in UI copy
rg -n " — | – " src -g '*.{tsx,jsx,vue,svelte,astro,html,mdx}'
# Animating layout properties
rg -n "transition:[^;]*\b(width|height|top|left|margin|padding)\b" src
```
### 18.4 Self-score and "remove one accessory"
Before handoff, score each axis 1 to 5. **Anything below 4 gets fixed before handoff.**
| Axis | Question |
|---|---|
| **P** Purpose | Does every element serve the screen's goal? |
| **H** Hierarchy | Is it clear within 3 seconds what matters and what to press? |
| **E** Execution | Are spacing, alignment, states and copy finished? |
| **S** Specificity | Does the page belong only to this product (logo-swap test)? |
| **R** Restraint | One accent, one signature, nothing extra? |
| **V** Vitality | Is there life (media, rhythm, motion) rather than a sterile template? |
Then **remove one decorative element** and compare screenshots. If it's no worse without it, it doesn't come back.
### 18.5 Review format for an existing UI
| Severity | Where | Before | After | Why |
|---|---|---|---|---|
| Blocker | `Hero.tsx:42` | `outline: none` on a button | `:focus-visible` ring 2 px `--focus` | WCAG 2.4.7, §13 |
| Major | «Возможности» section | 3 identical cards with icons in circles | one large scene + a list | template, §3.3 |
| Minor | Footer | `...` | `…` | §4.8 |
Severity: **Blocker** (accessibility, honesty, broken behavior), **Major** (slop, hierarchy, motion), **Minor** (polish). Verdict: "Block", "Changes needed" or "Accepted".
---
## 19. Installing the skills
Skills install into the project (or globally with `-g`) for the chosen agents via the Skills CLI. The skill names below were checked against each repository's `--list` output.
**Flags:** `-s, --skill` space-separated names · `-a, --agent` space-separated agents (`claude-code`, `cursor`, `codex`, `windsurf`, `github-copilot`, `gemini-cli`, `opencode`, etc.; `'*'` for all) · `-g` global · `-y` no prompts · `-l` list a repository's skills · `npx skills use <repo>@<skill>` use once without installing.
```bash
# Core: design and anti-slop
npx skills@latest add anthropics/skills --skill frontend-design
npx skills@latest add Leonxlnx/taste-skill --skill design-taste-frontend
npx skills@latest add miqdadbadjuber/anti-slop --skill antislop antislop-ui antislop-copywriting antislop-layoutmobile
npx skills@latest add nextlevelbuilder/ui-ux-pro-max-skill --skill ui-ux-pro-max
npx skills@latest add pbakaus/impeccable --skill impeccable
npx skills@latest add Nutlope/hallmark --skill hallmark
# Emil Kowalski: design engineering and animation
npx skills@latest add emilkowalski/skills --skill emil-design-eng animate animation-performance review-animations improve-animations
# Apple: both skills are named apple-design, install one
npx skills@latest add dickwu/apple-design-skill # HIG + Liquid Glass
# npx skills@latest add emilkowalski/skills --skill apple-design # Designing Fluid Interfaces → web
# use the other one ad hoc: npx skills use emilkowalski/skills@apple-design | claude
# Motion
npx skills@latest add motiondivision/ai-kit --skill motion
npx skills@latest add BowTiedSwan/animejs-skills --skill animejs
# GSAP only if the project already uses GSAP:
# npx skills@latest add greensock/gsap-skills --skill gsap-core gsap-timeline gsap-scrolltrigger gsap-react gsap-performance
# Guidelines and polish
npx skills@latest add vercel-labs/agent-skills --skill web-design-guidelines
npx skills@latest add ibelick/ui-skills --skill baseline-ui fixing-accessibility fixing-motion-performance
npx skills@latest add jakubkrehel/make-interfaces-feel-better
# Example: Claude Code and Cursor only, no prompts
npx skills@latest add pbakaus/impeccable --skill impeccable -a claude-code cursor -y
```
**Optional:**
- `199-biotechnologies/motion-dev-animations-skill` (`motion-dev-animations`): many Motion recipes, but its defaults (`whileHover` scale 1.05, magnetic buttons) contradict Apple style. Reference only.
- `LottieFiles/motion-design-skill` (`motion-design`): timings and principles for Lottie.
- `VoltAgent/awesome-design-md`: not a skill. Its `design-md/apple/DESIGN.md` can go into the project as an apple.com breakdown.
- anti-slop has its own installer: `npx antislop-ai`.
**Connecting this Ruleset:** §0.1. Skills with similar triggers (frontend-design, taste, impeccable, hallmark, antislop) may load at the same time; §2 resolves their conflicts and §0.3 sets the priority.
---
## 20. Sources
**Skills:** the repositories in §19, plus `vercel-labs/web-interface-guidelines`.
**Docs:** [Motion](https://motion.dev/docs) · [anime.js v4](https://animejs.com/documentation) · [Lucide](https://lucide.dev) · [Onest](https://fonts.google.com/specimen/Onest) · [Apple HIG](https://developer.apple.com/design/human-interface-guidelines) · [WCAG 2.2](https://www.w3.org/TR/WCAG22/) · [Core Web Vitals](https://web.dev/articles/vitals)
**Versions checked against npm at compile time:** lucide-react, lucide, lucide-static, @lucide/vue, @lucide/svelte, @lucide/angular, @lucide/astro 1.67.0 · motion 14.9.0 · animejs 4.5.0 · gsap 3.15.0 · @gsap/react 2.1.2 · @fontsource-variable/onest 5.3.1.