知识广场

按学科筛选:计算机科学 / 前端开发 / CSS
清除筛选

«计算机科学 / 前端开发 / CSS» 分类下共 9 篇帖子

CSS Grid 实现响应式三栏布局(一行代码搞定 12 列网格)

flexbox 适合一维,CSS Grid 适合二维。"侧栏 + 主内容 + 副栏"经典三栏 布局用 Grid 写比 flex 简洁 5 倍。 ## 1. 最简版 ```html <div class="layout"> <aside>侧栏</aside> <main>主内容</main> <section>副栏</section> </div> ``` ```css .layout { display: grid; grid-template-columns: 200px 1fr 280px; gap: 24px; } ``` `fr` 是"剩余空间分数"。`1fr` 中间吃满,左右固定。 ## 2. 窄屏自动折叠 ```css .layout { display: grid; grid-template-columns: 200px 1fr 280px; gap: 24px; } @media (max-width: 1000px) { .layout { grid-template-columns: 200px 1fr; } .layout > section { grid-column: 1 / -1; /* 副栏跨整行,垂直堆叠 */ } } @media (max-width: 700px) { .layout { grid-template-columns: 1fr; } } ``` `grid-column: 1 / -1` 是 Grid 的常用语法:从第 1 条线到最后一条线, 即占满全宽。 ## 3. 命名区域(更清晰的多栏 / 多行) ```css .layout { display: grid; grid-template-columns: 200px 1fr 280px; grid-template-rows: auto 1fr auto; grid-template-areas: "header header header" "sidebar main rail" "footer footer footer"; gap: 16px; min-height: 100vh; } .layout > header { grid-area: header; } .layout > aside { grid-area: sidebar; } .layout > main { grid-area: main; } .layout > section { grid-area: rail; } .layout > footer { grid-area: footer; } ``` 窄屏重排,只改 `grid-template-areas`: ```css @media (max-width: 800px) { .layout { grid-template-columns: 1fr; grid-template-areas: "header" "main" "rail" "sidebar" "footer"; } } ``` ## 4. 12 列网格系统(不需要 Bootstrap) ```css .grid12 { display: grid; grid-template-columns: repeat(12, 1fr); gap: 20px; } .col-3 { grid-column: span 3; } .col-4 { grid-column: span 4; } .col-6 { grid-column: span 6; } .col-12 { grid-column: span 12; } @media (max-width: 800px) { .col-3, .col-4, .col-6 { grid-column: span 12; } } ``` ```html <div class="grid12"> <div class="col-4">A</div> <div class="col-4">B</div> <div class="col-4">C</div> <div class="col-6">D</div> <div class="col-6">E</div> </div> ``` 整套不到 30 行 CSS,干掉 Bootstrap grid 一整个模块。 ## 5. 让卡片网格自动决定列数(最常用!) ```css .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } ``` `auto-fill` + `minmax`:每列最小 260px,最大平分;窗口宽就放多列、 窗口窄就放少列,不需要任何 media query。视口超过 4×260 时一行 4 个, 缩到 2×260 时一行 2 个。 ## 6. 让子元素拉满高度 ```css .cards { display: grid; grid-auto-rows: 1fr; ... } ``` `grid-auto-rows: 1fr` 让每行所有 cell 等高,再加 `align-self: stretch` 内部内容拉满。 ## 7. subgrid(2024 + 浏览器全支持) 子元素的 grid track 对齐父元素: ```css .card { display: grid; grid-template-rows: subgrid; grid-row: span 3; /* 跨父网格 3 行 */ } ``` 适合"卡片列表里每张卡片内部各自有 header / body / footer,但希望同一行 卡片的 header / body / footer 严格对齐"。 ## 8. DevTools Chrome / Firefox 的 Inspector 里点 `grid` 标记会显示 grid 线条和 area 名字, 是调试 grid 唯一有效的方法。别盲调。 ## 踩过的坑 - `minmax(260px, 1fr)` 在窄屏(< 260px 视口)会撑破父容器;想严格防溢出 用 `minmax(min(260px, 100%), 1fr)`。 - `gap` 在 Safari 14 之前的 flexbox 不支持,Grid 支持。如果用 flex 老 Safari 兼容性差。 - 用 `grid-template-areas` 时 area 名要 **每个 cell 都有** 字符串。 `"header . header"` 用 `.` 表示空。 - 大量 grid 嵌套对老设备性能不友好。深嵌套 grid 时考虑用 `contain: layout` 限制重排范围。

Tailwind CSS v3.4+ 设计令牌定制 + 一些"高级"用法

