easeul

Base UITransitions

Scroll area

The one host here that publishes no open and closed pair. Base UI keeps this scrollbar mounted and only reports whether you are hovering or scrolling, so hiding it is the page's job and the state attribute has to come from the app. That is what data-motion-state is for.

Every value the manifest allows on this host, single presets and the pairs that compose. The first is what this component ships with. Both stages are the same component, so anything that changes between them is the attribute.

withoutno attribute
with easefuldata-motion="fade"
host writes

nothing. only data-hovering and data-scrolling

easeful matches

[data-motion~="fade"][data-motion-state="closed"]

what runs

transition: opacity 150ms

you write

<ScrollArea.Scrollbar data-motion="fade" data-motion-state={...} />

Where the attribute goes

The parts this demo composes, and the value each one carries. Everything inside an animated part is that part's content, so the tree stops there.

  1. ScrollArea.Root
  2. ScrollArea.Viewport
  3. ScrollArea.Content
  4. ScrollArea.Scrollbardata-motion="fade"data-motion-state

Source

"use client"

import { useEffect, useRef, useState } from "react"
import { ScrollArea } from "@base-ui/react/scroll-area"
import type { DemoProps } from "@/components/demos/props"

/* The one host in this matrix that publishes no open and closed state.
 *
 * Base UI's Scrollbar stays mounted permanently and carries only interaction
 * flags: data-orientation, data-has-overflow-y, data-hovering, data-scrolling.
 * There is no visible/hidden pair the way Radix's scrollbar has one, so easeful
 * has nothing to match on and the bar simply never leaves. Hiding it is the
 * application's job here, not the library's.
 *
 * So this demo drives data-motion-state, the escape hatch easeful documents for
 * exactly this case, from a scroll handler with the same 900ms delay the Radix
 * demo passes as scrollHideDelay. Both columns get the state, so the only
 * difference between them is still the attribute and therefore the animation:
 * without it the bar cuts, with it the bar fades. */
const HIDE_DELAY = 900

export function BaseUiScrollArea({ motion }: DemoProps) {
  const [shown, setShown] = useState(false)
  const timer = useRef<number | undefined>(undefined)

  useEffect(() => () => window.clearTimeout(timer.current), [])

  function onScroll() {
    setShown(true)
    window.clearTimeout(timer.current)
    timer.current = window.setTimeout(() => setShown(false), HIDE_DELAY)
  }

  return (
    <ScrollArea.Root className="scroller">
      <ScrollArea.Viewport className="scroller__viewport" onScroll={onScroll}>
        <ScrollArea.Content>
          <p>
            Scroll this box, then stop. Watch the bar on the right as it leaves rather than as it
            arrives.
          </p>
          <p>
            Base UI never hides this scrollbar itself. It reports whether you are hovering or
            scrolling and leaves the rest to the page, so the state attribute here comes from the
            demo rather than from the host.
          </p>
          <p>Without the attribute the bar cuts out on the frame the delay elapses.</p>
          <p>Keep going so there is room to scroll.</p>
          <p>One last line.</p>
        </ScrollArea.Content>
      </ScrollArea.Viewport>
      <ScrollArea.Scrollbar
        className="scroller__bar"
        orientation="vertical"
        data-motion={motion}
        data-motion-state={shown ? "open" : "closed"}
      >
        <ScrollArea.Thumb className="scroller__thumb" />
      </ScrollArea.Scrollbar>
    </ScrollArea.Root>
  )
}
The whole file, read from disk at build time. Both copies above render from it, and the motion prop is the only difference between them.