mcpField
F
framer-code-components-overrides

framer-code-components-overrides

github.com/fredm00n设计与 UI136399 次安装1 周前更新
代码仓库

关于 framer-code-components-overrides

面向使用 Framer 开发代码组件和覆盖项的编码 Agent,提供社区维护的实践指南。内容优先考虑 Framer 原生功能,并整理实现模式与常见问题。

主要功能

  1. 01代码组件与覆盖项指南
  2. 02优先评估原生功能
  3. 03Framer 常见问题与修复
  4. 04会话更新和贡献流程

适用场景

  1. 01判断 Framer 功能是否需要代码
  2. 02排查组件或覆盖项行为异常
  3. 03更新已安装的技能副本

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/fredm00n/framerlabs --skill framer-code-components-overrides

安装后在 Claude Code 中输入/framer-code-components-overrides确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
cms.md
fiber-handlers.md
hls-video.md
patterns.md
property-controls.md
webgl-shaders.md

详细介绍

Framer 代码开发

章节标签:[C] 适用于代码组件,[O] 适用于代码覆盖,[C/O] 适用于两者。

更新与贡献

此技能在公开仓库 fredm00n/framerlabs 中维护,并由社区持续更新。已安装的副本会逐渐过时,因此以下步骤每个会话只运行一次。

项目约定

项目的 Agent 指令文件(CLAUDE.md、AGENTS.md 或同类文件)可以用一行配置此技能:

framer-code-components-overrides: auto-update, contribute

auto-update 允许 Agent 在有新版本时替换已安装的副本。contribute 允许 Agent 提议将会话中获得的经验以拉取请求的形式提交回仓库。单独使用其中任一词都有效。若没有这一行,Agent 绝不会修改已安装的文件,也绝不会创建或建议拉取请求。它只会告知有新版本。

先检查是否有新版本

