Theme
Version
GitHub PyPI Discord
On this page

ScrollArea

Use CScrollArea when bounded content needs a consistent focus stop, optional region name, logical-axis policy, normalized scroll callback, or retained-root lifecycle behavior. The component renders one native scrolling div. The browser still owns its scrollbar, wheel, touch, trackpad, and keyboard behavior.

Use ordinary CSS when overflow: auto is enough. ScrollArea does not replace native scrollbars or add track, thumb, corner, edge-shadow, or scroll-button elements.

Start with one native viewport

The default slot is transparent. It adds no content wrapper and does not change the semantics, focus order, or layout of its children.

<c-CScrollArea aria_label="Recent activity">
  <ol>
    <li>Import completed</li>
    <li>Review requested</li>
    <li>Release approved</li>
  </ol>
</c-CScrollArea>

When aria_label or aria_labelledby is supplied, the viewport becomes a named region. Omit both for a generic focusable viewport. The two inputs are mutually exclusive.

Block, inline, and two-axis native scrolling
Show code
from typing import Any

import citry_ui
from citry import Component, citry
from citry_ui import CScrollArea

citry.register_library(citry_ui)


class ScrollAreaAtAGlance(Component):
    class Kwargs:
        pass

    class Slots:
        pass

    def template_data(
        self,
        kwargs: Kwargs,  # noqa: ARG002
        slots: Slots,  # noqa: ARG002
    ) -> dict[str, Any]:
        return {
            "python_activity": CScrollArea(
                style={"--cui-scroll-area-max-block-size": "7rem"},
                slots={
                    "default": (
                        "Python composition keeps the same native viewport. ",
                        "Its content remains ordinary escaped slot content. ",
                        "The scrollbar belongs to the browser.",
                    ),
                },
            ),
        }

    template = """
      <section class="scroll-area-glance">
        <article>
          <h3>Recent activity</h3>
          <c-CScrollArea
            aria_label="Recent activity"
            style="--cui-scroll-area-max-block-size: 9rem"
          >
            <ol class="scroll-area-glance__activity">
              <li>Import completed</li>
              <li>Review requested</li>
              <li>Access approved</li>
              <li>Build started</li>
              <li>Checks completed</li>
              <li>Release published</li>
              <li>Audit archived</li>
            </ol>
          </c-CScrollArea>
        </article>

        <article>
          <h3>Applied filters</h3>
          <c-CScrollArea
            axis="inline"
            aria_label="Applied filters"
          >
            <div class="scroll-area-glance__rail">
              <span>Region: Central Europe</span>
              <span>Status: Needs review</span>
              <span>Owner: Operations</span>
              <span>Window: Last 90 days</span>
            </div>
          </c-CScrollArea>
        </article>

        <article>
          <h3>Result matrix</h3>
          <c-CScrollArea
            axis="both"
            aria_label="Result matrix"
            style="--cui-scroll-area-max-block-size: 9rem"
          >
            <div class="scroll-area-glance__matrix">
              <strong>Service</strong><strong>Owner</strong><strong>Region</strong>
              <span>Accounts</span><span>Identity</span><span>Prague</span>
              <span>Ledger</span><span>Finance</span><span>Berlin</span>
              <span>Search</span><span>Discovery</span><span>Vienna</span>
              <span>Archive</span><span>Records</span><span>Warsaw</span>
            </div>
          </c-CScrollArea>
        </article>

        <article>
          <h3>Python composition</h3>
          {{ python_activity }}
        </article>
      </section>
    """

    css = """
      :where(.scroll-area-glance) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-glance article) {
        display: grid;
        gap: 0.75rem;
        align-content: start;
        min-inline-size: 0;
      }

      :where(.scroll-area-glance h3) {
        margin: 0;
      }

      :where(.scroll-area-glance__activity) {
        display: grid;
        gap: 0.5rem;
        margin: 0;
        padding: 1rem 1rem 1rem 2rem;
      }

      :where(.scroll-area-glance__rail) {
        display: flex;
        inline-size: max-content;
        gap: 0.75rem;
        padding: 1rem;
      }

      :where(.scroll-area-glance__rail span) {
        padding: 0.375rem 0.625rem;
        border-radius: 999px;
        background: color-mix(in srgb, Highlight 14%, Canvas);
      }

      :where(.scroll-area-glance__matrix) {
        display: grid;
        grid-template-columns: repeat(3, minmax(9rem, 1fr));
        gap: 1px;
        inline-size: max-content;
        min-inline-size: 30rem;
        background: color-mix(in srgb, CanvasText 18%, transparent);
      }

      :where(.scroll-area-glance__matrix > *) {
        padding: 0.625rem;
        background: Canvas;
      }
    """


preview = ScrollAreaAtAGlance()

preview  # noqa: B018

Enter the viewport with the keyboard

The viewport always has tabindex="0" and a visible focus ring. Native Page, Home, End, Space, arrow, wheel, and touch behavior stays with the browser, so exact keys and pixel increments can differ by platform. Focusable children keep their ordinary Tab order. ScrollArea never traps or moves focus.

