设计实践

给 Framer 活动页做一个“倒计时”代码组件

给 Framer 活动页做一个“倒计时”代码组件:设计师在属性面板里填目标时间、结束文案、颜色和字体,组件每秒刷新,时间到了自动显示“活动已开始”。核心计算写成纯函数,用 Node 自带测试验证。

BINBHO · 图文教程

给 Framer 活动页做一个“倒计时”代码组件:设计师在属性面板里填目标时间、结束文案、颜色和字体,组件每秒刷新,时间到了自动显示“活动已开始”。核心计算写成纯函数,用 Node 自带测试验证。

先看清成品

计算函数在本机用 node --test 跑了 4 个测试并通过;第一版因为 Date.parse("11/11") 被当成合法日期而测试失败(见调试示例),已修复。组件文件用 esbuild 编译通过(把 react、framer 当外部依赖),确认语法正确。没有在 Framer 编辑器里实际加载组件,属性面板的显示以 Framer 为准。

注意:Framer 的计划、价格和自定义域名、CMS 条目数等限额以官方价格页为准,本文不列具体数字。只使用你有权使用的图片、字体和文案;不要共享账号。

做完之后你会得到什么?

成果 说明
Countdown 组件 天 / 时 / 分 / 秒,等宽数字不跳动
属性面板 目标时间、结束文案、文字颜色、数字颜色、字体
时区安全 只接受带时区的 ISO 时间
测试 跨天、已结束、格式错误、补零

需要准备哪些环境?

项目 要求 说明
Framer 账号 能创建代码文件的项目 左侧 Assets → Code → New Code File
Node.js 18 以上(本机 v22) 本地测试

文件结构

26-framer-A-src/
├── countdownLogic.js        # 纯函数,本地测试用
├── countdown.test.mjs
├── Countdown.tsx            # 引用 countdownLogic.js 的开发版
└── Countdown.framer.tsx     # 合并后的单文件,直接粘贴进 Framer

第 1 步:计算逻辑

countdownLogic.js:

// 纯函数:计算距离目标时间的剩余天/时/分/秒(与 React 无关,方便测试)
// 只接受带时区的 ISO 8601,避免 Date.parse 按访客本地时区“猜”出一个时间
const ISO_TZ = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:\d{2})$/

export function remaining(targetISO, now = Date.now()) {
  const target = ISO_TZ.test(targetISO) ? Date.parse(targetISO) : NaN
  if (Number.isNaN(target)) return { invalid: true }
  let ms = Math.max(0, target - now)
  const done = ms === 0
  const d = Math.floor(ms / 86400000); ms -= d * 86400000
  const h = Math.floor(ms / 3600000); ms -= h * 3600000
  const m = Math.floor(ms / 60000); ms -= m * 60000
  const s = Math.floor(ms / 1000)
  return { d, h, m, s, done }
}
export const pad = (n) => String(n).padStart(2, "0")

第 2 步:本地测试

countdown.test.mjs:

import test from "node:test"
import assert from "node:assert"
import { remaining, pad } from "./countdownLogic.js"
const T = "2026-11-11T00:00:00+08:00"
test("跨天", () => assert.deepStrictEqual(remaining(T, Date.parse("2026-11-09T22:58:30+08:00")), { d: 1, h: 1, m: 1, s: 30, done: false }))
test("已结束", () => assert.strictEqual(remaining(T, Date.parse("2026-11-12T00:00:00+08:00")).done, true))
test("格式错误", () => {
  assert.ok(remaining("11/11", 0).invalid)
  assert.ok(remaining("2026-11-11T00:00:00", 0).invalid) // 缺时区
})
test("补零", () => assert.strictEqual(pad(7), "07"))
node --test

检查结果:# pass 4、# fail 0。

第 3 步:组件

Countdown.tsx:

import { useEffect, useState } from "react"
import { addPropertyControls, ControlType } from "framer"
import { remaining, pad } from "./countdownLogic.js"

/**
 * 活动倒计时
 * @framerSupportedLayoutWidth auto
 * @framerSupportedLayoutHeight auto
 */