在使用以下任何内容之前:

  1. 获取 https://raw.githubusercontent.com/fredm00n/framerlabs/main/skills/framer-code-components-overrides/SKILL.md,并将其 metadata.version 与本文件 frontmatter 中的版本进行比较。
  2. 版本相同,或获取失败(离线、网络受阻):继续使用此副本。如果获取失败,只需说明一次无法检查更新。
  3. **远程版本较新:**本次会话改用远程 SKILL.md,而不是本文件其余内容;并从相同位置读取参考文件(https://raw.githubusercontent.com/fredm00n/framerlabs/main/skills/framer-code-components-overrides/references/<file>.md),而不是使用本地副本。然后,根据项目约定执行(NEW 和 OLD 分别代表两个版本号):
    • **没有 auto-update:**不要改动已安装的文件,并告知用户:"framer-code-components-overrides NEW 已发布,而你的副本是 OLD,因此本次会话我将使用新版本。若要更新副本,请运行 npx skills add fredm00n/framerlabs --skill framer-code-components-overrides,或者将 framer-code-components-overrides: auto-update 添加到 CLAUDE.md 或 AGENTS.md,以便自动保持最新。此技能由社区维护:将 contribute 添加到该行(framer-code-components-overrides: auto-update, contribute),我就会提议将我们学到的内容以拉取请求的形式提交回仓库。"
    • **有 auto-update:**从仓库下载整个 skills/framer-code-components-overrides/ 文件夹(在临时目录中浅克隆最简单),然后一次性替换已安装的文件夹,这样上游已删除的文件也会一并移除。如果任何下载失败,则保持已安装的副本不变。在 git 仓库中,此更新属于普通更改:单独提交,并放到当前工作的分支或拉取请求中,绝不要直接提交到受保护分支。告知用户:"已在独立提交中将 framer-code-components-overrides 从 OLD 更新到 NEW。"如果该行中没有 contribute,则补充:"此技能由社区维护:将 contribute 添加到 CLAUDE.md 或 AGENTS.md 中的该行,我就会提议将我们学到的内容以拉取请求的形式提交回仓库。"

只从该确切仓库和路径获取更新。

将你学到的内容贡献回上游

仅当项目中此技能对应的条目包含 contribute 时才执行。

工作结束时(不要在任务进行中)检查本次会话是否发现了值得补充到此技能中的内容:该发现确实耗费了时间或导致了问题,已通过真实构建验证,对所有 Framer 用户都成立,并且此技能和 Framer 自己的文档中都没有提到。仅关于某个项目的事实不符合条件。如果没有符合条件的内容,请勿作声。

如果有:

  1. 将每条发现改写成适合陌生读者理解的内容。对照你正在处理的项目,删除所有源自该项目的信息:公司、客户和产品名称、域名、Framer 项目 ID、分支名称、文件路径、密钥和令牌、截图。保留发现日期,格式为“(2026-10-12)”。
  2. 克隆 fredm00n/framerlabs,从 main 创建分支,并编辑 skills/framer-code-components-overrides/。将每条发现放在对应主题下,修改已有条目,而不是添加近似重复内容。更新 metadata.version(修复问题时递增补丁版本,新增章节时递增次要版本),并在根目录 README.md 中今天的日期下添加一条通俗易懂的变更日志。
  3. 检查未关闭的拉取请求,看看是否已有拉取请求涵盖相同发现。如果有,就在该拉取请求中补充。
  4. 向用户展示差异并征求同意,然后再创建拉取请求。公共仓库中的拉取请求一经创建就会公开,即使从未合并也一样。
  5. 获得用户同意后,将分支推送到没有该仓库写入权限的分叉仓库,针对 main 创建拉取请求,并将链接提供给用户。提交消息、标题和说明应面向使用这些技能的人,说明此次更改添加了什么内容以及适用场景,而不是提及来自哪个项目或会话。

**维护者:**每次修改此技能、引用文件或已安装副本时,都要递增 metadata.version,否则安装副本将永远无法获取更新。

原生优先:是否真的需要代码?[C/O]

除非用户明确要求代码组件或代码覆盖项,否则应使用 Framer 原生功能构建,包括图层、变体、效果和 CMS 绑定。只为原生功能无法实现的内容编写代码,例如 WebGL、画布、第三方 SDK、滚动计算、DOM 和浏览器 API。

对于已验证的 UI 模式,这一点尤其重要。常见问题手风琴、标签页、客户评价滑块、定价切换、导航下拉菜单和模态框都可以在 Framer 中原生实现,而且原生方案更好:网站所有者无需编写代码即可编辑,将其绑定到 CMS,使用文本和颜色样式调整外观,并适配不同断点、经受 Framer 升级。即使代码组件能够正常工作、外观也正确,用它实现上述功能仍属于过度设计。

折叠面板或手风琴应由一个包含两个变体(Collapsed 和 Expanded)的组件构成,仅此而已。不要用包装组件包住整个集合:使用 Collection List 重复显示条目,让内容来自 CMS。不要添加属性、对象数组,或重新实现原生图层已有功能的字体、字重和颜色控件。

如果无法判断原生功能能否实现,请在编写代码前询问用户。询问只需一条消息;错误判断则会导致整个构建返工。

常见问题(快速查阅)

问题原因修复方法
覆盖中找不到变量文本只读取 props.children先检查 props.text——绑定变量的文本会绕过 children
字体样式未生效单独访问字体属性展开整个字体对象:...props.font
水合不匹配在渲染期间使用浏览器 API使用 isClient 状态模式
style 处出现水合错误 #425,且包含 > 选择器或带引号属性值的 SSR CSS 始终不生效React 18 的服务端渲染器会转义 <style>{css}</style> 文本,因此 > 会以 &gt; 发送," 会以 &quot; 发送渲染 <style dangerouslySetInnerHTML={{ __html: css }} />
尺寸卡在 0 / SSR 输出的尺寸一直保留从 window 读取的初始状态已等于实际值,导致 setState 不执行操作将初始状态设为 0,并在 effect 中切换(参见水合安全性)
后续发布后,覆盖找不到元素,既无错误也无需改代码它通过 React useId() 查询自身元素;水合结果与服务端渲染不同时,DOM 会保留服务端的 id在挂载后于浏览器中生成 id,或使用 ref(参见链接元素的 ID)
子元素没有宽度、offsetLeft 或 offsetParent;其上的 scroll-snap-align 被忽略Framer 在 frame 的每个子元素外包裹了一个 display: contents 元素遍历到第一个有布局框的后代元素,并使用 getBoundingClientRect 测量(参见测量 Frame 的子元素)
用户绑定令牌时,颜色值导致崩溃对于令牌,ControlType.Color 返回 {value: "#xxx"};静态值则返回字符串使用前通过 tok(v) 解包(参见颜色令牌)
覆盖属性未定义以为可以使用属性控件覆盖不支持 addPropertyControls
覆盖未出现在 Framer 的选择器下拉菜单中导出值通过调用工厂/HOC 生成器产生——Framer 的静态扫描器只识别字面量覆盖导出将每个覆盖写成字面量 export function withX(Component) 或 export const withX = (Component) => …(参见覆盖必须是字面量导出)
滚动动画异常容器上设置了 overflow: scroll在视口上使用 IntersectionObserver(参见滚动检测)
设置目标 ID 后,滚动/动画悄无声息地停止工作useScroll 目标存储在 useState 中,首次渲染时捕获了 null对需要实时读取的目标使用 useRef(参见实时读取 Refs)
findByFramerName 找不到命名的 CMS 图层图层是动态组件实例——名称不在 data-framer-name 上用一个带有预期名称的普通 frame 包裹动态组件
Code 组件显示空状态,尽管其插槽中有 Collection List插槽属性是一个包含单个元素的数组;getCollectionData(array) 找不到查询传入 props.collectionList[0](参见 references/cms.md)
对某些访客来说,CMS 事件落在错误的日期CMS 日期是以 UTC 写入的类型化墙上时钟时间,本地时间 getter 会使其偏移使用 getUTCDate() 等方法读取日期(参见 references/cms.md)
CMS 项目的所有命名图层同时消失整个重复的 Collection List 项目被制作为组件将项目保留为普通 frame(参见重复项永远不能是组件)
重写 props.style 高度的覆盖在已发布页面上不起作用Framer 将尺寸输出为生成的 CSS 类,且页面根元素带有内联 min-height: 100vh将值设为内联样式,并对根元素使用 !important(参见已发布页面的尺寸设置)
调用 router.navigate() 或脚本化点击链接后,URL 发生变化但页面未加载Framer 会将程序化的跨页面导航软化为不同步的路由变化改为就地拦截(参见跨页面导航)
HLS 视频永久呈现像素化Chrome 中使用 .m3u8,但未使用 HLS.js使用 HLS.js 动态导入模式(参见HLS 视频流)
覆盖层卡在“半按下”状态 / 需要点击两次才能关闭使用合成事件(dispatchEvent)触发 Framer 交互通过 fiber 遍历直接调用 React 处理函数(参见 references/fiber-handlers.md)
覆盖层被内容挡住父级创建了堆叠上下文使用 React Portal 在 document.body 层级渲染
着色器附加错误编译失败导致着色器为 Null在调用 attachShader() 前检查 createShader() 的返回值
TypeScript Timeout 错误使用 NodeJS.Timeout 类型改用 number——这是浏览器环境
组件显示名称需要在 Framer UI 中设置自定义名称Component.displayName = "Name"
所有曲线的缓动感觉都一样未跟踪初始距离目标发生变化时跟踪 initialDiff(参见 references/patterns.md)
绑定 URL 的筛选器不会响应程序化 URL 写入replaceState/pushState 不会触发 popstate写入后手动派发该事件(参见将状态写入 URL)
滑块拖动导致浏览器历史记录暴增 / 按后退键被拦截每次 onChange 都调用 pushState高频写入时使用 replaceState
范围输入框显示蓝色原生填充 / 滑块无法设置样式原生 <input type="range"> 浏览器样式使用 appearance:none,中和轨道样式,并针对不同引擎自定义滑块(参见设置原生范围输入框样式)
通过覆盖接入的按钮忽略 Enter 和 SpaceonTap 是指针手势;键盘和 .click() 都不会触发它为实例传入 onClick(参见组件实例)
覆盖添加的 DOM 消失,或 React 抛出 insertBefore / removeChild 错误覆盖替换或编辑了 React 管理的子元素在 React 节点旁插入同级节点,绝不要替换它们(参见在 React 节点旁插入 DOM)
隐藏表单值为空Framer 读取表单后才写入值,或输入框由代码注入使用原生隐藏输入框,并在捕获阶段的 submit 监听器中填充(参见 Framer 表单)
新的 CMS 项目只有重新加载后才会显示CMS 内容会烘焙进每次已发布的部署中重新获取页面并插入新卡片(参见刷新已发布的 CMS 内容)
任何人都能读取“私有”CMS 字段Framer 会将每个项目的所有字段发布到 .framercms 文件中不要将私有数据存入 CMS(参见CMS 数据是公开的)

目录

  • 原生优先 — 判断此功能是否需要编写代码
  • 基础 — 组件与覆写、注释、入门模板、多文件组件
  • 创作 — 属性控件、字体、颜色令牌
  • 渲染与 SSR — 水合、画布检测、并发渲染、npm 导入
  • CMS — 覆写中的文本时序、重复项、代码组件 CMS 模式、CMS 公开的内容
  • 覆写 — 特定模式 — 变体控制、fiber 处理程序、组件实例、Framer 表单
  • DOM 与性能 — 滚动检测、实时读取 ref、Portal、发布后的尺寸、跨页面导航、在 React 旁插入内容、实时 CMS 刷新、常见模式
  • 媒体 — HLS 视频、WebGL
  • 调试 — 条件日志

基础

代码组件与覆写

代码组件 [C]:添加到画布的自定义 React 组件。支持 addPropertyControls。

代码覆写 [O]:包装现有画布元素的高阶组件。不支持 addPropertyControls。

必需注释 [C/O]

始终至少包含:

/**
 * @framerDisableUnlink
 * @framerIntrinsicWidth 100
 * @framerIntrinsicHeight 100
 */

完整列表:

  • @framerDisableUnlink — 修改后阻止取消链接
  • @framerIntrinsicWidth / @framerIntrinsicHeight — 默认尺寸
  • @framerSupportedLayoutWidth / @framerSupportedLayoutHeight — any、auto、fixed、any-prefer-fixed

大多数 Framer 代码文件都可以以 // @ts-nocheck 开头。Framer 的 hook 和 prop 类型比较宽松,而且某些模式(条件 hook、fiber 遍历)无法通过严格的 TypeScript 检查。

代码覆写模式 [O]
import type { ComponentType } from "react"
import { useState, useEffect } from "react"

/**
 * @framerDisableUnlink
 */
export function withFeatureName(Component): ComponentType {
    return (props) => {
        // State and logic here
        return <Component {...props} />
    }
}

命名:始终使用 withFeatureName 前缀。

覆写不接受属性控件,也无法查询 CMS;只有在 CMS 内容渲染后,才能读取这些内容(参见 CMS)。其设置存放在文件顶部的常量区块中。

覆写必须是字面量导出(选择器检测)[O]

Framer 的覆写选择器会静态扫描文件,只列出语法上可识别为覆写的导出——字面量 function 声明,或直接赋给导出的箭头函数。值为函数调用返回值的导出对扫描器不可见:它不会出现在覆写下拉菜单中(即使按名称引用时可以正常工作)。

尝试通过工厂函数合并几个几乎相同的覆写时,这个问题尤为明显:

// ❌ BROKEN — factory hides the overrides from the picker.
// Only literal functions in the file get listed; these three never appear.
const setVariant = (name) => (Component) => (props) => {
    const [, setStore] = useStore()
    return <Component {...props} onClick={() => setStore({ variant: name })} />
}
export const withSetVariant1 = setVariant("Variant 1") // not listed
export const withSetVariant2 = setVariant("Variant 2") // not listed
export const withSetVariant3 = setVariant("Variant 3") // not listed

// ✅ CORRECT — each override is a literal function; all are listed.
export function withSetVariant1(Component): ComponentType {
    return (props) => {
        const [, setStore] = useStore()
        return <Component {...props} onClick={() => setStore({ variant: "Variant 1" })} />
    }
}

两种字面量形式都能识别:export function withX(Component) {…} 或 export const withX = (Component) => (props) => …。规则是:绝不要通过调用辅助函数来生成覆写——每个覆写都要重复写出字面量形式,即使代码会更冗长。(完整示例——3 个 setter 和 1 个 reader 共享一个 store——参见 references/patterns.md → 覆写之间的共享状态。)

代码组件模式 [C]
import { motion } from "framer-motion"
import { addPropertyControls, ControlType } from "framer"

/**
 * @framerDisableUnlink
 * @framerIntrinsicWidth 300
 * @framerIntrinsicHeight 200
 */
export default function MyComponent(props) {
    const { style } = props
    return <motion.div style={{ ...style }}>{/* content */}</motion.div>
}

MyComponent.defaultProps = {
    // Always define defaults
}

addPropertyControls(MyComponent, {
    // Controls here
})
多文件组件 [C]

拆分到多个源文件中的组件,每个源文件都需要一个 Framer 代码文件;文件之间的每个导入都必须包含扩展名:./constants.tsx,而不是 ./constants。


创作

属性控件参考 [C]

完整的控件类型和模式请参见 references/property-controls.md。

字体处理 [C/O]

绝不要单独访问字体属性。始终展开整个字体对象。

// ❌ BROKEN - Will not work
style={{
    fontFamily: props.font.fontFamily,
    fontSize: props.font.fontSize,
}}

// ✅ CORRECT - Spread entire object
style={{
    ...props.font,
}}

字体控件定义:

font: {
    type: ControlType.Font,
    controls: "extended",
    defaultValue: {
        fontFamily: "Inter",
        fontWeight: 500,
        fontSize: 16,
        lineHeight: "1.5em",
    },
}
颜色令牌(ControlType.Color)[C]

用户选择静态颜色时,ControlType.Color 的值会以纯字符串的形式传入;绑定到 Framer 颜色令牌时,则会以 { value: "#xxx" } 对象的形式传入。直接读取该值的组件会在用户绑定令牌时立刻出错。

始终进行解包:

const tok = (v: any) =>
    v && typeof v === "object" && "value" in v ? v.value : v

const bg = tok(props.background) // string in both cases

在解析颜色、生成 CSS 字符串或设置画布样式时,使用 tok() 处理颜色属性。其他可绑定令牌的控件(尺寸、阴影)也可能采用相同的包装结构,使用前请先确认。


渲染与 SSR

Hydration 安全性 [C/O]

Framer 会在服务端预渲染。SSR 期间无法使用浏览器 API。

两阶段渲染模式:

const [isClient, setIsClient] = useState(false)

useEffect(() => {
    setIsClient(true)
}, [])

if (!isClient) {
    return <Component {...props} /> // SSR-safe fallback
}

// Client-only logic here

绝不要在渲染时直接访问:

  • window、document、navigator
  • localStorage、sessionStorage
  • window.innerWidth、window.innerHeight

应在 useEffect 中处理,而不是 useLayoutEffect,否则可能出现 hydration 不匹配和首次绘制闪烁。

初始状态必须与 SSR 一致,然后在 effect 中切换:

// ❌ BROKEN — looks "smart" but isn't
const [vw, setVw] = useState(
    typeof window !== "undefined" ? window.innerWidth : 0
)

useEffect(() => {
    setVw(window.innerWidth) // no-op: state already equals window.innerWidth
}, [])

初始状态已经等于客户端的实际值,因此 setState 不会产生任何变化,SSR 输出的尺寸(始终为 0)会永远保留在渲染后的 DOM 中。

// ✅ CORRECT — start at SSR-safe value, force a re-render via effect
const [vw, setVw] = useState(0)
const [vh, setVh] = useState(0)

useEffect(() => {
    const onResize = () => {
        setVw(window.innerWidth)
        setVh(window.innerHeight)
    }
    onResize()
    window.addEventListener("resize", onResize)
    return () => window.removeEventListener("resize", onResize)
}, [])

// Hide the first paint (SSR'd at 0) until the effect has populated real values
<div style={{ opacity: vw > 0 ? 1 : 0 }} />

将 > 0 不透明度门控与 effect 中的状态切换配合使用,可隐藏首次绘制;否则刷新时会看到尺寸从 0/默认值跳变为实际值。

用于关联元素的 Id [C/O]

绝不要通过 React 生成的 id(useId())关联元素,例如使用 document.querySelector('[data-x="' + useId() + '"]') 查找自身元素的 override。当已发布页面使用与服务端渲染不同的树进行 hydration 时,DOM 会保留服务端的 id,而客户端代码却会查找自己的 id。查找结果为 null,不会抛出任何错误,但之前能正常工作的控件在下一次发布后就会失效。

如果目标元素会转发 refs,请使用 ref。否则,请在浏览器中于挂载后生成 id,并且只在 id 存在时才渲染该属性:

function useClientId() {
    const [id, setId] = useState<string | null>(null)
    useEffect(() => {
        setId("x-" + Math.random().toString(36).slice(2, 10))
    }, [])
    return id
}

const id = useClientId()
<Component {...props} {...(id ? { "data-x": id } : {})} />
// every lookup returns early while id is null

每次发布或修改代码后,都要重新测试页面上的所有交互元素,而不仅是你修改的元素:这类问题会出现在未触碰过的代码中。

画布与预览检测 [C/O]
import { RenderTarget } from "framer"

const isOnCanvas = RenderTarget.current() === RenderTarget.canvas

// Show debug only in editor
{isOnCanvas && <DebugOverlay />}

适用于:

  • 调试叠加层
  • 在编辑器中禁用高开销效果
  • 预览切换
并发渲染:使用 startTransition 包装状态更新 [C/O]

Framer 使用 React 的并发渲染器。在事件处理程序中进行多次 setter 更新(步进器、异步链、表单字段)时,负载较高可能会导致卡顿。请使用 startTransition 包装非紧急更新:

import { startTransition } from "react"

const handleClick = () => {
    startTransition(() => {
        setQty((q) => ({ ...q, [id]: q[id] + 1 }))
        setError(null)
    })
}

const onSubmit = async () => {
    startTransition(() => {
        setLoading(true)
        setError(null)
    })
    try {
        const res = await fetch(...)
        // ...
    } catch (e) {
        startTransition(() => {
            setError(e.message)
            setLoading(false)
        })
    }
}

不要包装用户输入对应的 setter(onChange → setValue)——它需要立即响应。

NPM 包导入 [C/O]

标准导入方式(首选):

import { Component } from "package-name"

Framer 缓存卡住时,通过 CDN 强制指定版本:

import { Component } from "https://esm.sh/[email protected]?external=react,react-dom"

React 组件始终要包含 ?external=react,react-dom。


CMS

Override 中的内容加载时机 [O]

CMS 文本会在 hydration 后异步传入 props.text(约 50–200ms)。对于从组件属性绑定的可变文本,首次渲染时会同步传入,无需等待。

以下可靠模式适用于两种情况:使用 resolvePlainText(props)(见下文的 Override 中的文本),并在值非空时才继续处理:

const plainText = resolvePlainText(props)
// plainText is "" until content arrives → gate your animation on plainText.length > 0

避免任意延迟 100ms——如果元素在加载时已经位于视口内,这会导致竞态条件。

覆盖项中的文本 [O]

文本的来源取决于设置方式,有以下两种:

来源所在位置可用时间
静态文本(在 Framer 中输入)props.children 嵌套结构首次渲染时始终可用
绑定变量的文本(组件属性/CMS)props.text(纯字符串)变量在首次渲染时可用;CMS 内容异步加载

始终先检查 props.text,再回退到 children:

import { isValidElement } from "react"

function extractParts(raw: any): any[] {
    if (typeof raw === "string") return [raw]
    if (isValidElement(raw)) return [raw]
    if (Array.isArray(raw)) return raw.flatMap(extractParts)
    return []
}

function toPlainText(parts: any[]): string {
    return parts.map((p) => (typeof p === "string" ? p : "\n")).join("")
}

function resolvePlainText(props: any): string {
    if (typeof props.text === "string" && props.text.length > 0) {
        return props.text  // variable-bound or CMS
    }
    const raw = props.children?.props?.children?.props?.children
    return toPlainText(extractParts(raw))  // static text
}

切勿假设文本只存在于 props.children 中。 绑定变量的文本会绕过 children 结构——props.children 中会包含占位内容,而 props.text 中才有实际值。如果只读取 children,就看不到变量文本。

重复项永远不要做成组件 [C]

切勿将 Collection List 的整个重复项做成组件。findByFramerName 遍历器通过 props.children 读取 React 元素树,而组件实例的内部树要等到渲染后才会出现。因此,将重复项包装在组件中会一次性隐藏所有命名图层(某次构建中,背景、视频和移动标题全部消失,重复项也缩成了一个背景图层)。组件可以放在各个命名框架内部;重复项顶层的命名框架则应保持为普通框架。

代码组件中的 CMS [C]

代码组件通过 ControlType.ComponentInstance 插槽接收 Framer CMS Collection List,然后遍历生成的 React 元素树以提取每个项目的内容。核心辅助函数:

  • 使用 useQueryData + getCollectionData 实例化项目;插槽属性是一个数组,因此传入第一个元素
  • 使用 addFieldsToSelect 读取项目模板未绑定的字段
  • CMS 日期是以 UTC 写入的墙上时钟时间:使用 getUTC* 读取日期
  • 使用 findByFramerName 从每个项目的模板中提取命名图层
  • 如果需要能识别动态组件的名称,必须用普通框架包裹该组件
  • 如果一个 CMS 组件包装了另一个组件,使用 getPropertyControls(WrappedComponent) 继承控件

完整模式、辅助代码和注意事项:请参阅 references/cms.md。

CMS 数据是公开的 [C/O]

已发布页面的 HTML 只包含页面绑定的字段,但 Framer 也会将每个集合发布为 framerusercontent.com/cms/... 上的 .framercms 数据文件,其中包含每个项目的所有字段。加载更多分页和其他运行时读取操作会获取这些文件,任何人都可以下载。页面未显示的字段(例如电子邮件地址、内部备注)仍是公开的;删除字段后,旧部署中的文件仍会保留在线。

  • 切勿在 Framer CMS 中存储私人数据,无论数据来自表单、插件还是服务器 API。
  • 通过覆盖项限制 CMS 内容只会将其隐藏在屏幕上;数据仍保存在文件中。
  • 私人数据或用户专属数据应存储在运行时读取的外部存储中(例如使用行级安全策略的 Supabase),而不是 CMS。

覆盖项 — 特定模式

变体控制 [O]

无法从属性中读取变体名称(它们可能经过哈希处理)。请在内部管理:

export function withVariantControl(Component): ComponentType {
    return (props) => {
        const [currentVariant, setCurrentVariant] = useState("variant-1")

        // Logic to change variant
        setCurrentVariant("variant-2")

        return <Component {...props} variant={currentVariant} />
    }
}
触发 Framer 附加的处理器 [O]

合成 DOM 事件(dispatchEvent)无法可靠地触发 Framer Motion 处理器——它们会让元素停留在半按下状态。应改为从 DOM 节点向上遍历 React fiber 树,找到包含处理器的 fiber 并直接调用:

const onTap = findFiberHandler(wrapper, "onTap")
onTap?.({} as any, {} as any)

完整辅助函数、调试代码片段、深层链接用例和维护风险:请参阅 references/fiber-handlers.md。

组件实例:onClick、variant、作用域 CSS [O]

画布上的组件实例可以通过覆盖项由代码驱动:

export function withRefreshButton(Component): ComponentType {
    return (props) => {
        const [loading, setLoading] = useState(false)
        return (
            <Component
                {...props}
                variant={loading ? "Loading" : "Default"} // variant *name* from the canvas
                onClick={() => run(setLoading)}         // onClick, not onTap
                aria-label="Refresh"
            />
        )
    }
}
  • 使用 onClick,不要使用 onTap。 两者都会触发实例的根元素,但 onTap 属于指针手势: 聚焦元素上的 Enter 和 Space,以及脚本调用的 .click() 都不会触发它。
  • variant 接收变体名称,因此由代码驱动的状态(加载中、激活)可以复用画布上设计的变体。
  • 与循环动画关联的加载状态应在完整循环结束时结束。 如果图标每秒旋转一圈,就将 Loading 变体保持 max(1, ceil(elapsed / 1000)) 秒;否则,请求若在 300 ms 内完成,图标会在旋转到一半时跳回。
  • 没有样式控件的内置组件(例如分页中的“Load More”按钮)可以通过任意覆盖项注入作用域限定为 [data-framer-name="<instance name>"] 的 <style> 来重新设置样式。先在画布上为实例命名;图层名称会在发布后保留,而生成的 .framer-* 类不会。
Framer 表单 [O]

以下是原生 Framer 表单的提交方式,以提交到 Webhook 的表单为例:

  • 浏览器使用全局 fetch 向 api.framer.com/forms/v1/forms/<id>/submit 发送请求,并添加工作量证明标头。表单还会自行渲染隐藏的蜜罐输入框。
  • Framer 存储提交内容后,Success 变体便会立即显示,此时 Webhook 或电子邮件目标尚未运行。页面无法获知 Webhook 是否接受了提交;如果访客需要了解这一点,Webhook 必须提供其判定结果(可由覆盖项轮询一个简单的状态端点)。
  • 检测提交是否成功,方法是一次性包装 window.fetch,并在请求为指向 api.framer.com/forms/ 的 POST 且 res.ok 时进行匹配。请求正文就是已提交的数据,因此覆盖项可以从中读取字段。
  • 添加计算字段(令牌、访客 IP),应在画布上使用真正的隐藏输入框(formInputHidden 加上 formInputName),而不是通过代码注入;然后在表单上添加捕获阶段的 submit 监听器来填充字段,该监听器会先于 Framer 的处理程序读取表单而运行。
useEffect(() => {
    const form = document.querySelector("form")
    const input = form?.querySelector('input[name="ip"]') as HTMLInputElement | null
    if (!form || !input) return
    const fill = () => {
        const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set
        set?.call(input, valueRef.current) // native setter so React sees the change
    }
    form.addEventListener("submit", fill, true) // capture phase
    return () => form.removeEventListener("submit", fill, true)
}, [])

DOM 与性能

滚动检测限制 [C/O]

Framer 的滚动检测使用基于视口的 IntersectionObserver。对容器应用 overflow: scroll 会破坏此检测。

对于由滚动触发的动画,请使用:

const observer = new IntersectionObserver(
    (entries) => {
        entries.forEach((entry) => {
            if (entry.isIntersecting && !hasEntered) {
                setHasEntered(true)
            }
        })
    },
    { threshold: 0.1 }
)
实时读取的引用:使用 useRef,而非 useState [C/O]

每次事件发生时都会实时读取 .current 的钩子(Framer Motion 的 useScroll、IntersectionObserver 目标、RAF 循环)必须接收 useRef。将目标存入 useState 会在首次调用钩子时捕获 null,并且状态变化后也不会重新订阅。

这个陷阱常常看起来能正常工作——useScroll 会静默地回退到窗口滚动,因此页面乍看之下似乎会动,直到你用 id="..." 固定目标后一切都冻结。

// ❌ BROKEN — useScroll captures target: null on first render
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null)
useEffect(() => { setScrollEl(document.getElementById("section")) }, [])
const { scrollYProgress } = useScroll({ target: scrollEl })