Viewport and descendant focus
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaActivityAndFocus(Component):
    template = """
      <section
        class="scroll-area-focus"
        x-data="{last:'Focus the viewport, a link, or an action'}"
        @activity-focus="last=$event.detail"
        @activity-blur="last=$event.detail"
      >
        <p>
          Tab enters the viewport before its descendants. Native scrolling
          keys keep focus on the viewport.
        </p>
        <c-CScrollArea
          aria_label="Deployment activity"
          style="--cui-scroll-area-max-block-size: 15rem"
          c-attrs="{
            '@focus':'$dispatch(`activity-focus`, `Focused ${$event.target.id}`)',
            '@blur':'$dispatch(`activity-blur`, `Left ${$event.target.id}`)',
          }"
          $c-props="{
            onScrollChange:(detail)=>
              last=`Block offset ${Math.round(detail.blockOffset)}`,
          }"
          id="deployment-activity"
        >
          <ol class="scroll-area-focus__timeline">
            <li>
              <strong>09:10</strong>
              <span>Build completed.</span>
              <a href="#build-details">View build details</a>
            </li>
            <li>
              <strong>09:18</strong>
              <span>Security review requested.</span>
              <c-CButton size="sm" variant="outline">Open review</c-CButton>
            </li>
            <li>
              <strong>09:26</strong>
              <span>Staging deployment completed.</span>
              <a href="#staging-log">Read staging log</a>
            </li>
            <li>
              <strong>09:42</strong>
              <span>Production approval received.</span>
              <c-CButton size="sm">Publish release</c-CButton>
            </li>
            <li>
              <strong>09:51</strong>
              <span>Release notes archived.</span>
              <a href="#release-notes">Open release notes</a>
            </li>
          </ol>
        </c-CScrollArea>
        <output x-text="last">Focus the viewport, a link, or an action</output>
      </section>
    """

    css = """
      :where(.scroll-area-focus) {
        display: grid;
        gap: 0.75rem;
        max-inline-size: 38rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-focus p, .scroll-area-focus output) {
        margin: 0;
      }

      :where(.scroll-area-focus__timeline) {
        display: grid;
        gap: 1rem;
        margin: 0;
        padding: 1rem 1rem 1rem 2.5rem;
      }

      :where(.scroll-area-focus__timeline li) {
        display: grid;
        grid-template-columns: 4rem 1fr;
        gap: 0.375rem 0.75rem;
        align-items: center;
      }

      :where(.scroll-area-focus__timeline li > :not(strong)) {
        grid-column: 2;
      }
    """


preview = ScrollAreaActivityAndFocus()

preview  # noqa: B018

Do not attach a root key handler to reproduce native scrolling. It can consume Home, End, or arrow keys intended for an input or another interactive child.

Keep wide data semantic

Use axis="both" for a table or other surface whose meaning requires two dimensions. The slotted Table keeps its own caption, headers, cells, and focus behavior. ScrollArea only supplies the bounded native viewport.

A semantic table at narrow width
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaWideTable(Component):
    template = """
      <section class="scroll-area-wide-table" x-data="{direction:'ltr'}">
        <h2 id="quarterly-results-title">Quarterly service results</h2>
        <p>
          The Table keeps its caption and headers. ScrollArea only bounds the
          two-dimensional viewport.
        </p>
        <button
          type="button"
          @click="direction=direction === 'ltr' ? 'rtl' : 'ltr'"
        >Flip table direction</button>
        <div :dir="direction">
          <c-CScrollArea
            axis="both"
            aria_labelledby="quarterly-results-title"
            style="--cui-scroll-area-max-block-size: 16rem"
          >
            <table class="scroll-area-wide-table__table">
            <caption>Latency and availability by quarter</caption>
            <thead>
              <tr>
                <th scope="col">Service</th>
                <th scope="col">Q1 latency</th>
                <th scope="col">Q2 latency</th>
                <th scope="col">Q3 latency</th>
                <th scope="col">Q4 latency</th>
                <th scope="col">Availability</th>
              </tr>
            </thead>
            <tbody>
              <tr>
                <th scope="row"><a href="#quarterly-results-title">Accounts</a></th>
                <td>112 ms</td><td>104 ms</td><td>98 ms</td>
                <td>91 ms</td><td>99.99%</td>
              </tr>
              <tr>
                <th scope="row"><a href="#quarterly-results-title">Ledger</a></th>
                <td>190 ms</td><td>172 ms</td><td>160 ms</td>
                <td>151 ms</td><td>99.97%</td>
              </tr>
              <tr>
                <th scope="row"><a href="#quarterly-results-title">Search</a></th>
                <td>86 ms</td><td>81 ms</td><td>74 ms</td>
                <td>69 ms</td><td>99.95%</td>
              </tr>
              <tr>
                <th scope="row"><a href="#quarterly-results-title">Archive</a></th>
                <td>244 ms</td><td>231 ms</td><td>218 ms</td>
                <td>205 ms</td><td>99.90%</td>
              </tr>
              <tr>
                <th scope="row"><a href="#quarterly-results-title">Reports</a></th>
                <td>155 ms</td><td>149 ms</td><td>141 ms</td>
                <td>134 ms</td><td>99.96%</td>
              </tr>
            </tbody>
            </table>
          </c-CScrollArea>
        </div>
        <p class="scroll-area-wide-table__print-note">
          This fixture supplies its own compact print table so the final
          column fits inside the physical page.
        </p>
      </section>
    """

    css = """
      :where(.scroll-area-wide-table) {
        display: grid;
        gap: 0.75rem;
        inline-size: min(100%, 42rem);
        min-inline-size: 0;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-wide-table h2, .scroll-area-wide-table p) {
        margin: 0;
      }

      :where(.scroll-area-wide-table > button) {
        justify-self: start;
      }

      :where(.scroll-area-wide-table__table) {
        inline-size: 52rem;
        border-collapse: collapse;
      }

      :where(.scroll-area-wide-table__table caption) {
        padding: 0.75rem;
        font-weight: 700;
        text-align: start;
      }

      :where(.scroll-area-wide-table__table th,
        .scroll-area-wide-table__table td) {
        min-inline-size: 7rem;
        padding: 0.625rem;
        border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
        text-align: start;
      }

      :where(.scroll-area-wide-table__table thead th) {
        background: color-mix(in srgb, Highlight 12%, Canvas);
      }

      @media print {
        :where(.scroll-area-wide-table) {
          inline-size: 100%;
        }

        :where(.scroll-area-wide-table__table) {
          inline-size: 100%;
          table-layout: fixed;
          font-size: 8pt;
        }

        :where(.scroll-area-wide-table__table th,
          .scroll-area-wide-table__table td) {
          min-inline-size: 0;
          padding: 0.2rem;
          overflow-wrap: anywhere;
        }
      }
    """