Tailwind 的核心是 utility-first CSS,但生产里要配合品牌设计系统, 直接用默认色板 / 间距是不行的。下面是 5 个把 Tailwind 用到位的点。 ## 1. 设计令牌:用 CSS 变量做主题 ```css /* src/styles/tokens.css */ @layer base { :root { --color-brand: 220 90% 56%; /* HSL,分量分开存便于 alpha */ --color-brand-soft: 220 90% 96%; --color-text: 222 47% 11%; --color-bg: 0 0% 100%; --radius: 0.75rem; } .dark { --color-brand: 220 90% 65%; --color-text: 210 40% 96%; --color-bg: 222 47% 11%; } } ``` `tailwind.config.ts`: ```ts export default { content: ['./src/**/*.{ts,tsx}'], darkMode: 'class', theme: { extend: { colors: { brand: 'hsl(var(--color-brand) / <alpha-value>)', 'brand-soft': 'hsl(var(--color-brand-soft) / <alpha-value>)', text: 'hsl(var(--color-text) / <alpha-value>)', bg: 'hsl(var(--color-bg) / <alpha-value>)', }, borderRadius: { DEFAULT: 'var(--radius)', }, }, }, } ``` 之后: ```html <div class="bg-brand text-white rounded"> <p class="text-brand/80">透明度 80%</p> </div> ``` `/80` 是 Tailwind v3.1+ 的 alpha 语法,配合 `<alpha-value>` 占位让 HSL 变量与 opacity 联动。 切换 dark mode: ```ts document.documentElement.classList.toggle('dark') ``` 所有 token 通过 CSS 变量级联变化。 ## 2. 自定义 plugin ```ts // tailwind.config.ts import plugin from 'tailwindcss/plugin' export default { plugins: [ plugin(({ addUtilities, theme }) => { addUtilities({ '.text-balance': { 'text-wrap': 'balance' }, '.scrollbar-thin': { 'scrollbar-width': 'thin', }, // 用 theme 引用其它 token '.no-x-scroll': { 'overflow-x': 'hidden', 'background': theme('colors.bg / 50%'), }, }) }), ], } ``` `<div class="text-balance">` 应用 `text-wrap: balance`(让标题换行均衡)。 ## 3. 任意值 / arbitrary values ```html <div class="w-[273px] grid-cols-[200px_1fr_auto] before:content-['→']"> ``` 方括号语法让你写一次性的非标准值,避免为了某个奇异需求改 config。 但常用值还是放 config 里。 ## 4. group / peer 改善交互样式 ```html <!-- 父 hover 时子元素响应 --> <div class="group"> <h3 class="group-hover:text-brand">Title</h3> <p class="group-hover:opacity-100 opacity-50">Detail</p> </div> <!-- 兄弟元素影响 --> <input type="checkbox" class="peer"> <label class="peer-checked:text-brand">Toggle me</label> <!-- 命名 peer --> <input type="checkbox" class="peer/agree"> <label class="peer-checked/agree:underline">同意条款</label> ``` `group-*` / `peer-*` 是 Tailwind 的 ":has" 替代品,不需要 JS。 ## 5. responsive + container queries ```html <!-- 视口响应式 --> <div class="text-sm md:text-base lg:text-lg"> <!-- 容器查询(Tailwind v3.4+) --> <div class="@container"> <div class="@md:flex @lg:grid"> ``` 启用 container queries 插件: ```ts import containerQueries from '@tailwindcss/container-queries' export default { plugins: [containerQueries], } ``` ## 6. dark mode 策略 ```ts darkMode: 'class' // 用 .dark 类 // 或: darkMode: 'media' // 用 prefers-color-scheme // 或最新: darkMode: ['selector', '[data-mode="dark"]'] ``` `class` 模式最灵活——可以手动切;`media` 跟随系统。 现代项目通常 class + 手动初始化跟随系统。 ## 7. arbitrary properties ```html <div class="[mask:linear-gradient(black,transparent)]"> <div class="[--my-var:42px] [width:var(--my-var)]"> ``` 让任何 CSS 属性都能写在 class 里——逃生舱。 ## 8. apply(谨慎用) ```css .btn { @apply px-4 py-2 rounded bg-brand text-white; } .btn:hover { @apply bg-brand-dark; } ``` `@apply` 让你把 Tailwind class 抽成自定义类。适合: - 公司风格按钮 / input 等基础组件 - 把超长 class 列表抽出(可读性) 不适合:日常 UI(违反 utility-first 哲学)。 ## 9. just-in-time(JIT)默认开启 Tailwind v3+ 默认 JIT 模式: - 只生成你用到的 class - 任意值(`w-[273px]`)即时编译 - watch 模式毫秒级重新生成 生产 CSS 体积通常 5-20 KB(gzipped),比传统 CSS 框架轻。 ## 10. 与 shadcn/ui `shadcn/ui` 不是组件库,是 "复制粘贴的 Tailwind 组件代码": ```bash npx shadcn-ui@latest init npx shadcn-ui@latest add button card dialog ``` 把组件 .tsx 文件复制到你的项目里,你拥有源码可改。 2024 后 React 项目用 Tailwind + shadcn 几乎是 default。 ## 11. typography 插件 ```bash npm i -D @tailwindcss/typography ``` ```html <article class="prose dark:prose-invert"> <h1>...</h1> <p>...</p> </article> ``` 给纯 markdown 渲染的文章一键加上专业排版(标题大小、行高、代码块、 列表等)。 ## 12. forms 插件 ```bash npm i -D @tailwindcss/forms ``` normalize 浏览器默认 input / select / checkbox 样式差异, 让你 utility class 控制更可预测。 ## 踩过的坑 - `content` 配置漏路径:JIT 扫不到你的源文件,class 没生成 → 看着没样式。 确认 `content: ['./src/**/*.{ts,tsx,html}']` 覆盖。 - 类名拼接 / 动态字符串:`text-{color}` 这种 Tailwind 扫描不到。 改成完整字符串 `text-red-500` 写出来,或在 safelist 里列出。 - `@apply` 滥用:所有按钮都 `.btn` `.btn-lg` `.btn-primary`,最后 CSS 比手写还多。utility-first 才是 Tailwind 的正道。 - dark mode class 加错位置:必须在 `<html>` 而不是 `<body>` 上,否则 某些组件查不到。

CSS anchor positioning:原生写 tooltip / popover(不再用 Popper.js)

## 起因 每个 React 项目都装 `@floating-ui/react` 或 `popper.js` 解决 tooltip / dropdown / popover 的"跟着元素,但溢出视口时自动翻转" 这类位置计算。 代码体积 + 学习曲线 + 抽象层都有成本。 Chrome 125+(2024 中)开始支持 **CSS Anchor Positioning**, 原生 CSS 写定位浮动元素。Firefox / Safari 跟进中。 ## 解决方案 ### 最简单的 tooltip ```html <button class="btn">Hover me</button> <div class="tooltip">I am a tooltip</div> <style> .btn { anchor-name: --my-btn; /* 命名锚点 */ } .tooltip { position: absolute; position-anchor: --my-btn; /* 指定锚点 */ position-area: top; /* 在锚点上方 */ margin: 4px; } </style> ``` 效果:tooltip 自动定位到 btn 正上方 4px。 ### 自动翻转避免溢出 ```css .tooltip { position: absolute; position-anchor: --my-btn; position-area: top; /* 如果上方放不下,自动 fallback 到下方 */ position-try-fallbacks: --bottom; } @position-try --bottom { position-area: bottom; } ``` 视口顶部空间不够 tooltip → 自动跑到下面。Popper.js 的 "flip middleware" 原生实现。 ### 多 fallback ```css .tooltip { position: anchor: --my-btn; position-area: top; position-try-fallbacks: top right, bottom right, bottom left, top left; } ``` 按顺序尝试位置,第一个能完整显示的胜出。 ### dropdown menu ```html <button id="trigger" popovertarget="menu">Menu ↓</button> <menu id="menu" popover> <li><button>编辑</button></li> <li><button>删除</button></li> </menu> <style> #trigger { anchor-name: --trigger; } #menu { position-anchor: --trigger; position-area: bottom span-right; min-width: anchor-size(width); /* menu 至少跟 trigger 一样宽 */ margin: 4px 0; } </style> ``` `popover` 是 HTML 新属性(2023+),让任意元素能 toggle 显示, 配 anchor positioning 写下拉菜单几行搞定。 **完全无 JavaScript**。 ### popover API ```html <button popovertarget="dialog">Open</button> <div id="dialog" popover> <h2>对话框</h2> <button popovertarget="dialog" popovertargetaction="hide">关闭</button> </div> ``` `popover` 属性让元素自动有: - ESC 关闭 - 点击外部关闭(auto 模式) - 出现在 top layer(覆盖任何 z-index) - 焦点管理 ```css #dialog { /* 默认显示在浏览器中心 */ margin: auto; /* 进入动画 */ &:popover-open { animation: fade-in 0.2s; } } @keyframes fade-in { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } ``` modal 风格对话框: ```html <dialog id="modal"> <p>真的删除吗?</p> <button onclick="modal.close()">取消</button> <button>确认</button> </dialog> <button onclick="modal.showModal()">删除</button> ``` `<dialog>` 元素 + `showModal()` 自动焦点 trap + backdrop + ESC 关闭。 零依赖 modal。 ## 跟 Popper.js / floating-ui 对比 | | CSS anchor + popover | Popper.js / floating-ui | |---|---|---| | 浏览器支持 | Chrome/Edge 125+ / Safari/FF 跟进 | 全支持 | | bundle 影响 | 0 | 5-15 KB | | 灵活度 | 中(CSS 限制) | 高(JS 任意) | | 实施 | CSS only | JS hook + ref | | Modal trap | popover 自动 | 第三方库 | 2025-2026 浏览器覆盖率到位后可以全切。 **现在写新项目**:渐进增强——支持的浏览器用 CSS,老的退化用 JS 库。 ### 渐进增强 ```js if (CSS.supports('position-anchor: --x')) { // 啥都不做,CSS 处理 } else { // fallback 到 floating-ui await import('@floating-ui/dom').then(...) } ``` ## React 集成 只要 React 渲染对应 HTML: ```tsx function Tooltip({ children, label }) { return ( <> <span className="tooltip-target">{children}</span> <span className="tooltip-content" role="tooltip">{label}</span> </> ) } ``` ```css .tooltip-target { anchor-name: --tt; } .tooltip-content { position: absolute; position-anchor: --tt; position-area: top; } ``` 不需要 ref / 不需要 useState 控制位置。CSS 自动。 注意:每个 tooltip 实例需要唯一 anchor-name 否则冲突。 React 里: ```tsx const id = useId() <span style={{ anchorName: `--tt-${id}` }}>...</span> <span style={{ positionAnchor: `--tt-${id}` }}>...</span> ``` ## 实战:context menu 右键菜单: ```html <div id="cm-target">右键我</div> <menu id="ctxmenu" popover> <li><button>复制</button></li> <li><button>粘贴</button></li> <li><button>删除</button></li> </menu> <script> const target = document.getElementById('cm-target') const menu = document.getElementById('ctxmenu') target.addEventListener('contextmenu', (e) => { e.preventDefault() // 把 menu 定位到鼠标位置 menu.style.left = `${e.clientX}px` menu.style.top = `${e.clientY}px` menu.style.position = 'fixed' menu.showPopover() }) </script> ``` `showPopover()` API 触发显示。点外面 / ESC 自动关闭。 ## 与 dialog / modal 区别 | | dialog (showModal) | popover (showPopover) | |---|---|---| | 用法 | 阻塞性对话框 | 任意浮动元素 | | backdrop | ::backdrop 默认黑半透 | 无(popover=manual 时) | | inert | 背景内容自动 inert | 看模式 | | ESC | 自动关 | 自动关 | | 嵌套 | 多个 dialog 栈 | popover 也可栈 | 模态对话框用 `<dialog>`;非模态浮层用 `popover`。 ## 等待中的浏览器支持 ``` Chrome 125+ ✅ (2024-05) Edge 125+ ✅ Safari 17.4+ 部分 (popover 完整,anchor positioning 部分) Firefox 134+ anchor 部分 ``` 2026 末预计全支持。在那之前生产用 Popper.js 兜底; 个人项目 / Chrome 内部工具立刻可用。 ## 浏览器特性检测 ```css @supports (position-anchor: --x) { /* 新 API */ } @supports not (position-anchor: --x) { /* fallback:用 absolute + JS 控制 */ } ``` ## 我的实验感受 试用一周: - 简单 tooltip / dropdown:CSS 写 5 行 = floating-ui 50 行 - 复杂多步定位 / 动画 / 拖拽 → 还是 JS 灵活 - popover API 替代了 90% modal use case,不再需要 Headless UI Dialog 未来 1-2 年逐步迁移;现在新组件优先用 CSS 方案 + fallback。 ## 踩过的坑 1. **anchor-name 唯一性**:同 anchor-name 多个 target 时只第一个起效。 动态生成的组件用 useId 保证唯一。 2. **popover vs dialog 选错**:modal 阻塞场景仍是 dialog 的设计意图; popover 给非阻塞浮层。混用导致 UX 怪。 3. **position-try-fallbacks 顺序敏感**:写错顺序 fallback 选不优。 测视口各种尺寸验证。 4. **animation 不工作**:popover 进出动画需要 `:popover-open` + `@starting-style` 配 transition。CSS 语法新,文档要看 latest。 5. **Tailwind class 没原生支持**:要写自定义 utility。Tailwind v4 预计支持 anchor positioning utility。