// ✅ CORRECT — useScroll reads ref.current live on each event
const scrollRef = useRef<HTMLElement | null>(null)
useEffect(() => { scrollRef.current = document.getElementById("section") }, [])
const { scrollYProgress } = useScroll({ target: scrollRef })

适用于每次事件发生时通过引用句柄读取数据的任何 API,不仅限于 useScroll。

测量框架的子元素 [O]

Framer 可以将框架的每个子元素(例如堆栈中的组件实例)包装在一个 display: contents 元素中。该包装元素没有盒子:children[i] 不会报告宽度、offsetLeft 或 offsetParent,设置在其上的 scroll-snap-align 也会被忽略。应向下查找第一个拥有盒子的后代,并使用相对于滚动容器的 getBoundingClientRect() 进行测量,而不是使用 offsetLeft。

Z-index 堆叠上下文与 React Portal [C/O]

问题: 使用 position: absolute 的组件会继承其父级的堆叠上下文。即使设置 z-index: 9999,也无法显示在父级之外的元素上方。

解决方案: 使用 React Portal 在 document.body 层级渲染:

import { createPortal } from "react-dom"

export default function ComponentWithOverlay(props) {
    const [showOverlay, setShowOverlay] = useState(false)

    return (
        <div style={{ position: "relative" }}>
            {/* Main component content */}

            {/* Overlay rendered outside parent hierarchy */}
            {showOverlay && createPortal(
                <div style={{
                    position: "fixed",  // Fixed to viewport
                    inset: 0,
                    zIndex: 9999,
                    background: "rgba(0, 0, 0, 0.8)",
                }}>
                    {/* Overlay content */}
                </div>,
                document.body
            )}
        </div>
    )
}