preview = ScrollAreaWideTable()

preview  # noqa: B018

At 400 percent zoom, prefer block flow unless two-dimensional content is essential. In print, ScrollArea removes its own maximum size, border, and overflow clipping. An application must still reflow, scale, rotate, or replace content that is wider than the physical page.

Change native overflow policy

axis accepts logical block, inline, or both. scrollbar_width accepts auto or thin. scrollbar_gutter accepts auto, stable, or stable-both-edges. Native scrollbar thickness, overlay behavior, and gutter pixels remain browser and operating-system choices.

overscroll="contain" limits native scroll chaining on enabled axes, while none also requests suppression of local boundary effects. These are CSS policies, not promises that every browser, device, or synthetic event delivers the same gesture behavior.

The policies follow CSS Overflow, CSS Scrollbars, and CSS Overscroll Behavior.

Reactive axis and scrollbar policy
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaConfiguration(Component):
    template = """
      <section
        class="scroll-area-configuration"
        x-data="{
          axis:'block',
          width:'auto',
          gutter:'auto',
          overscroll:'auto',
          show:(value)=>value ?? 'server fallback',
        }"
      >
        <div class="scroll-area-configuration__controls">
          <label>
            Axis
            <select x-model="axis">
              <option value="block">Block</option>
              <option value="inline">Inline</option>
              <option value="both">Both</option>
            </select>
          </label>
          <label>
            Scrollbar width
            <select x-model="width">
              <option value="auto">Auto</option>
              <option value="thin">Thin</option>
            </select>
          </label>
          <label>
            Scrollbar gutter
            <select x-model="gutter">
              <option value="auto">Auto</option>
              <option value="stable">Stable</option>
              <option value="stable-both-edges">Both edges</option>
            </select>
          </label>
          <label>
            Overscroll
            <select x-model="overscroll">
              <option value="auto">Auto</option>
              <option value="contain">Contain</option>
              <option value="none">None</option>
            </select>
          </label>
        </div>

        <c-CScrollArea
          id="scroll-area-configuration-target"
          axis="block"
          aria_label="Configurable audit records"
          style="--cui-scroll-area-max-block-size: 12rem"
          $c-props="{
            axis,
            scrollbarWidth:width,
            scrollbarGutter:gutter,
            overscroll,
          }"
        >
          <div class="scroll-area-configuration__content">
            <span>Record 01</span><span>Identity review</span><span>Approved</span>
            <span>Record 02</span><span>Ledger review</span><span>Pending</span>
            <span>Record 03</span><span>Archive review</span><span>Approved</span>
            <span>Record 04</span><span>Search review</span><span>Pending</span>
            <span>Record 05</span><span>Report review</span><span>Approved</span>
            <span>Record 06</span><span>Export review</span><span>Pending</span>
          </div>
        </c-CScrollArea>

        <div class="scroll-area-configuration__actions">
          <button
            type="button"
            @click="axis=null;width=null;gutter=null;overscroll=null"
          >Release every override</button>
          <button type="button" @click="axis='diagonal'">
            Try an invalid axis
          </button>
        </div>
        <output
          x-text="`Requested: ${show(axis)}, ${show(width)}, ${show(gutter)}, ${show(overscroll)}`"
        >Requested: block, auto, auto, auto</output>
      </section>
    """

    css = """
      :where(.scroll-area-configuration) {
        display: grid;
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-configuration__controls) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
      }

      :where(.scroll-area-configuration__controls label) {
        display: grid;
        gap: 0.25rem;
      }

      :where(.scroll-area-configuration__content) {
        display: grid;
        grid-template-columns: repeat(3, minmax(9rem, 1fr));
        gap: 1px;
        inline-size: 38rem;
        min-block-size: 18rem;
        background: color-mix(in srgb, CanvasText 18%, transparent);
      }

      :where(.scroll-area-configuration__content span) {
        padding: 0.75rem;
        background: Canvas;
      }

      :where(.scroll-area-configuration__actions) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
      }
    """


preview = ScrollAreaConfiguration()

preview  # noqa: B018

Client axis, scrollbarWidth, scrollbarGutter, and overscroll values win field by field. null or omission releases one field to its latest server fallback. An invalid value keeps the last valid effective value and reports one diagnostic for that invalid episode.

The root owns instantaneous scroll-behavior: auto for direction, disabled-axis, and morph repair. An application can still request a smooth native movement in an explicit scrollTo() call, but it cannot replace the root's computed CSS policy.

Read logical RTL offsets

onScrollChange receives logical distance from inline start and block distance from the top. RTL callers do not need to interpret a negative browser scrollLeft. The detail describes the callback instant only and does not claim persistent edge or progress state.

Raw viewport geometry and native events follow CSSOM View.