CSS scroll-snap:用 CSS 写"翻页式"滚动(轮播 / 卡片流)

## 起因 要做一个商品轮播 + 滑到中间自动对齐 + 触屏滑动也丝滑。 传统做法用 swiper.js / react-slick 等库,bundle 几十 KB + 复杂 API。 `scroll-snap` CSS 几行原生实现,所有现代浏览器全支持,零 JS。 ## 1. 横向轮播 ```html <div class="carousel"> <div class="card">A</div> <div class="card">B</div> <div class="card">C</div> <div class="card">D</div> </div> <style> .carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; /* 横向 + 强制 snap */ scroll-padding: 16px; gap: 16px; padding: 16px; } .card { flex: 0 0 80%; /* 每个卡片占 80% 视口宽 */ scroll-snap-align: center; /* 滑动停在卡片中心 */ background: #eee; height: 200px; border-radius: 12px; } </style> ``` 效果: - 横向滑动 / swipe - 松手后自动对齐到最近卡片中心 - 触屏 momentum 滚动 + snap 一起 work - 鼠标 wheel / 键盘箭头都 work **完全 CSS**。 ## 2. `mandatory` vs `proximity` ```css scroll-snap-type: x mandatory; /* 强制:松手必停 snap 点 */ scroll-snap-type: x proximity; /* 接近:靠近 snap 点才 snap */ ``` `mandatory` 适合"看一个" 的轮播;`proximity` 适合"长列表偶尔对齐" 场景。 ## 3. snap-align 选项 ```css scroll-snap-align: start; /* 元素左边对齐 */ scroll-snap-align: center; /* 元素居中对齐 */ scroll-snap-align: end; /* 元素右边对齐 */ ``` ## 4. 全屏式纵向"翻页" ```html <main class="pages"> <section>第一屏</section> <section>第二屏</section> <section>第三屏</section> </main> <style> html { scroll-snap-type: y mandatory; } /* 整页 snap */ section { height: 100vh; scroll-snap-align: start; } </style> ``` 或者 `mandatory` 在 html 上: ```css html, body { height: 100%; scroll-snap-type: y mandatory; } section { scroll-snap-align: start; min-height: 100vh; } ``` 效果:滚轮 / 滑动按"屏" 翻页,落地页常用。 ## 5. snap-stop:必须 stop 在某些元素 ```css .important-card { scroll-snap-align: center; scroll-snap-stop: always; /* 不允许"飞越"这个元素 */ } ``` 普通卡片可以滚很远跳过;`always` 强制每个都停。 高 momentum 滑动时 prevent 飞过去。 ## 6. 进度指示器(dot / progress) scroll-snap 本身没有"当前第几页" 信息。要做 indicator dot: ```tsx function Carousel({ items }) { const ref = useRef<HTMLDivElement>(null) const [active, setActive] = useState(0) useEffect(() => { const el = ref.current if (!el) return const observer = new IntersectionObserver( (entries) => { entries.forEach(e => { if (e.isIntersecting) { const idx = Array.from(el.children).indexOf(e.target as HTMLElement) setActive(idx) } }) }, { root: el, threshold: 0.5 } ) Array.from(el.children).forEach(c => observer.observe(c)) return () => observer.disconnect() }, []) return ( <> <div ref={ref} className="carousel"> {items.map(item => <div key={item.id} className="card">{item.title}</div>)} </div> <div className="dots"> {items.map((_, i) => ( <span className={i === active ? 'active' : ''} /> ))} </div> </> ) } ``` `IntersectionObserver` 检测哪个卡片当前可见。 没 JS 也能 snap,只是没 indicator。 ## 7. 前进 / 后退按钮 ```tsx function Carousel({ items }) { const ref = useRef<HTMLDivElement>(null) function scroll(dir: number) { const el = ref.current if (!el) return const cardWidth = el.firstElementChild?.clientWidth ?? 0 el.scrollBy({ left: cardWidth * dir, behavior: 'smooth' }) } return ( <> <button onClick={() => scroll(-1)}>←</button> <button onClick={() => scroll(1)}>→</button> <div ref={ref} className="carousel"> {items.map(...)} </div> </> ) } ``` `scrollBy + smooth` 程序触发滚动 + snap 自动配合。 ## 8. 移动端 momentum iOS Safari 需要: ```css .carousel { -webkit-overflow-scrolling: touch; /* 老 iOS 启 momentum */ } ``` 现代 iOS 默认 momentum,但写上也无害。 ## 9. 隐藏 scrollbar 视觉上不要 scrollbar 但仍可滚: ```css .carousel { /* Firefox */ scrollbar-width: none; /* Chrome / Safari */ &::-webkit-scrollbar { display: none; } } ``` ## 完整示例:商品轮播 ```html <div class="product-carousel"> <div class="product">商品 1</div> <div class="product">商品 2</div> <div class="product">商品 3</div> <div class="product">商品 4</div> <div class="product">商品 5</div> </div> <style> .product-carousel { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; gap: 12px; padding: 16px; scrollbar-width: none; } .product-carousel::-webkit-scrollbar { display: none; } .product { flex: 0 0 calc(50% - 18px); /* 移动端每屏 2 个 */ scroll-snap-align: start; background: white; border: 1px solid #ddd; border-radius: 8px; padding: 12px; } @media (min-width: 768px) { .product { flex: 0 0 calc(25% - 12px); /* 桌面每屏 4 个 */ } } </style> ``` 响应式 + snap + 美观 < 30 行 CSS。 ## 11. 跟 Swiper.js 对比 | | scroll-snap CSS | Swiper.js | |---|---|---| | bundle | 0 | 30-100 KB | | 学习成本 | 极低(CSS 属性) | 中(API + plugin) | | 灵活度 | 中(CSS 限制) | 极高(事件 / 插件) | | 动画 / parallax | 困难 | 简单 | | autoplay | 自己写 JS | 内置 | | nested swiper | 难 | 支持 | | 多 breakpoint | media query | 内置 | 简单轮播 / 卡片流 → scroll-snap。 复杂动画 / autoplay / parallax / 大量 feature → Swiper。 ## 12. 浏览器支持 scroll-snap 全 evergreen 浏览器支持(Safari 11+ / Firefox 39+ / Chrome 69+ / Edge 79+)。 **不需要 polyfill**。 老 IE 不支持但本来也没人 care。 ## 实战 use case 我们网站几处用 scroll-snap 后: - 删除 swiper.js 依赖 → bundle 减 60 KB - 触屏滑动体验跟原生 app 一致 - 维护成本 0(CSS 一直 work) 不适合: - 复杂 carousel(autoplay / parallax / cube transition) - 多嵌套 swiper ## 踩过的坑 1. **scroll-snap-type 在父,align 在子**:写反了不 work。 2. **flex item 没设 `flex: 0 0 ...`** → 自动收缩 → 看起来都对齐 但宽度不对。强制 `flex-shrink: 0`。 3. **mobile 触屏 snap 不灵敏**:低端 Android 偶尔 snap 慢。 设 `scroll-snap-stop: always` 让必停每个。 4. **snap 让锚点跳乱**:含 `#anchor` URL 时 snap 偶尔覆盖 scroll-to-anchor。 配 `scroll-padding-top` 给固定 header 留位置。 5. **iOS Safari momentum 衰减**:iOS 上 momentum 后才 snap,感觉 slightly delayed。这是 OS 行为,无解。

