@cssints/reveal

中文文档
0.35.1发布于 2026/11/07MITTypeScriptES Module构建来源已验证近 30 天下载 7176

Scroll-driven and scroll-triggered entrance animations for cssints: motion's presets on animation-timeline: view() (reveal) or on CSS animation triggers (appear), with a tiny IntersectionObserver fallback

原始使用文档
文档目录4 节
  1. Rules
  2. Browsers
  3. Limits
  4. Credits

@cssints/reveal

Entrance animations for cssints, on @cssints/motion's presets, as two chain members of cssints/plugin scope():

  • reveal() is scroll-driven: an element runs the entrance as it scrolls into view, on animation-timeline: view() and animation-range, so the scroll scrubs it. In the spirit of the supersonic plugin and lax.js, with the browser doing the work.
  • appear() is scroll-triggered: the scroll only decides when a normal, time-based animation plays, on timeline-trigger + animation-trigger. It keeps its own duration and easing, so a slow scroll cannot stretch the entrance. The case is utilitybend's article on CSS animation triggers.

Where a target lacks the feature a member runs on, a 0.4 kB script plays the same animation once when the element enters.

import { cn, hover } from "cssints" with { type: "cssints" };
import { appear, reveal } from "@cssints/reveal" with { type: "cssints" };

// scroll-driven: the scroll scrubs it
export const fade = reveal();
export const card = reveal()
  .preset("slide-in-up")
  .range("entry 0% entry 100%")
  .duration("500ms")
  .easing("linear");
export const tile = cn(reveal().preset("blur-in"), hover(reveal().duration("1s")));

// scroll-triggered: it plays once on entry, with its own time
export const lede = appear().preset("slide-in-up").duration("600ms").easing("linear");
export const band = appear().preset("fade-in").inOut();

The CSS of reveal() (fade and card above):

@layer _.k {
  @keyframes kf-7f473efd {
    from {
      opacity: var(--motion-o);
      translate: var(--motion-x) var(--motion-y);
      scale: var(--motion-s);
      filter: blur(var(--motion-b));
    }
  }
  @keyframes kf-4f766ef7 {
    from {
      opacity: var(--motion-o);
    }
  }
  ._reveal-core {
    animation: var(--reveal-k) var(--reveal-d) var(--reveal-e) both;
    animation-range: var(--reveal-g);
    --reveal-k: kf-7f473efd;
    --reveal-rk: kf-4f766ef7;
    --reveal-d: 300ms;
    --reveal-e: ease-out;
    --reveal-g: entry 0% cover 40%;
    --motion-o: 0;
    --motion-x: 0;
    --motion-y: 0;
    --motion-s: 1;
    --motion-b: 0;
    animation-timeline: view();
  }
  @media (prefers-reduced-motion: reduce) {
    ._reveal-core {
      animation-name: var(--reveal-rk);
    }
  }
  /* only when a target lacks scroll-driven animations */
  @supports not (animation-timeline: view()) {
    [data-reveal] ._reveal-core:not([data-reveal-in]) {
      animation-play-state: paused;
    }
  }
}
@layer _.a {
  ._1a9p5a9 {
    --motion-y: 25%;
  }
  ._yorqor {
    --reveal-g: entry 0% entry 100%;
  }
  ._k4abtq {
    --reveal-d: 500ms;
  }
  /* ... */
}

The CSS of appear() (lede and band above):

@layer _.k {
  /* motion's keyframes, the two rules reveal() names too (one pair for the page) */
  ._reveal-enter {
    animation: var(--reveal-enter-k) var(--reveal-enter-d) var(--reveal-enter-e) both;
    timeline-trigger: --reveal-t view() var(--reveal-enter-g);
    --reveal-enter-k: kf-7f473efd;
    --reveal-enter-rk: kf-4f766ef7;
    --reveal-enter-d: 300ms;
    --reveal-enter-e: ease-out;
    --reveal-enter-g: entry 100% exit 0%;
    --reveal-enter: play-once;
    --reveal-leave: none;
    --motion-o: 0;
    --motion-x: 0;
    --motion-y: 0;
    --motion-s: 1;
    --motion-b: 0;
    animation-trigger: --reveal-t var(--reveal-enter) var(--reveal-leave);
    trigger-scope: --reveal-t;
  }
  @media (prefers-reduced-motion: reduce) {
    ._reveal-enter {
      animation-name: var(--reveal-enter-rk);
    }
  }
  /* only when a target lacks animation triggers */
  @supports not (animation-trigger: none) {
    [data-reveal] ._reveal-enter:not([data-reveal-in]) {
      animation-play-state: paused;
    }
  }
}
@layer _.a {
  ._5mnsmm {
    --reveal-enter: play-forwards;
  }
  ._67iv7r {
    --reveal-leave: play-backwards;
  }
  ._1a9p5a9 {
    --motion-y: 25%;
  }
  ._knj5bv {
    --motion-o: 0;
  }
  ._oflqj4 {
    --reveal-enter-e: linear;
  }
  ._rsl9nu {
    --reveal-enter-d: 600ms;
  }
}

