plans/issues/issue-2204.md
at (at: "label+0.2")Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving on. If anything in "STOP conditions" occurs, stop and report — do not improvise. When done, update this plan's row in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2204 --jq '.state'→open(if closed, mark DONE and stop).git diff --stat 42bfbe3ed..HEAD -- packages/framer-motion/src/animation/sequence/— any drift inutils/calc-time.ts⇒ re-read it against the excerpt below; mismatch = STOP.
42bfbe3ed, 2026-06-11Sequence labels exist, and relative offsets exist for "current time" ("+0.5",
"-0.5") and "previous segment" ("<+0.5"), but not for labels: at: "myLabel" works, at: "myLabel+0.2" silently falls through to currentTime.
The reporter building intricate staggered timelines has to hand-compute
absolute playhead times — exactly the math labels exist to avoid. GSAP
supports "label+=0.2"; this is table-stakes timeline ergonomics, and the fix
is a few lines in one pure function with an existing dedicated test file.
This is distinct from plan 005 (grid/distance stagger()), which is about
stagger functions, not at label arithmetic — no overlap.
packages/framer-motion/src/animation/sequence/utils/calc-time.ts (whole
file, 24 lines):
export function calcNextTime(
current: number,
next: SequenceTime,
prev: number,
labels: Map<string, number>
): number {
if (typeof next === "number") {
return next
} else if (next.startsWith("-") || next.startsWith("+")) {
return Math.max(0, current + parseFloat(next))
} else if (next === "<") {
return prev
} else if (next.startsWith("<")) {
return Math.max(0, prev + parseFloat(next.slice(1)))
} else {
return labels.get(next) ?? current
}
}
sequence/create.ts:76 (label-with-time definitions) and
create.ts:88-93 (segment at resolution). Both pass the shared
timeLabels map (create.ts:53).SequenceTime type (sequence/types.ts:47-52) already includes
${string}, so "label+0.2" type-checks today — it just resolves wrong.packages/framer-motion/src/animation/sequence/utils/__tests__/calc-time.test.ts
(single describe covering absolute/label/relative/< forms).packages/framer-motion/src/animation/sequence/__tests__/index.test.ts.| Purpose | Command | Expected |
|---|---|---|
| Build (once, repo root) | yarn build | exit 0 |
| Unit tests | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="calc-time" | pass |
| Sequence suite | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="sequence" | pass |
| Lint | yarn lint | exit 0 |
Syntax decision — record in this plan's README row and wait for APPROVED:
at: "label+0.2" / at: "label-0.2" (matches the
issue's ask and Motion's existing "<+0.2" flavor; no = like GSAP's
"label+=0.2")."step+1" keeps working); only when exact lookup fails, split a trailing
+number/-number and look up the base label; if the base label doesn't
exist either, keep today's fallback (current).If REJECTED: comment on the issue that label-relative offsets are
declined, suggest computing via a label-only at plus segment-level delay,
and close as not_planned
(gh api -X PATCH repos/motiondivision/motion/issues/2204 -f state=closed -f state_reason=not_planned)
— only with an APPROVED-CLOSE row.
Extend calc-time.test.ts (existing labels.set("foo", 2) fixture):
// Label with offset
expect(calcNextTime(4, "foo+1", 100, labels)).toBe(3)
expect(calcNextTime(4, "foo-1", 100, labels)).toBe(1)
expect(calcNextTime(4, "foo+0.25", 100, labels)).toBe(2.25)
expect(calcNextTime(4, "foo-3", 100, labels)).toBe(0) // clamped to 0
expect(calcNextTime(4, "bar+1", 100, labels)).toBe(4) // unknown label → current (unchanged fallback)
// Exact-match precedence
labels.set("baz+1", 9)
expect(calcNextTime(4, "baz+1", 100, labels)).toBe(9)
Run the calc-time filter → the new assertions FAIL on current code (e.g.
"foo+1" returns 4, not 3). This is the bug-shaped failure required
before implementing.
calcNextTimeReplace the final else branch only. Target shape (keep it byte-light per
repo style):
} else {
const labelTime = labels.get(next)
if (labelTime !== undefined) return labelTime
const match = next.match(/^(.+)([+-]\d*\.?\d+)$/)
if (match) {
const base = labels.get(match[1])
if (base !== undefined) {
return Math.max(0, base + parseFloat(match[2]))
}
}
return current
}
Notes: (.+) is greedy so "a+b+0.2" resolves base "a+b" first — correct,
since the offset must be the trailing numeric part. Don't support whitespace
("foo + 0.2") — keep parity with the strict "<+0.2" parsing above.
Verify: calc-time filter → all pass, including Step 1's new cases.
In sequence/__tests__/index.test.ts, add one test modeled on the existing
label tests there: a sequence [[el, {...}, {duration: 1}], "mid", [el2, {...}, {duration: 1}], [el3, {...}, { at: "mid+0.5", duration: 1 }]] built via
createAnimationsFromSequence, asserting the third subject's computed
times/duration place its start at 1.5s (inspect the returned definition's
transition[key].times against duration the same way neighboring tests
do — copy their assertion style).
Verify: sequence filter → all pass.
yarn build → exit 0; yarn lint → exit 0; full framer-motion client suite
once: cd packages/framer-motion && yarn test-client → no new failures
(pre-existing SSR/use-velocity failures noted in repo memory don't count).
Comment on #2204 with the shipped syntax + example, referencing the release
it will go out in. Close as completed
(gh api -X PATCH repos/motiondivision/motion/issues/2204 -f state=closed -f state_reason=completed)
— or leave open until release per maintainer preference stated in the row.
In scope:
packages/framer-motion/src/animation/sequence/utils/calc-time.tspackages/framer-motion/src/animation/sequence/utils/__tests__/calc-time.test.tspackages/framer-motion/src/animation/sequence/__tests__/index.test.tsOut of scope:
sequence/types.ts — SequenceTime already admits ${string}; adding a
template-literal type for label+n is impossible to express usefully."+=0.2" aliases; percentage offsets; "<label" combinations.feature/sequence-label-offsetfeat: support time offsets from labels in sequence 'at' optionyarn build + yarn lint exit 0plans/issues/README.md row updatedcalcNextTime no longer matches the excerpt (drifted) → STOP.seek("label+0.2") behaves
identically.Math.max(0, ...) clamp (consistent with the other relative forms).