// ===== demos/typography/pill-slot-cycle/PillSlotCycle.tsx ===== // pill-slot-cycle —— 句中词槽轮换:固定句干 + 句尾 pill 老虎机式滚一格 // 源:notion-ai 4.5–8.5s。旧 pill 上飞淡出、新 pill 从下带运动模糊滑入, // 连换 6 次后 pill 消失、句子落成 "One AI tool to do it all." 收束。 import React from 'react'; import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from 'remotion'; import { G } from '../../_fixtures/Fixtures'; const FONT = 'Helvetica, Arial, sans-serif'; const PILLS = [ { label: 'Ask a question', icon: '?' }, { label: 'Find in Drive', icon: '▲' }, { label: 'Find in Slack', icon: '#' }, { label: 'Summarize', icon: '≡' }, { label: 'Improve writing', icon: '✎' }, { label: 'Draft an agenda', icon: '☰' }, ]; const BEAT = 21; // ~0.7s @30fps const INTRO = 12; // 句干入场 const CYCLES = PILLS.length; const Pill: React.FC<{ label: string; icon: string; style?: React.CSSProperties }> = ({ label, icon, style, }) => (
{icon} {label}
); export const PillSlotCycle: React.FC = () => { const frame = useCurrentFrame(); // 句干入场:ease-out 上浮淡入 const stemT = interpolate(frame, [0, INTRO], [0, 1], { extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); const cycleStart = INTRO; const cycleEnd = cycleStart + CYCLES * BEAT; // 当前处于第几个 pill 拍 const rel = frame - cycleStart; const idx = Math.min(Math.floor(rel / BEAT), CYCLES - 1); const beatFrame = rel - idx * BEAT; const SWAP = 8; // 每拍前 8f 完成换位 // 收束段:pill 上飞消失,"do it all." 从下滑入落位 const isFinale = frame >= cycleEnd; const finT = interpolate(frame, [cycleEnd, cycleEnd + 14], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.back(1.4)), }); // 槽内容渲染 let slot: React.ReactNode = null; if (!isFinale && rel >= 0) { const incoming = PILLS[idx]; const outgoing = idx > 0 ? PILLS[idx - 1] : null; // 新 pill:从下 +120px 带 blur 滑入,ease-out const inT = interpolate(beatFrame, [0, SWAP], [0, 1], { extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); const inY = interpolate(inT, [0, 1], [120, 0]); const inBlur = interpolate(inT, [0, 0.7, 1], [14, 4, 0]); // 旧 pill:向上 -120px 加速飞出淡掉 const outT = interpolate(beatFrame, [0, SWAP - 1], [0, 1], { extrapolateRight: 'clamp', easing: Easing.in(Easing.cubic), }); const outY = interpolate(outT, [0, 1], [0, -130]); slot = (
{/* 撑起槽宽度的隐形占位(当前 pill) */} {outgoing && outT < 1 && (
)}
); } else if (isFinale) { // 最后一个 pill 上飞离场(前 8f),然后 "do it all." 落位 const lastOutT = interpolate(frame, [cycleEnd, cycleEnd + 7], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.in(Easing.cubic), }); slot = (
do it all. {lastOutT < 1 && (
)}
); } return ( {/* 句干固定不动:整行左端锚死,不随 pill 宽度居中重排 */}
One AI tool to {slot}
); };