CSS Container Queries:组件按父容器尺寸响应式而不是视口

媒体查询(@media)按视口尺寸响应式。但同一个组件可能放在不同宽度的 容器里(侧栏窄 / 主区宽 / 仪表盘卡片各种尺寸),媒体查询不知道 组件实际可用宽度。 Container Queries(容器查询)让组件按 **父容器** 尺寸响应式。 2023 后全 evergreen 浏览器支持,可以放心用。 ## 1. 启用容器 ```css .card-container { container-type: inline-size; /* 或者 size(同时观察宽 + 高) */ container-name: card; /* 可选,命名容器便于精确引用 */ } ``` `container-type: inline-size` 让浏览器观察这个元素的宽度变化, 开销小(不需要观察高度)。 ## 2. 容器查询 ```css @container card (min-width: 400px) { .card-title { font-size: 1.5rem; } .card-image { display: block; } } @container card (min-width: 600px) { .card { display: grid; grid-template-columns: 200px 1fr; } } ``` `@container card` 引用前面命名为 "card" 的容器。 不写名字也行:`@container (min-width: 400px)` 用最近的祖先容器。 ## 3. 完整例子:自适应卡片 ```html <div class="grid"> <div class="card-wrap"> <article class="card"> <img src="thumb.jpg" alt=""> <div> <h3>标题</h3> <p>描述...</p> </div> </article> </div> <div class="card-wrap"> <article class="card">...</article> </div> </div> ``` ```css .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; } .card-wrap { container-type: inline-size; } .card { display: flex; flex-direction: column; gap: 8px; } .card img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } /* 容器够宽时改横向布局 */ @container (min-width: 360px) { .card { flex-direction: row; } .card img { width: 120px; aspect-ratio: 1; flex-shrink: 0; } } ``` 视口窄时卡片竖排;视口宽时容器宽度 > 360px,自动横排。 **同一组件 + 同一 CSS 在不同上下文行为不同**。 ## 4. 容器查询单位 CSS 出了 `cqw` / `cqh` / `cqi` / `cqb` 等单位,相对于容器尺寸: ```css .card-title { font-size: clamp(1rem, 4cqi, 2rem); } ``` `4cqi` = 4% 容器 inline 尺寸。容器越宽字体越大,但限制在 1-2rem 之间。 ## 5. style queries(实验性) 按容器的某个 CSS 自定义属性查询: ```css .theme-dark { --mode: dark; container-type: normal; } @container style(--mode: dark) { .card { background: #1e1e1e; color: #fff; } } ``` 适合切主题不影响组件 HTML 结构。仍是实验阶段,部分浏览器支持。 ## 6. 与媒体查询配合 媒体查询管页面布局(侧栏开关、导航形态),容器查询管组件内部。 分工清晰: ```css /* 媒体查询:响应视口 */ @media (max-width: 800px) { .layout { grid-template-columns: 1fr; } .sidebar { display: none; } } /* 容器查询:响应组件可用空间 */ @container card (min-width: 400px) { .card { ... } } ``` ## 7. 命名 vs 匿名容器 匿名(不写 container-name)查询最近的祖先 container: ```css .parent { container-type: inline-size; } @container (min-width: 500px) { .child { ... } } ``` 命名让你跨层级精确引用: ```css .page { container: page / inline-size; } .card { container: card / inline-size; } @container page (min-width: 1000px) { /* 引用 page 容器 */ } @container card (min-width: 400px) { /* 引用 card 容器 */ } ``` ## 8. polyfill / 回退 老浏览器不支持时降级: ```css .card { /* 默认(窄屏 / 不支持时的样子) */ flex-direction: column; } @container (min-width: 360px) { .card { flex-direction: row; } } /* 或者 @supports 兜底 */ @supports not (container-type: inline-size) { /* 不支持容器查询的浏览器用媒体查询近似 */ @media (min-width: 600px) { .card { flex-direction: row; } } } ``` ## 9. 性能 `container-type: inline-size` 让浏览器为这个元素建立 containment context。 开销很小(不重排不重绘),但避免无脑给所有元素加。 通常每个独立组件根加一个就好。 `container-type: size`(同时观察宽 + 高)更贵些,因为元素的高度 通常由内容决定,会创建一个潜在的"无限循环"风险。 ## 10. 实际收益 之前没容器查询时,常见 hack: - 给容器加 class(`.card--wide` / `.card--narrow`)→ 业务代码要知道布局 - ResizeObserver + JS 控制 → 跨框架不一致 + 性能差 - 多套 CSS 类按 prop 切换 → 难维护 容器查询是这些痛点的官方解。组件 truly self-contained。 ## 11. 工具支持 Tailwind CSS v3.4+ 有 container queries plugin: ```html <div class="@container"> <div class="@md:flex @lg:grid">...</div> </div> ``` UI 库(shadcn / Mantine)渐渐采纳。 ## 踩过的坑 - 自己引用自己:`.card { container-type: inline-size; }` 然后 `@container (min-width: ...) .card { width: ... }` —— 改 width 会 触发容器尺寸变化 → 触发条件再判断 → 死循环。浏览器有保护但视觉上抖。 - 父子嵌套容器名相同:第二个 container-name 覆盖第一个,意外查询。 跨层 query 务必命名清楚。 - height container query 慎用:必须 `container-type: size` 且容器有 确定高度(不能完全由内容撑开)。 - 把 container-type 加到 body 上 → 全局影响,性能可能下降。粒度 控制在组件根元素。