LTR and RTL logical offsets
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaRtlAndDirection(Component):
    template = """
      <section
        class="scroll-area-direction"
        x-data="{
          direction:'ltr',
          ltrOffset:0,
          rtlOffset:0,
          flipOffset:0,
        }"
      >
        <div class="scroll-area-direction__controls">
          <button
            type="button"
            @click="direction=direction === 'ltr' ? 'rtl' : 'ltr'"
          >Flip the third rail</button>
          <output x-text="`Third rail direction: ${direction}`">
            Third rail direction: ltr
          </output>
        </div>

        <div class="scroll-area-direction__grid">
          <article dir="ltr">
            <h3>LTR</h3>
            <c-CScrollArea
              axis="inline"
              aria_label="LTR deployment stages"
              $c-props="{
                onScrollChange:(detail)=>
                  ltrOffset=Math.round(detail.inlineOffset),
              }"
            >
              <div class="scroll-area-direction__rail">
                <span>Plan</span><span>Build</span><span>Review</span>
                <span>Approve</span><span>Publish</span><span>Archive</span>
              </div>
            </c-CScrollArea>
            <output x-text="`Logical offset ${ltrOffset}`">
              Logical offset 0
            </output>
          </article>

          <article dir="rtl">
            <h3>RTL</h3>
            <c-CScrollArea
              axis="inline"
              aria_label="مراحل النشر"
              $c-props="{
                onScrollChange:(detail)=>
                  rtlOffset=Math.round(detail.inlineOffset),
              }"
            >
              <div class="scroll-area-direction__rail">
                <span>تخطيط</span><span>بناء</span><span>مراجعة</span>
                <span>موافقة</span><span>نشر</span><span>أرشفة</span>
              </div>
            </c-CScrollArea>
            <output x-text="`Logical offset ${rtlOffset}`">
              Logical offset 0
            </output>
          </article>

          <article :dir="direction">
            <h3>Direction change</h3>
            <c-CScrollArea
              axis="inline"
              aria_label="Direction-changing stages"
              $c-props="{
                onScrollChange:(detail)=>
                  flipOffset=Math.round(detail.inlineOffset),
              }"
            >
              <div class="scroll-area-direction__rail">
                <span>North</span><span>South</span><span>East</span>
                <span>West</span><span>Coast</span><span>Harbor</span>
              </div>
            </c-CScrollArea>
            <output x-text="`Logical offset ${flipOffset}`">
              Logical offset 0
            </output>
          </article>
        </div>
      </section>
    """

    css = """
      :where(.scroll-area-direction) {
        display: grid;
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-direction__controls) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
        align-items: center;
      }

      :where(.scroll-area-direction__grid) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
        gap: 1rem;
      }

      :where(.scroll-area-direction article) {
        display: grid;
        gap: 0.5rem;
        min-inline-size: 0;
      }

      :where(.scroll-area-direction h3) {
        margin: 0;
      }

      :where(.scroll-area-direction__rail) {
        display: flex;
        inline-size: max-content;
        gap: 0.75rem;
        padding: 1rem;
      }

      :where(.scroll-area-direction__rail span) {
        min-inline-size: 7rem;
        padding: 0.625rem;
        border-radius: 0.5rem;
        background: color-mix(in srgb, Highlight 12%, Canvas);
        text-align: center;
      }
    """


preview = ScrollAreaRtlAndDirection()

preview  # noqa: B018

A direction change preserves the last cached logical distance when the same root remains connected. Stylesheet-only direction changes are reconciled at the next native scroll, configuration update, or Citry morph settlement. Vertical writing modes keep usable native overflow but suspend normalized callbacks and lifecycle repair.

Nest independent scrolling regions

Nested ScrollAreas remain ordinary nested native scroll containers. The browser decides which area receives a gesture. Give nested named regions distinct useful names, and leave incidental regions unnamed.

Nested regions and overscroll policy
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaNestedAreas(Component):
    template = """
      <section class="scroll-area-nested" x-data="{direction:'ltr'}">
        <h2>Operations document</h2>
        <p>
          The outer document and inner inspector are separate native scroll
          containers. Tab order and gesture targeting stay with the browser.
        </p>
        <button
          type="button"
          @click="direction=direction === 'ltr' ? 'rtl' : 'ltr'"
        >Flip document direction</button>
        <div :dir="direction">
          <c-CScrollArea
            overscroll="auto"
            style="--cui-scroll-area-max-block-size: 20rem"
          >
            <div class="scroll-area-nested__document">
            <p>
              The deployment plan contains enough content to scroll before
              and after the nested inspector.
            </p>
            <p>
              Review the service boundary, owner, and current policy before
              continuing to the approval section.
            </p>

              <c-CScrollArea
                aria_label="Service inspector"
                overscroll="contain"
                style="--cui-scroll-area-max-block-size: 10rem"
              >
                <dl class="scroll-area-nested__inspector">
                <dt>Service</dt><dd>Ledger export</dd>
                <dt>Owner</dt><dd>Finance platform</dd>
                <dt>Region</dt><dd>Central Europe</dd>
                <dt>Status</dt><dd>Needs approval</dd>
                <dt>Retention</dt><dd>Seven years</dd>
                <dt>Encryption</dt><dd>Customer managed</dd>
                <dt>Review</dt><dd>Quarterly</dd>
                </dl>
              </c-CScrollArea>

              <c-CScrollArea axis="inline" overscroll="none">
                <div class="scroll-area-nested__rail">
                  <span>Plan</span><span>Build</span><span>Review</span>
                  <span>Approve</span><span>Release</span>
                </div>
              </c-CScrollArea>

            <p>
              Continue through the remaining deployment notes after leaving
              the inspector.
            </p>
            <p>
              The outer viewport does not register the inner viewport as a
              widget or arbitrate its gestures.
            </p>
            <p>
              Real wheel, precision trackpad, and touch behavior remains a
              platform acceptance check.
            </p>
            </div>
          </c-CScrollArea>
        </div>
      </section>
    """

    css = """
      :where(.scroll-area-nested) {
        display: grid;
        gap: 0.75rem;
        max-inline-size: 40rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-nested h2, .scroll-area-nested p) {
        margin: 0;
      }

      :where(.scroll-area-nested > button) {
        justify-self: start;
      }

      :where(.scroll-area-nested__document) {
        display: grid;
        gap: 1.5rem;
        padding: 1rem;
      }

      :where(.scroll-area-nested__inspector) {
        display: grid;
        grid-template-columns: max-content 1fr;
        gap: 0.625rem 1rem;
        margin: 0;
        padding: 1rem;
      }

      :where(.scroll-area-nested__inspector dt) {
        font-weight: 700;
      }

      :where(.scroll-area-nested__inspector dd) {
        margin: 0;
      }

      :where(.scroll-area-nested__rail) {
        display: flex;
        inline-size: max-content;
        gap: 0.75rem;
        padding: 1rem;
      }

      :where(.scroll-area-nested__rail span) {
        min-inline-size: 7rem;
        padding: 0.5rem;
        background: color-mix(in srgb, Highlight 12%, Canvas);
      }
    """


preview = ScrollAreaNestedAreas()