Rules

  • Methods, each once, in any order, all optional: preset(name) (fade-in), range(<'animation-range'>) (entry 0% cover 40% for reveal(), entry 100% exit 0% for appear()), duration(<time [0s,∞]>) (300ms) and easing(<easing-function>) (ease-out). Each is one class that sets a custom property, so a method works under a condition.
  • reveal(). Where the timeline is native, the range decides when the animation runs and the scroll drives it (back and forth); the duration and the easing shape the fallback's one play (the easing applies to the scrubbed progress too).
  • appear(). The range is the activation range of the trigger: the element is fully in at entry 100%, so the animation plays there and not while it is entering. The animation then runs over duration() with easing(), whatever the scroll does. The default actions are play-once none: it plays once and stays, which is what an entrance wants — and exactly what the fallback does. inOut() sets play-forwards play-backwards, so it plays back out when the element leaves (where the triggers are native).
  • Presets are motion's entrance presets: fade-in, slide-in-up, slide-in-down, slide-in-left, slide-in-right, scale-in, blur-in. The -out ones are not: the fallback can only play when an element enters.
  • Reuse of motion. Both members name motion's motionKeyframes.in and motionKeyframes.reducedIn, so that rule is motion's kernel, printed once however many styles use it, and a preset sets the values those keyframes read from motion's presets table under motion's names (motionVars). Only each member's own properties are its own names: appear()'s are --reveal-enter-*, because reveal()'s kernel declares the scope's short names (--reveal-k and the rest) and two kernels of one scope may not share one of them. A scope may reuse a name another scope owns — that is how both of them set --motion-* — so motion(), reveal() and appear() can sit on one page.
  • trigger-scope is required. The trigger name is the kernel's (--reveal-t) and every element of the class shares it; without trigger-scope: --reveal-t, the last element in the document would drive every card. With it, two boxes of the same class trigger independently (checked in Chrome 156). Two different styles on one page do not collide either: each kernel has its own name.
  • Reduced motion. Under prefers-reduced-motion: reduce each kernel runs the opacity-only keyframes (motion's rule): a slide, scale or blur becomes a fade over the same range or trigger.
  • One kernel, written as styles. The animation shorthand resets animation-timeline and animation-trigger; a kernel prints a shorthand before its longhands, so the declarations beside it win.
  • Conflicts. reveal() and appear() set the animation: a cn() that holds one and an animation* property is a build error, derived from the kernel: cssints: reveal: kernel enter sets animation, timeline-trigger, animation-trigger, trigger-scope, animation-name (this style also sets animation-duration).
  • Errors are the grammar's, in the types and at build time: reveal.preset: unexpected "fade-out" in "fade-out", expected fade-in | ..., appear.range: unexpected "sideways" in "entry 0% sideways".

Browsers

  • Native. reveal(): Chrome 115, Safari 26 (scroll-driven animations, css.properties.animation-timeline and animation-range); Firefox has them behind a flag only. appear(): Chrome 146, Edge 146, Opera 130 (css.properties.animation-trigger, timeline-trigger and trigger-scope); Firefox and Safari have no animation triggers today. CSS animation triggers are a candidate for Interop 2027.
  • The fallback. When a browserslist target lacks the feature a member runs on, the build adds that member's @supports not rule above to the sheet (it stays out of the sheet for targets that all have it), and the kernel's declarations give no support warning: the runtime polyfills them. The script, @cssints/reveal/runtime, comes with it: each runtime entry names it (script), so a module that uses reveal() imports it through virtual:cssints/runtime/reveal.io and one that uses appear() through virtual:cssints/runtime/reveal.io-appear; each is empty when every target has its feature. Nothing to import by hand.
  • One script, two gates. In the browser the script does nothing where both features work. It gates on CSS.supports("animation-timeline:view()") for reveal() and CSS.supports("animation-trigger:none") for appear(): it marks <html data-reveal> (the rule then holds each fallback animation at its first frame) and an IntersectionObserver gives an element data-reveal-in when it enters the viewport, which plays the keyframes once, over duration(). A MutationObserver picks up elements added later. 605 bytes minified, 369 gzip; no imports.
  • The fallback equals appear()'s default. Playing once on entry is exactly play-once none, so on Firefox and Safari appear() needs no extra behaviour; it cannot play back out (inOut()), follow the range, or react to the scroll.
  • Why not a polyfill. flackr's scroll-timeline polyfill scrubs the animation as the native timeline does, but it is about 61 kB minified (17 kB gzip), rewrites stylesheets and runs on every scroll. An entrance is a one-shot: playing it once on entry is what most sites want, costs 1/50 of the bytes and nothing per scroll. The price is that the fallback is not scrubbed and ignores range().
  • Without the script, a target that lacks the feature plays the animation on load, as motion() would: nothing stays hidden.
  • Tested in Chrome 156 (native triggers and the fallback forced), Firefox 157 (the fallback) and WebKit 27.2 (the reveal timeline native, the fallback for appear()): bun run test:browsers.

Limits

  • With targets that mix both (the default browserslist), the script ships to every browser and only probes where the feature is native.
  • The fallback does not follow the range, the scroll direction or an element that leaves the viewport: it plays once. An element that gets the class after it is in the document (not added with it) is not seen.
  • Until the script runs, an animation below the fold may start playing; it is paused where it is and resumes on entry. The script runs with the first module that uses reveal() or appear().
  • None of motion(), reveal() and appear() may be put on one element: each sets the animation, which two kernels of one style refuse (kernels ... both set animation). On a page they share motion's keyframes and custom properties.
  • The trigger is on the animated element itself. A tall section that triggers its children is written by hand: css.timelineTrigger() and css.animationTrigger() on the section, and css.animationTrigger() on the child. A separate trigger element and a .from("--name") method are deferred.
  • Entrance presets only, one per element; for an exit on scroll write the animation with animationTimeline("view()") and animationRange("exit ...") yourself.
cd packages/reveal && bun run test            # fixtures through the engine (output by targets, runtime modules, conflicts, errors)
cd packages/reveal && bun run test:browsers   # Chrome, Firefox and WebKit through playwright-core

Credits

In the spirit of the supersonic plugin (MIT, Alex Illarionov) and lax.js (MIT, Alex Fox). The presets are @cssints/motion's. No code is copied.

版本与发布记录

@cssints/reveal 共发布 42 个版本,首次发布于 2026/10/11;过去 12 个月发布了 42 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.35.1latest2026/11/073 天前
0.35.02026/11/073 天前
0.34.02026/11/073 天前
0.33.02026/11/046 天前
0.32.02026/11/046 天前
0.31.02026/11/047 天前

关于 @cssints/reveal 的常见问题

@cssints/reveal 是什么?

Scroll-driven and scroll-triggered entrance animations for cssints: motion's presets on animation-timeline: view() (reveal) or on CSS animation triggers (appear), with a tiny IntersectionObserver fallback

如何安装 @cssints/reveal?

在项目目录运行 npm install @cssints/reveal,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:cssints(^0.35.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@cssints/reveal 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@cssints/reveal 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@cssints/reveal 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@cssints/reveal 可以商用吗?

@cssints/reveal 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@cssints/reveal 的下载量有多少?

根据 npm 官方下载统计,@cssints/reveal 在 2026/10/11 – 2026/11/09 这 30 天内被下载 7,176 次,日均约 239 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cssints/reveal 还在维护吗?

最新版本 0.35.1 发布于 2026/11/07(3 天前);至今共发布 42 个版本,其中过去 12 个月发布了 42 个;首次发布于 2026/10/11。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。