forty-cdk
llms.txt

Primitives

Scroll Area

A scrollable region with cross-browser, stylable synthetic scrollbars.

forty-cdk/scroll-area

Scroll the pane and watch the custom scrollbar follow. The thumb is sized from the content, and data-state says whether the bar is showing.

Row 1 — headless primitives expose state and behavior; you bring the styles.

Row 2 — headless primitives expose state and behavior; you bring the styles.

Row 3 — headless primitives expose state and behavior; you bring the styles.

Row 4 — headless primitives expose state and behavior; you bring the styles.

Row 5 — headless primitives expose state and behavior; you bring the styles.

Row 6 — headless primitives expose state and behavior; you bring the styles.

Row 7 — headless primitives expose state and behavior; you bring the styles.

Row 8 — headless primitives expose state and behavior; you bring the styles.

Row 9 — headless primitives expose state and behavior; you bring the styles.

Row 10 — headless primitives expose state and behavior; you bring the styles.

Row 11 — headless primitives expose state and behavior; you bring the styles.

Row 12 — headless primitives expose state and behavior; you bring the styles.

Row 13 — headless primitives expose state and behavior; you bring the styles.

Row 14 — headless primitives expose state and behavior; you bring the styles.

Row 15 — headless primitives expose state and behavior; you bring the styles.

Row 16 — headless primitives expose state and behavior; you bring the styles.

Hides native scrollbars on the inner viewport and exposes synthetic scrollbar + thumb + corner directives that the consumer styles freely. This is the only primitive in forty-cdk that ships CSS: a single <style> tag (id for-scroll-area-hide-native) is injected into document.head the first time a viewport mounts. It hides webkit / Firefox / IE native scrollbars on [forScrollAreaViewport] only, leaving the rest of your CSS untouched.

Anatomy

<div forScrollArea type="hover">
  <div forScrollAreaViewport>
    <div forScrollAreaContent>… long content …</div>
  </div>
  <div forScrollAreaScrollbar orientation="vertical">
    <div forScrollAreaThumb></div>
  </div>
  <div forScrollAreaScrollbar orientation="horizontal">
    <div forScrollAreaThumb></div>
  </div>
  <div forScrollAreaCorner></div>
</div>

Examples

Always-painted track

type='always' keeps both tracks (and the corner) mounted and visible regardless of overflow. Laying the scrollbars out in flow as grid columns reserves a stable gutter, so the viewport width never shifts as content crosses the overflow boundary.

Row 1 — the track stays painted whether or not the axis overflows.

Row 2 — the track stays painted whether or not the axis overflows.

Row 3 — the track stays painted whether or not the axis overflows.

Row 4 — the track stays painted whether or not the axis overflows.

Row 5 — the track stays painted whether or not the axis overflows.

Row 6 — the track stays painted whether or not the axis overflows.

Row 7 — the track stays painted whether or not the axis overflows.

Row 8 — the track stays painted whether or not the axis overflows.

Row 9 — the track stays painted whether or not the axis overflows.

Row 10 — the track stays painted whether or not the axis overflows.

Row 11 — the track stays painted whether or not the axis overflows.

Row 12 — the track stays painted whether or not the axis overflows.

Row 13 — the track stays painted whether or not the axis overflows.

Row 14 — the track stays painted whether or not the axis overflows.

Row 15 — the track stays painted whether or not the axis overflows.

Row 16 — the track stays painted whether or not the axis overflows.

Geometry signals

The root exposes its live scroll geometry as read-only signals via exportAs: scrollTop / scrollLeft, the client vs. scroll size on each axis, plus hovering and scrolling. Grab the reference with #sa="forScrollArea" and read them straight in the template; here they drive the readout and a scrolled-percentage bar without a single scroll listener of your own.

Row 1 — scroll me on either axis.

Row 2 — scroll me on either axis.

Row 3 — scroll me on either axis.

Row 4 — scroll me on either axis.

Row 5 — scroll me on either axis.

Row 6 — scroll me on either axis.

Row 7 — scroll me on either axis.

Row 8 — scroll me on either axis.

Row 9 — scroll me on either axis.

Row 10 — scroll me on either axis.

Row 11 — scroll me on either axis.

Row 12 — scroll me on either axis.

Row 13 — scroll me on either axis.