preview  # noqa: B018

Distinguish the component callback from native events

onScrollChange is a semantic component callback supplied through $c-props. It runs at most once per animation frame after one or more actual native scroll events. It receives the latest native event as detail.source. Content resize, image load, configuration changes, and component-owned repairs do not create this callback.

Event-scoped logical scroll details
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaNativeCallback(Component):
    template = """
      <section
        class="scroll-area-callback"
        x-data="{
          rows:6,
          sentinelTop:150,
          imageVisible:false,
          expanded:false,
          nativeCount:0,
          settled:0,
          callbackCount:0,
          lastInline:0,
          lastBlock:0,
        }"
        @scroll-area-native="nativeCount += 1"
        @scroll-area-settled="settled += 1"
        x-init="$nextTick(() => {
          const host = $refs.shadowHost;
          const fixture = $refs.shadowFixture;
          if (!host.shadowRoot && fixture) host.attachShadow({mode:'open'}).append(fixture);
        })"
      >
        <div class="scroll-area-callback__controls">
          <button type="button" @click="rows += 2">Add content</button>
          <button type="button" @click="rows = Math.max(1, rows - 2)">
            Remove content
          </button>
          <button type="button" @click="sentinelTop += 80">
            Move absolute marker
          </button>
          <button
            type="button"
            @click="setTimeout(()=>imageVisible=true,350)"
          >Load a delayed image</button>
          <button type="button" @click="expanded=!expanded">
            Toggle content stylesheet
          </button>
        </div>

        <c-CScrollArea
          axis="both"
          aria_label="Event-scoped audit log"
          style="--cui-scroll-area-max-block-size: 13rem"
          c-attrs="{
            '@scroll':'$dispatch(`scroll-area-native`)',
            '@scrollend':'$dispatch(`scroll-area-settled`)',
          }"
          $c-props="{
            onScrollChange:(detail)=>{
              callbackCount += 1;
              lastInline = Math.round(detail.inlineOffset);
              lastBlock = Math.round(detail.blockOffset);
            },
          }"
        >
          <div
            class="scroll-area-callback__content"
            :class="{'scroll-area-callback__content--expanded':expanded}"
          >
            <template x-for="row in rows" :key="row">
              <p x-text="`Audit row ${row}: current native content`"></p>
            </template>
            <span
              class="scroll-area-callback__sentinel"
              :style="`inset-block-start:${sentinelTop}px`"
            >Absolute marker</span>
            <template x-if="imageVisible">
              <img
                class="scroll-area-callback__image"
                alt="Delayed audit chart"
                src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="
              />
            </template>
            <div x-ref="shadowHost" class="scroll-area-callback__shadow-host">
              <div x-ref="shadowFixture">
                <p style="inline-size:26rem;min-block-size:5rem;padding:0.5rem">
                  Open ShadowRoot content changes native layout without creating
                  a component callback.
                </p>
              </div>
            </div>
          </div>
        </c-CScrollArea>

        <dl class="scroll-area-callback__readout">
          <dt>Native scroll events</dt><dd x-text="nativeCount">0</dd>
          <dt>Native scrollend events</dt><dd x-text="settled">0</dd>
          <dt>Component callbacks</dt><dd x-text="callbackCount">0</dd>
          <dt>Logical inline offset</dt><dd x-text="lastInline">0</dd>
          <dt>Block offset</dt><dd x-text="lastBlock">0</dd>
        </dl>
      </section>
    """

    css = """
      :where(.scroll-area-callback) {
        display: grid;
        gap: 1rem;
        max-inline-size: 42rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-callback__controls) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
      }

      :where(.scroll-area-callback__content) {
        position: relative;
        inline-size: 44rem;
        min-block-size: 26rem;
        padding: 1rem;
      }

      :where(.scroll-area-callback__content--expanded) {
        min-block-size: 34rem;
      }

      :where(.scroll-area-callback__content p) {
        margin: 0 0 1rem;
      }

      :where(.scroll-area-callback__sentinel) {
        position: absolute;
        inset-inline-start: 28rem;
        padding: 0.375rem 0.625rem;
        border-radius: 0.375rem;
        background: color-mix(in srgb, Highlight 18%, Canvas);
      }

      :where(.scroll-area-callback__image) {
        display: block;
        inline-size: 30rem;
        block-size: 6rem;
        margin-block: 1rem;
        background: color-mix(in srgb, Highlight 14%, Canvas);
      }

      :where(.scroll-area-callback__shadow-host) {
        display: block;
        min-inline-size: 26rem;
        min-block-size: 5rem;
        border: 1px dashed GrayText;
      }

      :where(.scroll-area-callback__readout) {
        display: grid;
        grid-template-columns: max-content 1fr;
        gap: 0.375rem 1rem;
        margin: 0;
      }

      :where(.scroll-area-callback__readout dt) {
        font-weight: 700;
      }

      :where(.scroll-area-callback__readout dd) {
        margin: 0;
      }
    """


preview = ScrollAreaNativeCallback()

preview  # noqa: B018

Native root events remain Alpine listeners in attrs:

<section
  x-data="{nativeCount:0,settled:false,last:0}"
  @build-log-scroll="nativeCount += 1"
  @build-log-settled="settled = true"
>
  <c-CScrollArea
    aria_label="Build log"
    c-attrs="{
      '@scroll':'$dispatch(`build-log-scroll`)',
      '@scrollend':'$dispatch(`build-log-settled`)',
    }"
    $c-props="{onScrollChange:(detail)=>last=detail.blockOffset}"
  >
    ...
  </c-CScrollArea>
</section>

Native listeners observe every browser event, including an event produced by component-owned coordinate repair. ScrollArea dispatches no custom DOM event and exposes no public method. A listener on a component root has Citry's isolated component scope, so it cannot read ancestor-local x-data identifiers directly. Use $event, $dispatch, $store, or another explicit global bridge; use onScrollChange for owner-local callback state. Application controls can use an ordinary DOM ref and the native scrollTo() or scrollBy() method.