主要区别:

  • position: "fixed" 相对于视口定位,而不是相对于父级
  • Portal 会跳出组件的 DOM 层级结构和堆叠上下文
  • 适用于模态框、工具提示、弹出框和加载遮罩

画布与已发布网站: Portal 在画布编辑器和已发布网站中都能使用。无需检查 RenderTarget。

已发布网站上的尺寸无法被 Override 读取 [O]

在已发布的 Framer 页面中,有两种尺寸不会传递到 Override 的 props 中:

  • Frame 的高度会作为生成的 CSS 类(.framer-xxxxxxx { height: 100vh })输出,而不是内联样式,因此无法从 props.style 中读取或重写高度。请改为传入显式的内联 height;内联样式的优先级高于生成的类。
  • 每个页面根元素在 <div data-framer-root> 上都有内联的 min-height: 100vh。要覆盖它,必须使用 !important。

这两点都会影响手机上的全屏页面:100vh 是大视口高度,以浏览器 URL 栏隐藏时的尺寸为准,因此在这两处都改为 100svh 前,单屏页面会滚动几像素。最简单的修复方法是添加适用于整个网站的自定义 CSS:[data-framer-root] { min-height: 100svh !important },并设置 Frame 自身的高度;备用方案是在 Override 中将两者都设为内联样式(2026-08-19)。请使用 [data-framer-name="…"] 定位已发布的元素,它在发布后仍会保留;不要使用生成的 .framer-* 类,因为它们会变化。