Row 14 — scroll me on either axis.

Row 15 — scroll me on either axis.

Row 16 — scroll me on either axis.

Row 17 — scroll me on either axis.

Row 18 — scroll me on either axis.

scrollTop
0
scrollLeft
0
client
0 × 0
scroll
0 × 0
scrolling
false
hovering
false

scrolled 0% down the vertical axis.

API

ForScrollArea

Root directive. Owns scroll type, hide-delay, hover state, and writing direction.

PropertyTypeDescription
type
input
Visibility behavior. 'always' keeps the track painted even with no overflow; 'auto' self-hides. See Behavior notes and the grid example for reserving the gutter.
Default: 'hover'
scrollHideDelay
input
ms after the most recent scroll before scrollbars fade ('scroll' and 'hover').
Default: 600
trackPress
input
What a primary-button press on bare track does (see Behavior notes).
Default: 'page'
trackPressRepeatDelay
input
ms a held trackPress="page" gesture waits before the first auto-repeat step.
Default: 300
trackPressRepeatInterval
input
ms between auto-repeat steps of a held trackPress="page" gesture.
Default: 50
dir
input
Reflected as dir.
Default: —

All three trackPress* defaults are read from provideForScrollAreaDefaults for the surrounding scope, so an app can opt out of track-press paging in one place with provideForScrollAreaDefaults({ trackPress: 'none' }).

Data attributeValues
data-typeauto | always | scroll | hover

The resolved writing direction is reflected on the root via the native dir attribute (ltr / rtl), not a data-* hook, so select on [dir='rtl'] / :dir(rtl) to flip layout.

ForScrollAreaViewport

The actual scrolling element. Hides native scrollbars and reports scroll position and geometry to the root. Carries no data-* attributes.

PropertyTypeDescription
focusable
input
Whether the viewport is a keyboard tab stop. When true, emits tabindex="0" so the scroll container is focusable and gets native keyboard scrolling. Set false to opt out.
Default: true

Reflects tabindex="0" while focusable is true; no tabindex attribute when false.

ForScrollAreaContent

Marks the content element inside the viewport so its size changes drive the synthetic scrollbar. Carries no data-* attributes.

ForScrollAreaScrollbar

Synthetic track. It measures its own length and derives the axis geometry from it; the thumb renders from those signals.

PropertyTypeDescription
orientation
input.required
Which axis this track scrolls.
Default: —
Data attributeValues
data-orientationhorizontal | vertical
data-statevisible | hidden

The track self-removes via the hidden attribute plus an inline display: none when its axis has no overflow (except under type="always").

It also owns the axis geometry (the thumb renders from it) and the scroll commands a track gesture needs. Reach them through the template reference #bar="forScrollAreaScrollbar" when trackPress="none" and you want to build the gesture yourself:

MemberTypeDescription
trackLength
Signal
Measured track length along this axis, in CSS pixels.
thumbSize
Signal
Rendered thumb length (ratio × track, floored at 8px).
usableTrack
Signal
Travel available to the thumb (trackLength − thumbSize).
thumbOffset
Signal
Thumb offset from the track's start edge.
maxScroll
Signal
Maximum scroll offset of this axis.
scrollPosition
Signal
Scroll offset normalised to a start-edge origin in [0, maxScroll] (RTL absorbed).
pageStep
Signal
One page step in scroll pixels.
pressing
Signal
True while a track press is in flight.
trackPointFromEvent(event)
(event: PointerEvent) => number
Press offset from the track's start edge (measured from rect.left in both writing directions).
scrollToPosition(position)
(position: number) => void
Scrolls the axis to a start-edge-origin position, clamped into range.
scrollToTrackPoint(trackPx)
(trackPx: number) => void
Centres the thumb on a track offset.
pageBy(direction)
(direction: -1 | 1) => void
Steps one page backward / forward.
<div
  forScrollAreaScrollbar
  orientation="vertical"
  #bar="forScrollAreaScrollbar"
  (pointerdown)="onTrackPress($event, bar)"
>
  <div forScrollAreaThumb></div>