Customize standards-based styling

Public variables control the viewport's size, colors, border, radius, padding, focus ring, scroll padding, and complete standard scrollbar-color value. The one stable selector targets the same native viewport.

Public variables and the viewport selector
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaCustomization(Component):
    template = """
      <section class="scroll-area-customization">
        <article class="scroll-area-brand scroll-area-brand--orchard">
          <h3>Orchard notes</h3>
          <c-CScrollArea
            class_="brand-scroll"
            aria_label="Orchard notes"
            scrollbar_width="thin"
            scrollbar_gutter="stable"
          >
            <div class="scroll-area-customization__notes">
              <p>Pear block: pollinator rows checked.</p>
              <p>North field: irrigation pressure normal.</p>
              <p>West field: pruning review scheduled.</p>
              <p>Harvest window: seven days remaining.</p>
              <p>Cold store: capacity confirmed.</p>
            </div>
          </c-CScrollArea>
        </article>

        <article
          class="scroll-area-brand scroll-area-brand--harbor"
          style="color-scheme:dark"
        >
          <h3>Harbor notes</h3>
          <c-CScrollArea
            class_="brand-scroll"
            aria_label="Harbor notes"
            scrollbar_gutter="stable-both-edges"
          >
            <div class="scroll-area-customization__notes">
              <p>North berth: loading complete.</p>
              <p>East pier: tide window confirmed.</p>
              <p>Customs desk: manifest approved.</p>
              <p>Harbor pilot: departure booked.</p>
              <p>Weather station: visibility clear.</p>
            </div>
          </c-CScrollArea>
        </article>
      </section>
    """

    css = """
      :where(.scroll-area-customization) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-brand) {
        display: grid;
        gap: 0.75rem;
        padding: 1rem;
        border-radius: 1rem;
      }

      :where(.scroll-area-brand h3) {
        margin: 0;
      }

      :where(.scroll-area-brand--orchard) {
        background: #f5f0df;
        color: #203422;
        --cui-scroll-area-max-block-size: 10rem;
        --cui-scroll-area-background: #fffdf5;
        --cui-scroll-area-foreground: #203422;
        --cui-scroll-area-border-color: #78916d;
        --cui-scroll-area-focus-color: #315f37;
        --cui-scroll-area-radius: 1rem;
      }

      :where(.scroll-area-brand--harbor) {
        background: #102b38;
        color: #eefaff;
        --cui-scroll-area-max-block-size: 10rem;
        --cui-scroll-area-background: #173c4c;
        --cui-scroll-area-foreground: #eefaff;
        --cui-scroll-area-border-color: #72b5ce;
        --cui-scroll-area-focus-color: #c6ecff;
        --cui-scroll-area-scrollbar-color: #9eddf4 #173c4c;
      }

      .scroll-area-brand
      .brand-scroll[data-citry-ui-part="scroll-area"] {
        border-width: 2px;
      }

      :where(.scroll-area-customization__notes) {
        display: grid;
        gap: 0.75rem;
        padding: 1rem;
      }

      :where(.scroll-area-customization__notes p) {
        margin: 0;
      }

      @media (forced-colors: active) {
        :where(.scroll-area-brand) {
          border: 1px solid CanvasText;
        }
      }

      @media print {
        :where(.scroll-area-brand) {
          background: transparent;
          color: black;
        }
      }
    """


preview = ScrollAreaCustomization()

preview  # noqa: B018

Citry uses scrollbar-width, scrollbar-color, and scrollbar-gutter. Vendor scrollbar pseudo-elements are not public API. Forced colors restore platform scrollbar, border, and focus colors. Unlayered application rules override the Citry UI theme layer whether loaded before or after the component stylesheet. A named application layer must be ordered after citry-ui.theme.

Respect the clipping boundary

Native overflow clips ordinary positioned descendants. A dropdown, tooltip, or menu cannot escape merely because it appears in the default slot. Compose a supported Citry overlay or native top-layer element whose own contract defines its host, focus, and layering.

Clipped content and an independently owned overlay
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaOverlayBoundary(Component):
    template = """
      <section class="scroll-area-overlay-boundary">
        <h2>Credential review</h2>
        <p>
          The red sample is ordinary positioned content and clips at the
          viewport. The Popover follows its own anchored-layer contract.
        </p>
        <c-CScrollArea
          aria_label="Credential review notes"
          style="--cui-scroll-area-max-block-size: 12rem"
        >
          <div class="scroll-area-overlay-boundary__content">
            <span class="scroll-area-overlay-boundary__clipped">
              Ordinary positioned note
            </span>
            <p>Confirm the token owner and intended service boundary.</p>
            <p>Review the current scopes before granting another permission.</p>
            <c-CPopover>
              <c-fill name="activator" data="{ activator_attrs }">
                <c-CButton
                  size="sm"
                  variant="outline"
                  c-attrs="activator_attrs"
                >Open scope help</c-CButton>
              </c-fill>
              <c-fill name="title">Credential scope</c-fill>
              <c-fill name="default">
                Grant only the permissions this worker needs.
              </c-fill>
            </c-CPopover>
            <p>Record the approval before rotating the credential.</p>
            <p>Archive the previous key after the overlap window closes.</p>
          </div>
        </c-CScrollArea>
      </section>
    """

    css = """
      :where(.scroll-area-overlay-boundary) {
        display: grid;
        gap: 0.75rem;
        max-inline-size: 38rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-overlay-boundary h2,
        .scroll-area-overlay-boundary p) {
        margin: 0;
      }

      :where(.scroll-area-overlay-boundary__content) {
        position: relative;
        display: grid;
        gap: 1rem;
        min-block-size: 22rem;
        padding: 1rem;
      }

      :where(.scroll-area-overlay-boundary__clipped) {
        position: absolute;
        inset-block-start: 1rem;
        inset-inline-end: -5rem;
        inline-size: 8rem;
        padding: 0.5rem;
        border: 2px solid #b42318;
        background: Canvas;
        color: #b42318;
      }
    """