CSS variables 做运行时主题:用户自选色彩 + 实时切换

## 起因 设计师要"用户能自选主色"——蓝色 / 紫色 / 红色 / 绿色等 5 个选项, 切换立刻全 UI 跟着变。 传统做法:每个主题独立 stylesheet,user 选 → 切换 `<link>` href。 重 + 慢 + 全 UI 闪一下。 CSS 变量(custom properties)让"颜色变量化",运行时改 root 上的变量值 → 所有引用立刻更新,无闪烁、无重新 load。 ## 基础 ```css :root { --color-primary: #2563eb; --color-primary-hover: #1d4ed8; --color-text: #1f2937; --color-bg: #ffffff; } button.primary { background: var(--color-primary); color: white; } button.primary:hover { background: var(--color-primary-hover); } ``` JS 改变量: ```js document.documentElement.style.setProperty('--color-primary', '#9333ea') // 所有用了 var(--color-primary) 的元素瞬间变紫 ``` ## 多主题 preset ```css :root { --color-primary: #2563eb; /* default 蓝 */ --color-primary-hover: #1d4ed8; } [data-theme="purple"] { --color-primary: #9333ea; --color-primary-hover: #7e22ce; } [data-theme="red"] { --color-primary: #dc2626; --color-primary-hover: #b91c1c; } [data-theme="green"] { --color-primary: #16a34a; --color-primary-hover: #15803d; } ``` 切换: ```js document.documentElement.setAttribute('data-theme', 'purple') ``` 零延迟切换。 ## 派生:HSL + alpha 灵活 把 primary 拆 H/S/L 分量存: ```css :root { --color-primary-h: 220; --color-primary-s: 90%; --color-primary-l: 56%; } button { background: hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l)); border: 1px solid hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / 0.3); } button:hover { /* hover 加深:l - 10% */ background: hsl(var(--color-primary-h) var(--color-primary-s) calc(var(--color-primary-l) - 10%)); } ``` 只改 hue 就换主题(不用每个 shade 独立定义): ```js root.style.setProperty('--color-primary-h', '270') // 紫 root.style.setProperty('--color-primary-h', '0') // 红 root.style.setProperty('--color-primary-h', '140') // 绿 ``` 省定义。 ## 用 color-mix(CSS Color 5) ```css button { background: var(--color-primary); } button:hover { background: color-mix(in srgb, var(--color-primary) 80%, black); } ``` `color-mix` 让浏览器自动算"加深 20%",不需要 hover-specific 变量。 Chrome 111+ / Safari 16.2+ / Firefox 113+ 支持。 ## 用户自定义主色:color picker ```tsx function ThemeCustomizer() { const [hue, setHue] = useState(220) useEffect(() => { document.documentElement.style.setProperty('--color-primary-h', hue.toString()) localStorage.setItem('theme-hue', hue.toString()) }, [hue]) return ( <div> <label>主色 hue</label> <input type="range" min="0" max="360" value={hue} onChange={e => setHue(+e.target.value)} /> <div style={{ background: `hsl(${hue} 90% 56%)`, width: 100, height: 30 }} /> </div> ) } ``` slider 拖动 → 全站颜色实时变。 保存到 localStorage → 下次访问还原。 ## CSS Color 5 高级 ```css :root { --color-primary: oklch(60% 0.2 250); } .text-on-primary { /* 自动算对比色 */ color: oklch(from var(--color-primary) calc(l > 50% ? 0 : 100%) 0 0); } ``` oklch 色彩空间感知更线性,主题派生更自然。 relative color syntax 让 "从 primary 派生 text" 一行写完。 Browser 支持 2024 大多 evergreen 已经 OK。 ## 暗色模式 ```css :root { --color-bg: white; --color-text: #111; } @media (prefers-color-scheme: dark) { :root { --color-bg: #0d1117; --color-text: #e6edf3; } } /* 手动覆盖 */ [data-mode="dark"] { --color-bg: #0d1117; --color-text: #e6edf3; } [data-mode="light"] { --color-bg: white; --color-text: #111; } ``` `<html data-mode="dark">` 强制;不设 attr 跟系统。 ## 防止 FOUC 如果初始化 theme 在 React 之后 → 闪一下默认主题再切。 解决:inline script 在 head 顶部立刻设: ```html <head> <script> (function() { const saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) })() </script> <link rel="stylesheet" href="/main.css"> </head> ``` 页面渲染前 theme attribute 已经在 → 没有 FOUC。 ## CSS 框架配合 Tailwind 用 CSS variables 已经标准化(v3.3+): ```js // tailwind.config.ts theme: { extend: { colors: { primary: 'hsl(var(--color-primary-h) var(--color-primary-s) var(--color-primary-l) / <alpha-value>)', }, }, } ``` ```html <button class="bg-primary text-primary-foreground">Click</button> <!-- bg-primary 自动用 CSS variable --> ``` 切换 variable → 所有 Tailwind class 立刻变。 ## 性能 CSS variable 修改是浏览器层面 reflow / repaint,非常快。 全站千个 element 用同一 variable 切换 < 1 frame(16ms)。 对比"重新 load stylesheet" 切换: - 重新解析 CSS(10-100ms) - 全 DOM repaint - 偶尔 flash variable 切换胜出。 ## 与 CSS-in-JS 对比 ```tsx // emotion / styled-components 风格 const StyledButton = styled.button` background: ${props => props.theme.primary}; ` <ThemeProvider theme={{ primary: 'blue' }}> <StyledButton>Click</StyledButton> </ThemeProvider> ``` CSS-in-JS theming: - 优点:JS 完整控制 + 静态 type - 缺点:bundle 大 + 运行时开销 + SSR 复杂 CSS variable: - 优点:原生 + 零运行时 + 兼容任何框架 - 缺点:无类型 + JS 改时要 string 我个人 2024 后偏向 CSS variable + Tailwind / shadcn 的方案, CSS-in-JS 用得少了。 ## 限制 / 注意 ### 1. variable 不能用在 @media 条件本身 ```css :root { --break: 768px; } /* ❌ 不行 */ @media (min-width: var(--break)) { ... } ``` @media 查询的值必须是 literal。 ### 2. variable 不能改 @keyframes ```css @keyframes slide { from { left: 0; } to { left: var(--target); } /* 这能用 */ } ``` keyframes 内部用 variable OK;但改 variable 后正在跑的动画不会重新算。 ### 3. 旧 IE / 老 Safari 不支持 IE 11 全不支持;Safari 9.1+ 支持但有 bug。 2024 这些已经不需考虑。 ## 我们的主题系统 ```css :root { /* 基础 hue 用户可调 */ --hue: 220; /* 派生:primary 系列 */ --color-primary-50: hsl(var(--hue) 90% 96%); --color-primary-100: hsl(var(--hue) 90% 90%); --color-primary-500: hsl(var(--hue) 90% 56%); --color-primary-700: hsl(var(--hue) 90% 36%); /* 派生:text on primary(自动选黑/白) */ --text-on-primary: white; /* 中性色 */ --color-gray-50: oklch(98% 0 0); --color-gray-500: oklch(50% 0 0); --color-gray-900: oklch(20% 0 0); } @media (prefers-color-scheme: dark) { :root { --color-primary-500: hsl(var(--hue) 80% 65%); /* 暗色下提亮 */ } } ``` 用户改 `--hue` → 整套主题色协调地变。 设计师 once 调好 hue 与各 shade 关系,用户只动 hue 不破坏视觉一致。 ## 踩过的坑 1. **`var(--x)` 没 fallback** → variable 没定义时整个声明无效: ```css color: var(--missing); /* 整个 color 不生效 */ color: var(--missing, black); /* fallback 到 black */ ``` 2. **JS setProperty 大小写敏感**:variable 名是 `--Color-Primary` 但 setProperty('--color-primary', ...) 设错变量。 3. **shadow DOM 隔离**:custom element 内部不继承 :root 变量。 需要在 shadow boundary 显式 forward。 4. **calc 不能跨单位**:`calc(var(--x) * 2px)` 错(var 已经带单位)。 存 unitless number + 在 calc 加 unit。 5. **打印模式**:`@media print` 没单独考虑主题 variable → 打印出来 是 default 颜色。专门 `@media print` 覆盖。