</div>
onTrackPress(event: PointerEvent, bar: ForScrollAreaScrollbar): void {
  if (event.pointerType === 'mouse' && event.button !== 0) return;
  const point = bar.trackPointFromEvent(event);
  if (point >= bar.thumbOffset() && point <= bar.thumbOffset() + bar.thumbSize()) return;
  bar.pageBy(point < bar.thumbOffset() ? -1 : 1);
}

ForScrollAreaThumb

Draggable thumb, sized and translated automatically. Mirrors its scrollbar's orientation and state.

Data attributeValues
data-orientationhorizontal | vertical
data-statevisible | hidden

ForScrollAreaCorner

Filler in the corner where horizontal and vertical scrollbars meet. Shows only when both scrollbars are visible (or always, under type="always"), self-removing via the hidden attribute plus an inline display: none otherwise. Carries no data-* attributes.

Accessibility

The synthetic scrollbars are pointer affordances only: they carry no ARIA roles, are not focusable, and neither a thumb drag nor a track press moves focus (mirroring a native scrollbar). The corner element is removed from the accessibility tree via the hidden attribute when not visible.

[forScrollAreaViewport] is focusable by default (tabindex="0"), so keyboard users can Tab to the scroll container and scroll it with the arrow keys / PageUp / PageDown / Home / End / Space in every browser, including Safari, which does not make a non-focusable overflow container keyboard-scrollable. Because the viewport is now a tab stop, recommend giving it role="region" plus an accessible label so screen-reader users know what the focusable container is:

<div forScrollAreaViewport role="region" aria-label="Release notes">
  <div forScrollAreaContent>… long content …</div>
</div>

The label may instead point at a visible heading with aria-labelledby. The library does not force a role on the viewport: a role="region" without an accessible name is itself an accessibility anti-pattern, and only the consumer knows the content's name. That is why the role and label stay opt-in. If the projected content is already a labelled, keyboard-focusable region, set [focusable]="false" on the viewport to avoid a redundant tab stop.

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).

.sa-scrollbar[data-state='hidden'] {
  opacity: 0;
  pointer-events: none;
}

.sa-scrollbar[data-orientation='vertical'] {
  width: 8px;
}