无法以编程方式跨页面导航 [C/O]

已发布的 Framer 网站是单页应用,所有通过脚本跳转到其他页面的操作——无论是 router.navigate()、对锚点调用 .click(),还是 window.location.replace()——都会变成不同步的路由更改:URL 更新了,但页面没有加载。请改为在当前页面内进行判断或协调:写入 localStorage 令牌、触发自定义 window 事件,然后让当前页面上的 Override 作出响应。唯一的例外是在完整页面加载时执行的重定向,例如在 head 脚本中执行,或在应用启动前执行:此时 location.replace() 会触发真正的导航,因此有效。

将状态写入 URL [C]

组件可以通过写入查询参数来驱动 Framer 的原生 URL 绑定筛选。Framer 的筛选功能负责读取和筛选——组件只需写入参数并保持同步。写入时有两条不太明显的规则:

  • 高频写入时使用 replaceState,而不是 pushState(例如拖动滑块、实时筛选)。pushState 会为每个事件添加一条历史记录,导致返回按钮无法正常使用。
  • history.replaceState / pushState 不会触发 popstate。 只有浏览器驱动的导航(后退/前进、输入 URL)才会触发。因此,写入后需要自行分发该事件,否则页面的其他部分无法获知更改:
const url = new URL(window.location.href)
url.searchParams.set(paramName, String(value))
window.history.replaceState({}, "", url.toString())
// replaceState is silent — fire popstate so URL-bound consumers re-sync
window.dispatchEvent(new PopStateEvent("popstate"))