用 CSS 变量 + prefers-color-scheme 实现暗色模式(含手动切换)

暗色模式现在是基础体验。正确做法是用 CSS 变量统一所有颜色, 然后 `@media (prefers-color-scheme: dark)` 覆盖变量。 手动切换则用 `[data-theme="dark"]` 选择器。 ## 1. 颜色变量化 ```css :root { --bg: #ffffff; --card: #f8f9fa; --text: #1a1a1a; --text-soft: #6b7280; --border: #e5e7eb; --primary: #2563eb; --primary-soft: #eff6ff; } body { background: var(--bg); color: var(--text); } .card { background: var(--card); border: 1px solid var(--border); } ``` 任何颜色都引用变量,绝不写死 `color: #333`。 ## 2. 自动跟随系统(最少代码) ```css @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; --card: #1e293b; --text: #f1f5f9; --text-soft: #94a3b8; --border: #334155; --primary: #60a5fa; --primary-soft: #1e3a8a; } } ``` 只这样:暗色模式自动跟系统设置变。 ## 3. 手动切换(覆盖系统设置) 加 `[data-theme]` 优先级: ```css :root { --bg: #fff; /* ... */ } @media (prefers-color-scheme: dark) { :root { --bg: #0f172a; /* ... */ } } [data-theme="dark"] { --bg: #0f172a; /* ... */ } [data-theme="light"] { --bg: #ffffff; /* ... */ } ``` JS: ```js function setTheme(theme) { // 'dark' | 'light' | 'system' if (theme === 'system') { document.documentElement.removeAttribute('data-theme') localStorage.removeItem('theme') } else { document.documentElement.setAttribute('data-theme', theme) localStorage.setItem('theme', theme) } } // 启动时还原 const saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) ``` ## 4. 避免 FOUC(First Of Unstyled Content) 如果上面的代码放在 `<script>` 模块末尾,会有一闪而过的错误主题。 解决:把"还原主题"提前到 `<head>` 顶部、inline 脚本: ```html <head> <script> (function() { var saved = localStorage.getItem('theme') if (saved) document.documentElement.setAttribute('data-theme', saved) })() </script> <!-- 后面才是 CSS --> </head> ``` inline 脚本同步执行,CSS 应用前主题已就位。 ## 5. 监听系统切换 用户用着用着把系统切到暗色——网页要立刻跟上: ```js const mq = window.matchMedia('(prefers-color-scheme: dark)') mq.addEventListener('change', e => { // 只在没设手动主题时跟随 if (!localStorage.getItem('theme')) { // CSS @media 会自动响应,这里不需要做什么,但如果你有 JS // 在用 mediaquery 判断当前主题,要刷新 updateChartTheme(e.matches ? 'dark' : 'light') } }) ``` ## 6. 图像 / iframe 内容怎么办 普通 `<img>` 没法跟随暗色。常用方案: ```html <picture> <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)"> <img src="logo-light.svg" alt="Logo"> </picture> ``` SVG icon 是 CSS color 控制的(用 `currentColor`): ```html <svg class="icon" fill="currentColor">...</svg> ``` ```css .icon { color: var(--text); } ``` iframe 内容(地图、第三方嵌入)就只能自己看是否支持 `?theme=dark` 参数。 ## 7. 配色技巧 不是简单地"颜色取反",几个要点: - 暗色背景不要纯黑(#000),用 #0d1117 / #0f172a 这种"接近黑" 减少 contrast 疲劳 - 卡片背景比 body 稍亮(如 #161b22) - 文字 #e6edf3 而不是 #fff,避免高对比刺眼 - 主色 brand color 通常要在暗色下稍微 desaturate + lighten - shadow 在暗色下基本看不见,可以用 border 替代分隔 GitHub Dark / Tailwind slate 系列是优秀范本,直接借用色值。 ## 8. 给暗色添加缓动 切换时颜色突变很扎眼,加 transition: ```css :root { /* ... */ } body, .card, .button { transition: background-color .2s, color .2s, border-color .2s; } ``` 但别把 `transition: all` 加在所有元素上,会触发不必要的重排。 ## 踩过的坑 - 写死的 SVG 颜色 / inline style 是暗色模式的常见漏网之鱼。grep 整个 代码库 `#[0-9a-fA-F]{3,6}` 看哪些没用变量。 - 图表库 / 代码高亮通常自带主题,需要单独切。Pygments / Highlight.js 都有 light + dark theme 包。 - 系统暗色模式但用户手动选浅色——很多人不太会用,记得在 settings 里 加 "跟随系统 / 强制亮色 / 强制暗色" 三选项。 - 别忘了 favicon / `meta[name=theme-color]` 也支持 prefers-color-scheme: ```html <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)"> ```

CSS View Transitions API:原生页面切换动画(不再装 Framer Motion)

