Scroll reveals are the first animation everyone adds and the first one that makes a site feel cheap. Three rules have survived every project I've used them on.

## Small offsets read as intent, large ones read as lag

A 60px slide takes long enough that a fast scroller sees the element mid-flight and reads it as the page struggling. 8–16px reads as a fade with a hint of direction.

```js
gsap.from(el, {
  opacity: 0,
  y: 12,
  duration: 0.35,
  ease: "power1.out",
  scrollTrigger: { trigger: el, start: "top 90%", once: true },
});
```

## `once: true` unless you have a reason

Re-triggering on scroll-up means someone reviewing your page — which is exactly what a prospective client does — watches everything animate a second and third time. It stops being a reveal and becomes a tax on re-reading.

## Never reveal something a crawler needs

Anything set to `opacity: 0` by CSS and revealed by JS is invisible if the script fails. For a portfolio, that's your entire case-study copy.

The fix is one line: put a `no-js` class on `<html>`, remove it in an inline script before paint, and scope the hidden state to `.no-js` being absent. If JS never arrives, everything is simply visible.

```html
<html class="no-js">
  <script>document.documentElement.classList.remove('no-js')</script>
```

```css
.no-js .anim-hidden { opacity: 1; }
```

## And respect the setting

`prefers-reduced-motion` isn't a preference for *less* animation, it's a medical accommodation for vestibular disorders. Don't shorten the duration — skip the transform entirely and set the end state.