export default function Countdown(props) {
    const { target, endText, color, accent, font } = props
    const [now, setNow] = useState(() => Date.now())

    useEffect(() => {
        const t = setInterval(() => setNow(Date.now()), 1000)
        return () => clearInterval(t)
    }, [])

    const r = remaining(target, now)
    if (r.invalid) return <div style={{ color: "red" }}>目标时间格式不对</div>
    if (r.done) return <div style={{ ...font, color }}>{endText}</div>

    const cell = (v, label) => (
        <div style={{ textAlign: "center" }}>
            <div style={{ ...font, fontSize: 40, color: accent, fontVariantNumeric: "tabular-nums" }}>{pad(v)}</div>
            <div style={{ fontSize: 12, color }}>{label}</div>
        </div>
    )
    return (
        <div style={{ display: "flex", gap: 16 }} aria-live="polite">
            {cell(r.d, "天")}{cell(r.h, "时")}{cell(r.m, "分")}{cell(r.s, "秒")}
        </div>
    )
}

Countdown.defaultProps = {
    target: "2026-11-11T00:00:00+08:00",
    endText: "活动已开始",
    color: "#333333",
    accent: "#E4393C",
}

addPropertyControls(Countdown, {
    target: {
        type: ControlType.String,
        title: "目标时间",
        description: "ISO 格式,带时区,例如 2026-11-11T00:00:00+08:00",
    },
    endText: { type: ControlType.String, title: "结束文案" },
    color: { type: ControlType.Color, title: "文字颜色" },
    accent: { type: ControlType.Color, title: "数字颜色" },
    font: { type: ControlType.Font, title: "字体", controls: "extended" },
})

要点:

  • defaultProps 保证组件在代码里直接使用时也有默认值;addPropertyControls 只影响画布上的属性面板。
  • @framerSupportedLayoutWidth auto 注释让组件宽高随内容自适应。
  • useEffect 返回清理函数,组件卸载时停止计时器。

第 4 步:放进 Framer

  1. Framer 项目左侧 Assets → Code → 新建代码文件,命名为 Countdown。
  2. 粘贴 Countdown.framer.tsx 的全部内容(已把计算函数合并进来,不依赖其他文件)。
  3. 保存后把组件从 Assets 拖到画布。
  4. 在右侧属性面板把“目标时间”改成你的活动时间,例如 2026-11-11T00:00:00+08:00。

预期结果:画布上显示四组数字并每秒变化;把目标时间改成过去的时间,显示“活动已开始”。

调试示例

第一版直接用 Date.parse(targetISO) 判断格式,测试实际失败:

not ok 3 - 格式错误
  error: The expression evaluated to a falsy value:
      assert.ok(remaining("11/11", 0).invalid)

Node(V8)会把 "11/11" 解析成某年的 11 月 11 日,不同浏览器的解析规则也不一致;不带时区的 2026-11-11T00:00:00 会按访客本地时区计算,海外访客看到的时间就错了。改成先用正则要求“ISO 格式 + 时区”,并在测试中加上缺时区的用例。

出错了怎么办?

现象 原因 解决
显示“目标时间格式不对” 没带时区或格式不对 写成 YYYY-MM-DDTHH:mm:ss+08:00
数字宽度跳动 字体数字不等宽 保留 fontVariantNumeric: "tabular-nums"
代码文件报找不到 ./countdownLogic.js 粘贴了开发版 改用 Countdown.framer.tsx
属性面板没有“字体” Framer 版本差异 以官方 Property Controls 文档为准调整
预览里时间不动 画布静态渲染 用 Preview 或发布后查看

模板规则

  • 计算逻辑写成纯函数并测试,组件只负责显示。
  • 时间一律带时区。
  • 属性面板只暴露设计师需要改的项。

项目资源

用途 地址
Property Controls https://www.framer.com/developers/property-controls
代码组件 API 参考 https://www.framer.com/developers/components-reference
代码组件示例 https://www.framer.com/developers/component-examples
Framer 价格 https://www.framer.com/pricing
Framer 服务条款 https://www.framer.com/legal/terms-of-service/

代码组件的价值在于“开发写一次,设计师随便用”。把容易出错的时间计算放进测试,设计师在面板里怎么改都不会把页面弄坏。