preview = ScrollAreaOverlayBoundary()

preview  # noqa: B018

ScrollArea does not register as an overlay owner, lock page scroll, make siblings inert, or create a stacking context.

Preserve only a retained root

A correlated Citry morph that retains the same root preserves valid client configuration, cached logical position, and focus on that root. Incoming server values become new fallbacks for fields without client ownership.

Retained-root morph and replacement scope
Show code
from __future__ import annotations

import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ScrollAreaLifecycle(Component):
    class Kwargs:
        step: int = 0
        replacement: int = 0

    class Slots:
        pass

    class Events:
        def refresh(self) -> ScrollAreaLifecycle:
            return ScrollAreaLifecycle(step=1, replacement=0)

        def replace(self) -> ScrollAreaLifecycle:
            return ScrollAreaLifecycle(step=2, replacement=1)

    def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, object]:  # noqa: ARG002
        return {
            "root_key": f"scroll-area-lifecycle-{kwargs.replacement}",
            "step": kwargs.step,
        }

    template = """
      <section
        class="scroll-area-lifecycle"
        x-data="{mounted:true,lastOffset:0,direction:'ltr'}"
      >
        <div class="scroll-area-lifecycle__controls">
          <button type="button" @c-click="refresh">
            Retained-root server morph
          </button>
          <button type="button" @c-click="replace">
            Replace the root
          </button>
          <button type="button" @click="mounted=!mounted">
            Remove or restore locally
          </button>
          <button
            type="button"
            @click="direction=direction === 'ltr' ? 'rtl' : 'ltr'"
          >Flip direction</button>
          <button
            type="button"
            @click="
              const area=$root.querySelector('#scroll-area-lifecycle-target');
              if (area) {
                area.setAttribute('tabindex','-1');
                area.setAttribute('role','button');
                area.dataset.axis='invalid';
              }
            "
          >Damage then repair owned attributes</button>
          <button
            type="button"
            @click="
              const area=$root.querySelector('#scroll-area-lifecycle-target');
              if (area) area.style.writingMode =
                area.style.writingMode === 'vertical-rl'
                  ? 'horizontal-tb'
                  : 'vertical-rl';
            "
          >Toggle unsupported writing mode</button>
        </div>

        <p>Server step: <output>{{ step }}</output></p>

        <template x-if="mounted">
          <div :dir="direction">
            <c-CScrollArea
              #c-key="root_key"
              id="scroll-area-lifecycle-target"
              axis="both"
              aria_label="Lifecycle audit records"
              style="--cui-scroll-area-max-block-size: 12rem"
              $c-props="{
                onScrollChange:(detail)=>
                  lastOffset=Math.round(detail.blockOffset),
              }"
            >
              <div class="scroll-area-lifecycle__content">
                <p>Server generation {{ step }}</p>
                <p>Scroll before using a server action.</p>
                <p>A retained root preserves its logical position and focus.</p>
                <p>A replacement root starts with native browser position.</p>
                <p>Removal cancels pending callback and observer work.</p>
                <p>Restoration creates a fresh local instance.</p>
                <p>Nested content focus is never redirected.</p>
                <p>The viewport remains useful without JavaScript.</p>
              </div>
            </c-CScrollArea>
          </div>
        </template>

        <output x-text="`Last user scroll offset ${lastOffset}`">
          Last user scroll offset 0
        </output>
      </section>
    """

    css = """
      :where(.scroll-area-lifecycle) {
        display: grid;
        gap: 1rem;
        max-inline-size: 40rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.scroll-area-lifecycle__controls) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
      }

      :where(.scroll-area-lifecycle p) {
        margin: 0;
      }

      :where(.scroll-area-lifecycle__content) {
        display: grid;
        grid-template-columns: repeat(2, minmax(16rem, 1fr));
        gap: 1rem;
        inline-size: 42rem;
        min-block-size: 24rem;
        padding: 1rem;
      }

      :where(.scroll-area-lifecycle__content p) {
        padding: 0.75rem;
        border-radius: 0.5rem;
        background: color-mix(in srgb, Highlight 10%, Canvas);
      }
    """


preview = ScrollAreaLifecycle()

preview  # noqa: B018

A replacement root, even with the same authored ID, starts with native browser position. Removal cancels pending callbacks and lifecycle work. Restoring a new root does not inherit the removed instance's offsets or focus.

Keep the native fallback useful

Without JavaScript, server output is already one focusable native viewport with its configured axis, standard scrollbar, gutter, overscroll, colors, and slot content. A supplied name already emits the region and naming attribute. Client enhancement only adds reactive configuration, normalized callbacks, direction repair, and retained-root lifecycle behavior.

Treat root attributes as trusted configuration

class_, style, and attrs all target the native viewport. attrs accepts ordinary descriptive attributes, dir, language hints, nonreserved data-*, and native Alpine event listeners that respect the isolated scope boundary. It rejects values that replace the root ID, role, focusability, region name, part marker, reflected state, lifecycle, or owned scrolling policy.

Slotted text and components follow Citry's normal trusted content boundary. ScrollArea does not evaluate content as HTML, URLs, selectors, or Alpine expressions.

API reference

Inputs

CScrollArea server inputs

Server inputs are passed in a template through <c-CScrollArea ... /> or in Python through CScrollArea(...).

