我们刚刚完成了一个名为 Radiant 的精美新 SaaS 营销站点模板,现在它已经作为 Tailwind UI 的一部分提供使用。
它使用 Next.js、Framer Motion 和 Tailwind CSS 构建,并有一个由 Sanity 驱动的博客。
自从我们上次构建这样的 SaaS 营销模板以来已经有一段时间了,在此期间我们学到了很多关于如何使这样的模板有用且易于使用的知识。我们试图将所有这些经验都融入到 Radiant 中。
像往常一样,请查看在线预览以获得完整的体验 — 这个模板中有很多很酷的细节,你必须在浏览器中才能真正欣赏到。
恰到好处的交互性
在这样的网站上过度使用动画是很容易的。我们都见过那种你甚至不能滚动几个像素就会有一堆不同元素动画进入的网站。更糟糕的是,当你必须等待内容出现才能阅读时,感觉有多慢。
Radiant 充满了令人愉悦的动画,但它们都是叠加在现有内容上并由用户交互触发的,所以网站仍然感觉很快。在大多数情况下,我们选择了循环动画,让元素在你与它们交互时感觉"活起来"。
我们几乎所有的动画都使用了 Framer Motion。它是声明式的,让我们可以为复杂的动画创建自己的 API,其他人无需太多努力就可以进行自定义。
不过它也有一些需要解决的缺点。例如,当你有多个元素独立动画时,将悬停状态传递给每个子元素是很烦人的。我们最终利用了 Framer 的变体传播来解决这个问题 — 悬停事件会触发父元素的变体变化,由于它们共享相同的变体键,这种变化会传播到子元素。
export function BentoCard() { return ( <motion.div initial="idle" whileHover="active" variants={{ idle: {}, active: {} }} data-dark={dark ? "true" : undefined} > /* ... */ </motion.div> );}父元素的变体之间没有区别所以它实际上不会改变,但是子元素在悬停时仍然会收到变更变体的信号,即使它们是深度嵌套的。
function Marker({ src, top, offset, delay,}: { src: string top: number offset: number delay: number}) { return ( <motion.div variants={{ idle: { scale: 0, opacity: 0, rotateX: 0, rotate: 0, y: 0 }, active: { y: [-20, 0, 4, 0], scale: [0.75, 1], opacity: [0, 1] }, }} transition={{ duration: 0.25, delay, ease: 'easeOut' }} style={{ '--offset': `${offset}px`, top } as React.CSSProperties} className="absolute left-[calc(50%+var(--offset))] size-[38px] drop-shadow-[0_3px_1px_rgba(0,0,0,.15)]" > /* ... */ </motion.div> )}/* ... */Logo 时间线动画有点不同,因为我们希望在停止悬停时,Logo 停留在当前位置,而不是返回到原始位置。这与 Framer 的指定起始和结束状态的方法不太兼容,所以实际上用 CSS 构建这个动画更容易。
它利用了可以设置负 animation-delay 值来偏移元素起始位置的事实。这样所有的 Logo 共享相同的动画关键帧,但它们可以从不同的位置开始并具有不同的持续时间。
function Logo({ label, src, className,}: { label: string src: string className: string}) { return ( <div className={clsx( className, 'absolute top-2 grid grid-cols-[1rem,1fr] items-center gap-2 whitespace-nowrap px-3 py-1', 'rounded-full bg-gradient-to-t from-gray-800 from-50% to-gray-700 ring-1 ring-inset ring-white/10', '[--move-x-from:-100%] [--move-x-to:calc(100%+100cqw)] [animation-iteration-count:infinite] [animation-name:move-x] [animation-play-state:paused] [animation-timing-function:linear] group-hover:[animation-play-state:running]', )} > <img alt="" src={src} className="size-4" /> <span className="text-sm/6 font-medium text-white">{label}</span> </div> )}export function LogoTimeline() { return ( /* ... */ <Row> <Logo label="Loom" src="./logo-timeline/loom.svg" className="[animation-delay:-26s] [animation-duration:30s]" /> <Logo label="Gmail" src="./logo-timeline/gmail.svg" className="[animation-delay:-8s] [animation-duration:30s]" /> </Row> /* ... */这种方法意味着我们不需要在 JavaScript 中跟踪播放状态,我们可以只使用 group-hover:[animation-play-state:running] 类在父元素悬停时启动动画。
正如你可能注意到的,我们在这个组件中使用了一堆任意属性来处理单个 animation 属性,因为这些实用程序在 Tailwind 中还不存在。这就是构建这些模板的好处 — 它帮助我们发现 Tailwind CSS 中的盲点。谁知道呢,也许我们会在 v4.0 中看到这些实用程序的添加!
有意的可重用性
设计这样的 SaaS 模板最棘手的部分是设计出用户可以轻松应用到自己产品的交互元素。没有什么比购买一个模板后发现它对示例内容如此具体以至于你无法真正用于自己的项目更糟糕的了。
我们想出了一些大多数 SaaS 产品可能会有的核心图形元素。一个带有图钉的地图,一个 Logo 集群,一个键盘 — 这些都可以应用到许多不同的功能中。因为我们希望它们易于为你的产品重新利用,所以我们在代码中构建了很多,并为它们设计了漂亮的 API。
例如,Logo 集群有一个简单的 API,允许你传入自己的 Logo,调整它们的位置和悬停动画以匹配。
<Logo src="./logo-cluster/dribbble.svg" left={285} top={20} hover={{ x: 4, y: -5, rotate: 6, delay: 0.3 }} />键盘快捷键部分是另一个很好的例子。添加你自己的快捷键就像将键名数组传递给 Keyboard 组件一样简单,因为每个键都是一个组件,你可以轻松添加自定义键或更改布局。
<Keyboard highlighted={["F", "M", "L"]} />事实证明,构建一个代码中的键盘实际上需要相当多的工作,但至少现在你永远不必自己发现这一点。
当然,我们也为你留出了放置自己产品截图的位置。以下是为我们的朋友 SavvyCal 定制的这个部分的样子,使用了相同的交互组件。
由 CMS 驱动
通常我们在为模板添加博客时只使用 MDX,但这次我们认为使用无头 CMS 会很有趣。我们决定尝试 Sanity,因为在 调查我们的观众 后听到了很多好评。
与创建文件、提交、更改和手动管理图像不同,CMS 允许你从他们的 UI 处理所有内容,因此即使是非开发人员也可以轻松贡献。
无头 CMS 的一个很酷的地方是你可以以结构化格式获取内容,因此类似于 MDX,你可以将元素映射到自己的自定义组件以处理所有的排版样式。
<PortableText value={post.body} components={{ block: { normal: ({ children }) => <p className="my-10 text-base/8 first:mt-0 last:mb-0">{children}</p>, h2: ({ children }) => ( <h2 className="mt-12 mb-10 text-2xl/8 font-medium tracking-tight text-gray-950 first:mt-0 last:mb-0"> {children} </h2> ), h3: ({ children }) => ( <h3 className="mt-12 mb-10 text-xl/8 font-medium tracking-tight text-gray-950 first:mt-0 last:mb-0"> {children} </h3> ), blockquote: ({ children }) => ( <blockquote className="my-10 border-l-2 border-l-gray-300 pl-6 text-base/8 text-gray-950 first:mt-0 last:mb-0"> {children} </blockquote> ), }, types: { image: ({ value }) => ( <img className="w-full rounded-2xl" src={image(value).width(2000).url()} alt={value.alt || ""} /> ), }, /* ... */ }}/>使用 CMS 还意味着所有的资产如图像都为你托管,你可以随时控制图像的大小、质量和格式。
<div className="text-sm/5 max-sm:text-gray-700 sm:font-medium"> {dayjs(post.publishedAt).format('dddd, MMMM D, YYYY')}</div>{post.author && ( <div className="mt-2.5 flex items-center gap-3"> {post.author.image && ( <img className="aspect-square size-6 rounded-full object-cover" src={image(post.author.image).width(64).height(64).url()} alt="" /> )} <div className="text-sm/5 text-gray-700"> {post.author.name} </div> </div>)}就像你可能在 Markdown 中使用前置内容一样,你也可以使用自定义字段丰富内容。例如,我们在博客文章模式中添加了一个 featured 布尔字段,以便你可以在博客的特殊部分中突出显示一些文章。
Sanity 特别是一个付费产品,但他们有一个相当慷慨的免费层,足够你玩耍。而且如果你想尝试其他无头 CMS,我认为我们在这里整合的 Sanity 仍然是一个很好的示例,展示了你如何使用其他工具进行集成。
这就是 Radiant!看看它的内部结构,试试它的功能,并告诉我们你的想法。
像我们所有的模板一样,它包含在一次性购买的 Tailwind UI 全访问 许可证中,这是支持我们在 Tailwind CSS 上工作的最佳方式,使我们能够在未来几年继续为你构建很棒的东西。