## 起因 要做"列表页 → 详情页"的过渡动画——卡片放大变成详情头图。 传统做法:装 Framer Motion / GSAP 写 layoutId 切换,几十行 JS。 `View Transitions API` 是 2024 年 Chrome 全面支持的浏览器原生功能, 两行 CSS + 一行 JS 就能让任何 DOM 切换变成 morph 动画。 ## 解决方案 ### 1. 最简单的同页面状态切换 ```html <style> ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; } </style> <button id="toggle">切换</button> <div id="content">Hello</div> <script> document.getElementById('toggle').addEventListener('click', () => { if (!document.startViewTransition) { // fallback: 不支持的浏览器直接改 document.getElementById('content').textContent = Math.random().toString(36).slice(2, 7) return } document.startViewTransition(() => { document.getElementById('content').textContent = Math.random().toString(36).slice(2, 7) }) }) </script> ``` 效果:内容用平滑 crossfade 切换,而不是瞬间替换。 默认动画时长 0.25s。 ### 2. 给特定元素命名 → 跨页面 morph ```html <style> .product-card img { view-transition-name: product-hero; } .product-detail .hero { view-transition-name: product-hero; } </style> ``` 列表页的卡片图和详情页的头图都叫 `product-hero`。 切换页面时(用 startViewTransition 包裹)浏览器自动 morph 一个元素到另一个。 ```ts // 列表页点击跳详情时 function navigate(href) { if (!document.startViewTransition) { location.href = href return } document.startViewTransition(() => { // 这里切换 DOM;SPA 用 router push router.push(href) }) } ``` 效果:卡片图无缝放大到详情页大图位置,浏览器算 FLIP 动画。 **不需要 Framer Motion 的 layoutId**。 ### 3. SPA 路由集成 #### React Router 6.4+ ```tsx import { useNavigate } from 'react-router-dom' import { flushSync } from 'react-dom' function MyLink({ to, children }) { const navigate = useNavigate() return ( <a href={to} onClick={(e) => { e.preventDefault() if (!document.startViewTransition) { navigate(to) return } document.startViewTransition(() => { flushSync(() => { navigate(to) }) // flushSync 让 React 立刻渲染,view transition 才能比较新旧 DOM }) }} > {children} </a> ) } ``` #### SvelteKit `+layout.svelte` 加一行: ```svelte <script> import { beforeNavigate } from '$app/navigation' beforeNavigate(({ complete }) => { if (document.startViewTransition) { const transition = document.startViewTransition(() => complete) } }) </script> ``` #### Astro 直接 `<ViewTransitions />` 标签开启全站,无需自己 hook: ```astro --- import { ViewTransitions } from 'astro:transitions' --- <head> <ViewTransitions /> </head> ``` ### 4. 自定义动画 ```css ::view-transition-old(product-hero) { animation: 0.3s ease-out fade-out; } ::view-transition-new(product-hero) { animation: 0.4s ease-in fade-in; } @keyframes fade-out { to { opacity: 0; transform: translateY(-20px); } } @keyframes fade-in { from { opacity: 0; transform: translateY(20px); } } ``` 每个命名元素独立设动画。无名的全局用 `::view-transition-old(root)`。 ### 5. 同时多个 view-transition-name 详情页同时 morph 头图 + 标题 + 按钮: ```css .list .card .img { view-transition-name: hero-img; } .list .card .title { view-transition-name: hero-title; } .detail .hero { view-transition-name: hero-img; } .detail h1 { view-transition-name: hero-title; } ``` 浏览器同时为 hero-img 和 hero-title 做 morph,看起来三个元素 "飞到"详情页位置。 注意:**同一时间 viewport 内每个 view-transition-name 只能有一个元素**。 列表页和详情页的元素不会同时存在,但列表页有两张卡片都叫 `hero-img` 就出错。要给每张卡片不同 name: ```css .card-1 .img { view-transition-name: hero-1; } .card-2 .img { view-transition-name: hero-2; } ``` 或者用 CSS variable: ```css .card .img { view-transition-name: var(--vt-name); } ``` ```jsx <div class="card" style={{ '--vt-name': `card-${id}` }}> ``` ### 6. 检测支持 ```ts if ('startViewTransition' in document) { // 支持 } else { // 不支持,立刻切换(无动画) } ``` Chrome / Edge 111+ 全支持;Safari 18+ 支持单文档;跨文档(MPA)navigation 还要 Chrome 126+。Firefox 还在开发。 不支持时优雅降级:动画不出现,但不影响功能。 ## 实战 demo ```html <!DOCTYPE html> <html> <head> <style> body { margin: 0; font-family: sans-serif; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; } .card { background: white; border-radius: 8px; overflow: hidden; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.1); } .card img { width: 100%; height: 200px; object-fit: cover; } .detail { display: none; padding: 24px; } .detail.show { display: block; } .detail .hero { width: 100%; height: 400px; object-fit: cover; border-radius: 12px; } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.3s; } </style> </head> <body> <div class="grid" id="grid"> <div class="card" data-id="1"> <img src="https://picsum.photos/seed/1/800/600" style="view-transition-name: hero-1"> <p>Card 1</p> </div> <div class="card" data-id="2"> <img src="https://picsum.photos/seed/2/800/600" style="view-transition-name: hero-2"> <p>Card 2</p> </div> </div> <div class="detail" id="detail"> <img class="hero" id="hero-img"> <button onclick="back()">← back</button> </div> <script> document.querySelectorAll('.card').forEach(card => { card.addEventListener('click', () => { const id = card.dataset.id const img = card.querySelector('img') // 详情页用同样的 view-transition-name const detailImg = document.getElementById('hero-img') detailImg.style.viewTransitionName = `hero-${id}` detailImg.src = img.src.replace('800/600', '1600/800') document.startViewTransition(() => { document.getElementById('grid').style.display = 'none' document.getElementById('detail').classList.add('show') }) }) }) function back() { document.startViewTransition(() => { document.getElementById('detail').classList.remove('show') document.getElementById('grid').style.display = 'grid' }) } </script> </body> </html> ``` 打开 Chrome 看,点 card 图片"飞"成大图 + 反向退出。零依赖。 ## 效果 - 列表 → 详情过渡从"瞬间切换"变"流畅 morph",体验提升明显 - bundle 减去 ~30KB(不装 Framer Motion) - 与现有 framework 集成只需 5-10 行 hook 代码 - 不支持的浏览器 graceful fallback 到无动画 ## 跨文档 transitions(MPA) Chrome 126+ 支持多页面 navigation 时的 view transition, 完全不需要 SPA。在两个 HTML 页面里 CSS 加: ```css @view-transition { navigation: auto; } ``` 浏览器 navigate 时自动应用 view transition。**SSR / 静态站也能有 SPA 般的过渡**。 ## 踩过的坑 1. **viewport 外的元素不参与**:如果列表卡片在视口外,detail 元素 morph 起点错误。让点击的卡片先 scrollIntoView 再触发 transition。 2. **同名元素冲突**:CSS 报"view-transition-name must be unique"。 不要在 React map 里所有 item 用同 name。 3. **transition 期间 click 失效**:transition 时整页冻结。复杂交互前 注意 transition duration 别太长。 4. **iOS Safari 18 之前不支持**:移动端覆盖度 2024 末期才到 80%+。 渐进增强是必须的。 5. **图片 src 切换 + view transition 并发**:浏览器对新 image 还没 下载完时 transition 显示空白。`<img>` 加 `decoding="sync"` 或 pre-cache 大图。