组件还会监听 popstate,以便在后退/前进时重新读取参数并同步自身 UI 状态(需限制范围并验证——参数可由用户编辑)。请从 props 初始化状态,而不是从 URL 初始化,以确保 SSR 结果一致;首次读取 URL 应在 useEffect 中进行。这仅适用于页面内——它不会触发导航,因此可以避开跨页面导航问题。其思路与自定义事件状态总线相同,但使用浏览器原生的 popstate 通道,因此 Framer 自身的 URL 筛选可以直接获取状态。

设置原生范围输入框的样式 [C]

Framer 不支持为原生 <input type="range"> 设置样式,而且浏览器会自行绘制蓝色轨道和滑块。要彻底重设样式:

  • 在输入框上设置 appearance: none(以及 -webkit-appearance: none)和 background: transparent。
  • 在两种引擎中将轨道设为中性:::-webkit-slider-runnable-track 和 ::-moz-range-track。
  • 在两种伪元素 ::-webkit-slider-thumb 和 ::-moz-range-thumb 中重绘滑块——appearance:none 也会移除原生滑块,因此需要在每种引擎中分别重建。
  • 在下方使用绝对定位的 <div> 绘制可见轨道和填充部分;透明输入框置于其上方。
  • 在 <style> 标签中用 className(.rs-thumb)限定选择器范围——裸写 input[type=range] 会影响页面上的所有范围输入框。