InputTypeDefaultEffect
idstr | NonegeneratedSets the native viewport ID.
aria_labelstr | NoneNoneAdds a nonempty direct region name and the region role; mutually exclusive with aria_labelledby.
aria_labelledbystr | NoneNoneAdds a validated IDREF-list region name and the region role; mutually exclusive with aria_label.
axis"block" | "inline" | "both" (CScrollAreaAxis)"block"Selects logical native overflow axes.
scrollbar_width"auto" | "thin" (CScrollAreaScrollbarWidth)"auto"Selects the standard native scrollbar width policy without hiding it.
scrollbar_gutter"auto" | "stable" | "stable-both-edges" (CScrollAreaScrollbarGutter)"auto"Selects standard native scrollbar-space reservation.
overscroll"auto" | "contain" | "none" (CScrollAreaOverscroll)"auto"Selects logical overscroll policy on enabled axes.
class_CClassValue | None (CClassValue)NoneAdds native viewport classes and merges them with attrs.
styleCStyleValue | None (CStyleValue)NoneAdds native viewport styles and merges them with attrs before the owned scrolling policy.
attrsMapping[str, object] | NoneNoneAdds copied allowed descriptive attributes and isolated-scope native listeners that may use event magics, dispatch, stores, or globals.

CScrollArea client inputs

Client inputs are passed in the browser through the $c-props="{ ... }" attribute on <c-CScrollArea />.

InputTypeOmitted behaviorEffect
axis"block" | "inline" | "both" (CScrollAreaAxis)Uses the latest server fallback; null has the same effect.Controls logical overflow axes while valid.
scrollbarWidth"auto" | "thin" (CScrollAreaScrollbarWidth)Uses the latest server fallback; null has the same effect.Controls standard native scrollbar width policy while valid.
scrollbarGutter"auto" | "stable" | "stable-both-edges" (CScrollAreaScrollbarGutter)Uses the latest server fallback; null has the same effect.Controls standard native scrollbar-space reservation while valid.
overscroll"auto" | "contain" | "none" (CScrollAreaOverscroll)Uses the latest server fallback; null has the same effect.Controls logical overscroll policy while valid.
onScrollChangefunctionOmission or null selects no component callback.Receives one event-scoped normalized snapshot for the latest native scroll event in a frame.

Slots

Slots are passed as nested content or <c-fill> tags in a template, or through the slots={...} argument in Python.

CScrollArea slots

SlotRequiredDataFallback
defaultnononeRenders an empty focusable native viewport.

Events

Component events are callback inputs supplied through $c-props. Native browser events remain available through Alpine @... attributes.

CScrollArea events

EventSignatureTrigger and timingDetailControlled and cancellation behavior
onScrollChange(detail: CScrollAreaScrollDetail) => void (CScrollAreaScrollDetail)One or more actual native scroll events occur on the valid initialized viewport.{inlineOffset, blockOffset, source} (CScrollAreaScrollDetail)Runs at most once per animation frame with the latest event. Return values do not cancel native scrolling; controlled state does not exist.

Methods

-

CSS

CSS variables to theme the components. Set them on an ancestor or the component itself.

CScrollArea CSS variables

Apply these variables to CScrollArea or one of its ancestors.

VariableTypePurposeDefault
--cui-scroll-area-max-block-sizelength or noneMaximum block size for block and both-axis viewports.20rem
--cui-scroll-area-backgroundcolorNative viewport background.Canvas
--cui-scroll-area-foregroundcolorInherited viewport foreground.CanvasText
--cui-scroll-area-border-colorcolorNative viewport border.color-mix(in srgb, currentColor 24%, transparent)
--cui-scroll-area-border-widthlengthNative viewport border width.1px
--cui-scroll-area-radiuslengthNative viewport corner radius.0.75rem
--cui-scroll-area-paddinglengthContent inset inside the native viewport.0px
--cui-scroll-area-scrollbar-colorcomplete scrollbar-color valueStandard native thumb and track colors as one property value.auto
--cui-scroll-area-focus-colorcolorViewport focus-visible ring.#2563eb
--cui-scroll-area-scroll-paddinglengthNative focus and anchor scroll padding.0px

Attributes

HTML attributes defined on the components that you can refer to for CSS, inspection, and testing. Read-only.

CScrollArea attributes

AttributeElementTypeMeaning
idNative viewport divsupplied or generated stringIdentifies the single component root and viewport.
tabindexNative viewport div"0"Places the viewport in sequential keyboard focus order.
roleNative viewport divabsent | "region"Present only when exactly one naming input is supplied.
aria-labelNative viewport divstring | absentSupplies the direct region name only when aria_label is used.
aria-labelledbyNative viewport divIDREF list | absentSupplies the referenced region name only when aria_labelledby is used.
data-axisNative viewport div"block" | "inline" | "both" (CScrollAreaAxis)Mirrors the effective logical axis policy.
data-scrollbar-widthNative viewport div"auto" | "thin" (CScrollAreaScrollbarWidth)Mirrors the effective standard scrollbar width policy.
data-scrollbar-gutterNative viewport div"auto" | "stable" | "stable-both-edges" (CScrollAreaScrollbarGutter)Mirrors the effective standard gutter policy.
data-overscrollNative viewport div"auto" | "contain" | "none" (CScrollAreaOverscroll)Mirrors the effective logical overscroll policy.

Selectors

Selectors for the DOM nodes in the components that you can use for CSS, inspection, and testing.

CScrollArea selectors

SelectorElementPurpose
[data-citry-ui-part="scroll-area"]Native viewport divThe focusable scroll viewport and class_, style, and attrs destination.

Interfaces

Aliases and data shapes referenced above.

Input type aliases

InterfaceDefinition
CClassValuestr | Mapping[str, bool] | Sequence[CClassValue]
CStyleValuestr | Mapping[str, object] | Sequence[CStyleValue]
CScrollAreaAxisLiteral["block", "inline", "both"]
CScrollAreaScrollbarWidthLiteral["auto", "thin"]
CScrollAreaScrollbarGutterLiteral["auto", "stable", "stable-both-edges"]
CScrollAreaOverscrollLiteral["auto", "contain", "none"]

CScrollAreaScrollDetail

FieldTypeDefaultMeaning
inlineOffsetfloat-Logical horizontal distance from inline start, clamped to the current native range.
blockOffsetfloat-Vertical distance from the top, clamped to the current native range.
sourceEvent-Latest native scroll event coalesced into this callback frame.

Translation keys

-