CSS 原生嵌套 + :has() + :where():2024 后不再需要 Sass 的几个功能

## 起因 写 CSS 多年装 Sass / Less / postcss-nesting 主要为了嵌套 + 父选择器 + mixin。但 2023 后浏览器原生支持: - **CSS Nesting**:原生嵌套,与 Sass 兼容语法 - **`:has()`**:父选择器(基于子元素状态匹配父元素) - **`:where()` / `:is()`**:选择器组合 + 控制优先级 - **CSS Layers (`@layer`)**:明确分层避免优先级地狱 新项目大多可以不引入 Sass。 ## 1. CSS Nesting ```css /* 旧:扁平 */ .card { padding: 16px; } .card .title { font-size: 18px; font-weight: bold; } .card .title:hover { color: blue; } .card.active { border: 2px solid blue; } .card.active .title { color: blue; } /* 新:嵌套(原生) */ .card { padding: 16px; & .title { /* & 显式指代父选择器,规范要求 */ font-size: 18px; font-weight: bold; &:hover { color: blue; } } &.active { border: 2px solid blue; & .title { color: blue; } } @media (max-width: 600px) { padding: 8px; } } ``` 要点: - `&` 是必须的(不像 Sass 可省) - 嵌套深度不要超过 3 层(可读性 / 编译 size) - @media / @supports 也能嵌套 浏览器支持:Chrome 112+ / Firefox 117+ / Safari 16.5+。 2024 中现代浏览器全支持。 ## 2. `:has()` —— 父选择器 CSS 历史上一直没法"父元素根据子元素状态变样式"。 `:has()` 终于解决: ```css /* 含图片的卡片样式不同 */ .card:has(img) { padding: 0; background: black; } /* 表单包含 invalid input 时整组红边框 */ form:has(input:invalid) { border: 1px solid red; } /* 卡片包含 .featured 时整张卡黄边 */ .card:has(.featured) { border: 2px solid gold; } /* "下个兄弟是 h2" 的段落加边距 */ p:has(+ h2) { margin-bottom: 24px; } /* 复杂:当 ul 包含 li.active 时 */ nav ul:has(li.active) { background: var(--accent-bg); } /* 没有图片的 article */ article:not(:has(img)) { text-align: center; } ``` 之前要靠 JS 加 class 解决的,现在纯 CSS。 **实际例子:自适应高亮表单** ```html <form> <label> Email <input type="email" required> </label> <label> Password <input type="password" required minlength="8"> </label> </form> ``` ```css label:has(input:invalid:not(:placeholder-shown)) { color: red; } label:has(input:valid) { color: green; } ``` 用户输入到无效状态时 label 立刻变红,不需要任何 JS。 ## 3. `:where()` —— 零优先级组合 `:where()` 把多个选择器组合,但**优先级为 0**: ```css /* :is() 优先级 = 最高的子选择器 (这里 #foo = 100) */ :is(.a, #foo) p { color: red; } /* :where() 优先级 = 0 */ :where(.a, #foo) p { color: red; } ``` 实战:写"基础重置"时希望可以被业务样式轻易覆盖: ```css /* 用 :where() 把所有标题清零,业务样式不需要 !important 就能改 */ :where(h1, h2, h3, h4, h5, h6) { margin: 0; font-weight: 400; } /* 业务样式:普通选择器优先级 1 > :where() 的 0 */ .title { font-weight: bold; } /* 生效 */ ``` 不再需要 `!important` 大战。 `:is()` 是同样组合但保留优先级,适合短化代码: ```css /* 旧 */ header h1, footer h1, main h1 { ... } /* 新 */ :is(header, footer, main) h1 { ... } ``` ## 4. `@layer` —— 显式优先级层 ```css /* base 最低 */ @layer base, components, utilities; @layer base { h1 { font-size: 2rem; } a { color: blue; } } @layer components { .btn { padding: 8px 16px; } .card { padding: 16px; } } @layer utilities { .text-center { text-align: center; } .hidden { display: none; } } ``` 后声明的 layer 总是覆盖前面的,**无视选择器优先级**。 意思: - `@layer base { h1 { ... } }` 的 h1(优先级 1) - 被 `@layer utilities { h1 { ... } }` 的同 h1(也是优先级 1)覆盖 ——因为 utilities 层在后 最强:第三方 CSS 引入时套个低优先级 layer: ```css @layer reset, vendor, base, components; @import url('https://cdn.example.com/some-lib.css') layer(vendor); /* 我的 base 总能覆盖 vendor 的所有 selector,无论它写了多复杂的 .x.y.z 选择器 */ ``` 终结"如何覆盖 Bootstrap 的样式"类问题。 ## 5. 用 PostCSS 转旧浏览器 旧浏览器(IE 11、老 Safari)支持差。生产建议加 PostCSS 转译: ```bash npm i -D postcss postcss-nesting postcss-preset-env ``` ```js // postcss.config.js export default { plugins: [ require('postcss-preset-env')({ stage: 2, features: { 'nesting-rules': true, 'has-pseudo-class': true, }, }), ], } ``` PostCSS 把 nesting / `:has()` 等转成等价旧 CSS,老浏览器也能用。 ## 6. 真实重构例子 之前用 Sass 写的组件: ```scss // Card.scss .card { padding: 16px; border-radius: 8px; background: white; &__title { font-size: 18px; font-weight: bold; } &__body { margin-top: 12px; } &--featured { border: 2px solid gold; .card__title { color: gold; } } } ``` 迁移到原生 CSS: ```css .card { padding: 16px; border-radius: 8px; background: white; & > .title { font-size: 18px; font-weight: bold; } & > .body { margin-top: 12px; } &.featured { border: 2px solid gold; & > .title { color: gold; } } } ``` 文件几乎一样,少装一个 Sass + sass-loader 依赖。 ## 效果 新项目: - 不再装 Sass / Less,CSS pipeline 简化 - `:has()` 替代了 5-6 处 JS 操控 class 的逻辑 - `@layer` 让多团队 CSS 协作不再撞车 - DevTools 调试 CSS(不再是编译后的"扁平"代码) ## 仍要用 Sass 的场景 CSS 原生还没有的: - **mixin** / `@function`:能写函数复用 - **运算 / 颜色函数**:CSS `color-mix()` 部分替代 - **partial / @use**:CSS `@import` 弱 如果只用嵌套 + 父选择器 → 原生 CSS 够;要重度逻辑还是 Sass / Stylus。 ## 踩过的坑 1. **`&` 不能省**:写 `.card { .title {} }` 在原生 CSS 里不工作 (但 Sass 工作)。必须 `& .title`。 2. **嵌套里的 type 选择器**: ```css .card { /* 旧 Sass 可以写 a {} 直接 */ a { color: blue; } /* CSS 也支持,但语义是 ".card a",跟 Sass 一致 */ } ``` 实际无差。 3. **`:has()` 性能**:浏览器要"反向"匹配,复杂 selector 可能慢。 单页面大量 `:has()` 时观察 performance。 4. **`@layer` 顺序很重要**:第一行 `@layer a, b, c` 定义顺序, 后续 `@layer a` 即使写在最后也属于第一层(低优先级)。 5. **PostCSS 转译错 `:has()`**:现有 polyfill 不完美(DOM 监听变化 性能差)。如果一定要老浏览器兼容,避免在频繁变化的元素上用。