双滑块(两个输入框叠放在同一轨道上): 对每个输入框设置 pointer-events: none(这样相互重叠的输入框不会挡住彼此或轨道),并且只对滑块伪元素设置 pointer-events: auto——只有滑块可以拖动。应在 onChange 处理函数中阻止滑块交叉(将每个滑块限制在另一个滑块 ± 一个步长的范围内),而不是在 DOM 中处理。

在 React 节点旁插入 DOM [O]

如果覆盖代码需要在 Framer 渲染的容器(例如 Collection List)中添加元素,应添加同级节点,保留 React 自己的节点不动。React 会保留对其渲染子节点的引用,之后还会移动、更新和移除它们(分页会追加页面,切换标签页会卸载列表)。替换或重新设置这些子节点的父节点会破坏下一次协调;放置在它们之间的外部节点则会保留,因为 React 只会操作自己的节点。

  • 使用 reactNode.before(mine) / after(mine) 插入节点,并为自己的节点添加标记(data-mine="<key>"),以便查找、跳过和重新放置。
  • 使用 MutationObserver 监听容器,在 React 重新挂载或追加节点时重新放置自己的节点。确保放置操作幂等:如果容器中已有且只有你的节点,就不做任何事。否则,每次无关的重新渲染都会移除并重新插入这些节点,导致动画重新开始。
  • 在进行自己的修改后立即调用 observer.takeRecords(),以免这些修改触发新一轮处理。
  • 切勿使用 React 也会管理的内联样式隐藏 React 节点;React 可能会重写它。如果必须隐藏某个元素,请使用属性并注入样式表规则。