Behavior notes

  • type="always" keeps a stable, always-painted track. Unlike auto / scroll / hover, which render a scrollbar only for an axis that actually overflows and self-hide otherwise, always keeps both scrollbars (and the corner) mounted and data-state="visible" regardless of overflow. When the axis does not overflow the thumb fills the full track and dragging it is a no-op, so the track never appears/disappears as content crosses the overflow boundary.

  • Reserving the gutter with type="always" is the consumer's layout job. forty-cdk is headless and does not own layout, so it cannot literally reserve a gutter. always only guarantees the track stays painted. To get "no content shift" behavior, lay the scrollbar out in flow (a grid column) rather than position: absolute, so the always-present track occupies real space:

    .sa {
      display: grid;
      grid-template-columns: 1fr auto; /* content | reserved scrollbar gutter */
      grid-template-rows: 1fr auto;
      width: 240px;
      height: 240px;
    }
    .sa-viewport {
      grid-column: 1;
      grid-row: 1;
    }
    .sa-scrollbar[orientation='vertical'] {
      grid-column: 2;
      grid-row: 1;
      width: 8px;
    }
    .sa-scrollbar[orientation='horizontal'] {
      grid-column: 1;
      grid-row: 2;
      height: 8px;
    }
    .sa-corner {
      grid-column: 2;
      grid-row: 2;
    }

    With type="always" the vertical track's grid column is always filled, so the viewport width stays constant whether or not the content overflows, with no reflow when it crosses the boundary. The position: absolute layout in the Examples section above is the right default for auto / hover / scroll, where an overlaid self-hiding scrollbar is the desired look.

  • Native scrollbars are hidden globally on [forScrollAreaViewport] via an injected <style> tag. If you need to opt out (e.g. for a debug build), remove #for-scroll-area-hide-native from the head, but that defeats the primitive's purpose.

  • Keyboard scrolling is native, via a focusable viewport. [forScrollAreaViewport] carries tabindex="0" by default, so it is a tab stop and the browser gives the focused overflow container native arrow / PageUp / PageDown / Home / End / Space scrolling. This works in every browser, including Safari (which, unlike Chrome/Firefox, does not make a non-focusable overflow container keyboard-scrollable). The scrollbar is a pointer affordance only (thumb drag + track press) and never a tab stop. If the projected content already holds focusable elements and you don't want the extra tab stop, set [focusable]="false" on the viewport.

  • Drag uses pointer-capture so the cursor doesn't lose the thumb if it briefly leaves the track. The pointermove / pointerup listeners are attached to the owner document (capture still routes them there), and an in-flight drag pins the track data-state="visible" and painted. That way a drag is never silently aborted if the scrollbar would otherwise self-hide mid-gesture (e.g. a type="scroll" fade, or a consumer display: none on [data-state="hidden"]).

  • RTL is handled on the horizontal axis. When the root resolves to dir="rtl", the horizontal thumb starts pinned to the right edge of the track and a leftward drag scrolls the content forward (the browser's native negative-scrollLeft model). Set the direction the standard way: [dir] on the root or an ancestor dir attribute.

  • Minimum thumb size is 8px, matching common UI conventions for very long content.

  • A press on bare track scrolls, like a native scrollbar. trackPress on the root picks the behaviour:

    • 'page' (default) steps one page toward the press, then auto-repeats while the pointer is held (trackPressRepeatDelay ms, then every trackPressRepeatInterval ms) and stops once the thumb reaches the pointer instead of overshooting. Moving the pointer further along the track resumes the repeat. One page is max(clientSize × 0.875, clientSize − 40, 1) scroll pixels, so a step keeps a sliver of the previous page in view.
    • 'jump' centres the thumb on the press point immediately, then keeps scrubbing while the pointer is held and moved.
    • 'none' ignores track presses entirely. It is the escape hatch for a hand-rolled gesture built on the scrollbar's commands (see ForScrollAreaScrollbar).

    A track press claims the gesture with preventDefault() (so the browser starts no text selection) and uses pointer-capture with document-level listeners, exactly like the thumb drag; the track is pinned data-state="visible" and painted while the press is in flight, so a type="hover" / "scroll" fade can't collapse it mid-gesture. A press whose defaultPrevented is already set (by a consumer handler on the same element that ran first) is left alone, and a press that lands on the thumb belongs to the thumb's drag. RTL works on the horizontal axis with no extra wiring: the press point maps through the same left-origin space the thumb uses, so pressing before the (right-resting) thumb steps the logical scroll forward.

  • A track press never moves focus. A native scrollbar is not focusable and a press on one does not move focus, so neither does this one. The preventDefault() also suppresses the implicit focus-on-pointerdown. This is deliberately the opposite of [forSliderTrack], where a press changes a value on a focusable widget. Keyboard scrolling stays the focusable viewport's job.

  • Motion is yours: the library writes plain scrollTop / scrollLeft. No behavior: 'smooth' is shipped, so animate a track press by setting scroll-behavior on [forScrollAreaViewport] and gating it yourself. That is also the primitive's prefers-reduced-motion hook:

    .sa-viewport {
      scroll-behavior: smooth;
    }
    @media (prefers-reduced-motion: reduce) {
      .sa-viewport {
        scroll-behavior: auto;
      }
    }
  • For reliable touch track presses, give the scrollbar touch-action: none. The library adds no inline touch-action (on the track or the thumb), so on touch a press-and-drag on the track can also pan the page. Add the rule in your own CSS on [forScrollAreaScrollbar] when you want the gesture to belong to the scrollbar.

  • The corner only shows when both scrollbars are visible (or always, under type="always", where both tracks are permanently present). Otherwise the directive hides it with an inline display: none in addition to the hidden attribute that removes it from the accessibility tree (the only place the rule "primitives never apply [hidden]" doesn't apply, because the corner has no logical presence without two scrollbars). Because the inline style beats any author selector rule, you can give [forScrollAreaCorner] a custom display without a .x[hidden] { display: none } workaround. The directive's display: none still wins while the corner is hidden, and your display applies once both scrollbars show.

  • Content observation is opt-in: the viewport observes its own size automatically, but only observes the content element when the consumer tags it with [forScrollAreaContent]. Skipping the directive is allowed (the viewport still scrolls and the scrollbars still render); the scrollbars just won't react to content reflows. The library never guesses firstElementChild, since that silently breaks when content is wrapped in a layer or split across siblings.

Wrapping in a design system

Subclass the root and re-provide FOR_SCROLL_AREA_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.