原位刷新已发布的 CMS 内容 [O]

Framer 网站是静态部署:新的 CMS 条目只有在页面的新版本发布后才会存在,运行中的页面永远不会获取到它。要在不重新加载页面的情况下显示新条目:

  1. 再次获取当前页面(fetch(location.pathname + "?x=" + Date.now(), { cache: "no-store" }))。 Framer 页面使用 max-age=0,因此这会返回最新部署版本。
  2. 使用 DOMParser 解析页面,并通过 [data-framer-name="..."] 读取列表中已渲染的条目。服务器渲染的 HTML 包含 Collection List 的第一页(按画布中设置的排序方式,无论 URL 查询参数是什么),每个日期字段都带有 <time datetime>,足以用于为条目建立索引并重新排序。
  3. 与页面加载时已有的条目进行比较,只插入新条目(见上文)。

提交表单后,发布可能需要 5 到 15 秒:每隔几秒轮询一次,并设置时间限制;等待期间应向访客显示明确的状态。

常见模式

有关共享状态、键盘检测、仅显示一次逻辑、滚动效果、磁性悬停、动画触发器、移动端优化、Safari SVG 修复、加载状态下锁定滚动,以及使用 lerp 动画的缓动曲线,请参阅 references/patterns.md。


媒体

HLS 视频流式传输(.m3u8)[C]

Chrome/Firefox 不原生支持 HLS——直接使用 <video src="...m3u8"> 会失败,或永远只播放最低画质版本(Safari 可原生处理)。解决方法:通过动态导入加载 HLS.js,并在失败时静默回退到原生视频播放。

完整的 loadHls / attachHls 实现和清理说明,请参阅 references/hls-video.md。

Framer 中的 WebGL [C]

着色器实现模式(包括透明度处理),请参阅 references/webgl-shaders.md。


调试

调试日志 [C/O]

在组件中,将每个 console.log 放在模块级布尔值的控制之下,以免生产构建泄露数据或产生噪声。切勿直接随意插入 console.log——之后要关闭它们,就得逐一查找。

const debugMode = false // flip to true when debugging this component

if (debugMode) console.log("Honeypot active, fields:", values)

对于处理用户输入(表单值)、身份验证状态或第三方令牌的组件,这一点尤其重要,否则这些内容会出现在生产环境的控制台日志中。

评论

支持 Markdown