use_scroll_area
use_scroll_area scrolls a box with the keys, the wheel and controls,
and leaves drawing it to the component that calls it.
It makes the component focusable, so Tab reaches it and the keys scroll it,
gives the box the style that makes it a scroll container,
and reports how far it's scrolled, for a "jump to latest" button or a position readout.
For a ready-made box, use scroll_area, which is built on this hook.
This page covers the hook and that component. Scrolling covers the pieces they're built from, for a box that needs something they don't do.
Using the hook
In a browser, a scroll container is keyboard-focusable:
Firefox puts every one in the Tab order,
Chrome only one that has nothing inside it that can take focus,
and while it's focused, the arrows, PageUp, PageDown, Space, Home and End scroll it.
Radix UI's ScrollArea
gives a React component the same behavior and leaves drawing the box to it.
use_scroll_area does the same for a component.
It puts the area in the Tab order whatever is inside it, as Firefox does,
since a hook can't see which of its descendants are focusable;
pass in_focus_order=False for an area whose children should take Tab instead.
It returns a handler, on_input, for the element the component returns, which is the one that's focused,
and a style and an ElementHandle, viewport, for the element that scrolls.
Those can be one element, or the one that scrolls can be inside the other,
as Radix's viewport is inside its root, with things that stay put beside it.
This log puts its border and title on the element it returns, and its lines in the element that scrolls inside it,
with stick_to_end=True, so it follows its newest line,
and a button that jumps back to it while it's scrolled back:
@component
def deploy_log() -> Div:
area = use_scroll_area(stick_to_end=True, auto_focus=True)
return Div(
style=col
| border
| border_lightrounded
| size(30, 6)
| (border_color("sky", 400) if area.focus.is_focused else border_color("slate", 600)),
# The element that's focused, which keys, and wheel events and presses anywhere inside, reach.
on_input=area.on_input,
children=(
Text(content=" Deploy log ", style=position_absolute | inset_top(-1) | inset_left(1) | clip_to_border),
# The element that scrolls, with the area's style merged after fill(1), which hides overflow.
Div(
style=fill(1) | col | area.style,
handle=area.viewport,
children=tuple(Text(content=f"{i:>2} {event}") for i, event in enumerate(EVENTS, start=1)),
),
# Beside the part that scrolls, so it stays over the last line.
*(
()
if area.is_at_end
else (
Text(
content=" ↓ Jump to latest ",
style=position_absolute
| inset_bottom(0)
| inset_x(0)
| margin_x_auto
| fit_content_width
| text_bg("sky", 800),
on_input=on_click(lambda: area.to_end),
),
)
),
),
)
is_at_end says whether the area is scrolled to its end, which it is while its content fits,
and to_end is the ScrollTo that scrolls it there,
past any end, so it reaches the end of content that grew since the last render.
is_at_start and to_start do the same for the start, and scroll is the
Scroll they're read from, as use_scroll reports it,
for a readout such as "rows 5-8 of 11".
Like use_scroll, they report the previous frame's scroll,
so the button appears a frame after the scroll that should show it.
The handler is on the element the component returns, so a wheel event or a press anywhere inside it reaches it, the button included, and the wheel scrolls the log from over the button too. A press that nothing inside the area claims focuses it, as a click focuses the nearest focusable element around it in a browser, so clicking the log focuses it, and clicking the button jumps without focusing it.
Keys
While the area, or something inside it, is focused, a key that nothing inside it handles bubbles to its handler:
| Key | Scrolls |
|---|---|
| Up, Down | A row up or down |
| Left, Right | A column left or right |
| PageUp, PageDown | A page up or down: 87.5% of the height of the view, as in Chrome |
| Space | A page down |
| Home, End | To the start or the end |
Home and End scroll vertically, as in a browser,
except in an area that only scrolls sideways, which has nothing to scroll vertically,
where they scroll to its first and last columns.
Shift+Space pages up in a browser, but terminals send it as Space, so there's no key for that.
page sets the size of a page, as for scroll_on_keys,
and wheel_lines how far each notch of the wheel scrolls, three lines by default.
A key or a wheel event that would move the area past the end of its range bubbles on,
so once a nested area reaches its end, the one around it scrolls,
as the web chains scrolling from one container to the next.
The handler judges that by the scroll as of the previous render, as is_at_end does,
so an event that arrives in the same frame as a scroll before it is judged by the scroll before that.
Which way it scrolls
axis is "y" for an area that scrolls vertically, the default, "x" for one that scrolls sideways,
or "both".
The style clips the axis the area doesn't scroll,
so a line too long for an area that scrolls vertically doesn't spill over its border or its siblings.
For "y" it hides the overflow, so the area narrows beside its siblings as a box with hidden overflow does;
for "x" it clips the overflow, so the area stays as tall as its content, as a wide code block in a document should.
A browser does much the same: setting only overflow-y: scroll makes overflow-x auto.
Merging the style
The style sets the element's overflow, and its stickiness with stick_to_end, and nothing else.
Merge it after the element's own style, or at least after anything that sets overflow:
the constraint utilities fill, length, percentage and ratio hide their box's overflow,
so area.style | fill(1) doesn't scroll, and fill(1) | area.style does.
Sizing the area
The area's size is the caller's, as a scroll container's is on the web. A scroll container's automatic minimum size is 0 on the axes it scrolls (see Scroll containers), so an area with no size of its own shrinks to fit beside its siblings, and scrolls what doesn't fit. In a column that's already too short for everything in it, such as a document that scrolls, that can leave the area no rows at all. Give it the size you mean:
fill(1)takes the space its siblings leave, as for a pane beside a sidebar or above a prompt.shrink(0) | max_height(8)makes it as tall as its content, up to eight rows, past which it scrolls, even in a column too short for everything in it, as the text area widget does.height(10)orlength(10)fixes it.
The hook doesn't add shrink(0) itself, since it can't tell which of those you mean,
and in a row, shrink(0) keeps an area with no width of its own as wide as its longest line.
Scrolling it from outside
The area scrolls the element with the viewport handle,
so a parent that creates the handle with use_state(ElementHandle) and passes it to the hook
can scroll the area with ScrollTo and ScrollBy,
and read its scroll with use_scroll(handle).
The chat example
passes its transcript's handle to the transcript's scroll area,
so the root jumps the transcript to its end when a message is sent,
and Shift+PageUp and Shift+PageDown page it while the prompt has focus,
which takes PageUp and PageDown itself to move its cursor.
Tab moves focus to the transcript, where PageUp and PageDown scroll it.
Pass a FocusHandle as handle to focus the area from outside
with FocusOn,
and in_focus_order=False to leave it out of Tab's order, as tabindex="-1" does,
for an area that a press or a FocusOn focuses but Tab passes by, such as a page's body.
A styled scroll area
scroll_area puts its children in a bordered box that scrolls,
and takes the hook's parameters, a style merged over its own, for a size,
and styles for its colors.
A browser draws a focus ring around a focused scroll container as an outline, which takes no space;
a terminal draws nothing outside a box, so its border changes color with focus instead,
as the text field widgets' borders do,
and its content stays where it is as focus comes and goes:
SOURCE = (
"def deploy(image: str, steps: tuple[int, ...] = (10, 50, 100)) -> None:",
" for percent in steps:",
" shift_traffic(image, percent)",
)
@component
def panes() -> Div:
return Div(
style=row | gap(1),
on_input=tab_focus,
children=(
scroll_area(
tuple(Text(content=event) for event in EVENTS),
style=size(24, 6),
auto_focus=True,
),
scroll_area(
tuple(Text(content=line) for line in SOURCE),
axis="x",
style=size(24, 6),
),
),
)
Its children are in a column, unless its style says otherwise,
and its style merges over the box's, and the hook's style over both,
so a style such as fill(1) sizes it and it still scrolls.
For a box without a border, pass ScrollAreaStyles(box=default)
with a focused_box that takes no space, such as a background color.
API
counterweight.headless.use_scroll_area
use_scroll_area(
axis: ScrollAxis = "y",
stick_to_end: bool = False,
viewport: ElementHandle | None = None,
wheel_lines: int = 3,
page: float = PAGE_FRACTION,
auto_focus: bool = False,
handle: FocusHandle | None = None,
is_focusable: bool = True,
in_focus_order: bool = True,
) -> ScrollArea
Scrolls a box with the keys, the wheel and controls, leaving how it's drawn to the calling component, as Radix UI's ScrollArea does for the web. The hook makes the calling component focusable, as browsers make a scroll container focusable, so Tab reaches it and the keys scroll it.
The calling component returns the element that's focused, which takes on_input,
and draws the content in the element that scrolls, which takes viewport and style.
They can be one element, or the scrolling one can be inside the other,
with things that stay put while it scrolls, such as a title or a "jump to latest" button, beside it.
| PARAMETER | DESCRIPTION |
|---|---|
axis
|
Which way the area scrolls:
TYPE:
|
stick_to_end
|
Whether the viewport stays scrolled to its end on the area's axes as its content grows, as for a log or a chat (see Following the end).
TYPE:
|
viewport
|
The element handle for the element that scrolls, which a parent creates with
TYPE:
|
wheel_lines
|
How many lines each notch of the wheel scrolls.
TYPE:
|
page
|
How much of the height of the view
TYPE:
|
auto_focus
|
Passed to
TYPE:
|
handle
|
The focus handle passed to
TYPE:
|
is_focusable
|
Passed to
TYPE:
|
in_focus_order
|
Passed to
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
ScrollArea
|
The style and the element handle for the element that scrolls, how far it's scrolled, |
ScrollArea
|
and the handler that scrolls it, for the element the calling component returns. |
| RAISES | DESCRIPTION |
|---|---|
ValueError
|
If |
counterweight.headless.ScrollArea
dataclass
ScrollArea(
style: Style,
viewport: ElementHandle,
scroll: Scroll,
is_at_start: bool,
is_at_end: bool,
to_start: ScrollTo,
to_end: ScrollTo,
focus: Focus,
on_input: InputHandler,
)
What use_scroll_area returns to the calling component.
style
instance-attribute
style: Style
The style that makes the element viewport names a scroll container on the area's axes,
and sticks it to its end on them if stick_to_end was passed,
for the calling component to merge over that element's own style: style=fill(1) | col | area.style.
On the axis it doesn't scroll, it clips the content, so a line too long for an area that scrolls vertically
doesn't spill over its border: with hidden overflow there, so the area narrows beside its siblings,
or for an area that scrolls sideways, with clipped overflow, so it stays as tall as its content.
It MUST be merged after any style that sets overflow,
such as the constraint utilities fill, length, percentage and ratio, which hide it,
or the element doesn't scroll.
It sets nothing else, so the element's size and layout are the caller's.
viewport
instance-attribute
viewport: ElementHandle
The element handle for the calling component to give the element that scrolls:
the one passed to the hook, or one the hook creates.
The area's handler and its controls scroll that element,
and ScrollTo, ScrollBy
and use_scroll reach it through the handle from anywhere.
scroll
instance-attribute
scroll: Scroll
How far the viewport is scrolled, as use_scroll reports it.
is_at_start
instance-attribute
is_at_start: bool
Whether the viewport is scrolled to the start of its content on each of the area's axes, as of scroll.
is_at_end
instance-attribute
is_at_end: bool
Whether the viewport is scrolled to the end of its content on each of the area's axes, as of scroll,
which is also true while the content fits, so a "jump to latest" button can show while it's false.
to_start
instance-attribute
to_start: ScrollTo
The control that scrolls the viewport to the start of its content on the area's axes.
to_end
instance-attribute
to_end: ScrollTo
The control that scrolls the viewport to the end of its content on the area's axes, wherever the end is when it applies, so it reaches the end of content that grew since the last render.
focus
instance-attribute
focus: Focus
Whether the calling component is focused, and its focus handle,
as use_focus returns them.
on_input
instance-attribute
on_input: InputHandler
The handler that scrolls the viewport, for the calling component to put on the element it returns,
which is the one that's focused, and which a wheel event or a press anywhere inside it bubbles to.
For the keys that scroll a page in a browser, it returns a ScrollBy
or ScrollTo for the viewport:
Up and Down by a row, Left and Right by a column,
PageUp, PageDown and Space by a page (see page_down),
and Home and End to the start and the end: vertically, or for an area that only scrolls sideways, sideways.
For the wheel, it scrolls by wheel_lines toward the wheel's direction,
as scroll_on_wheel does, Shift with the vertical wheel included.
It returns None for a key or a wheel event that would move the viewport past the end of its range, as of scroll,
so the event bubbles to the next scroll container out.
For a MouseDown, it returns FocusOn for the component while it's focusable,
so a press that nothing inside the area claims focuses it, as a click focuses the nearest focusable element
around it in a browser.
It returns None for anything else.
counterweight.widgets.scroll_area
scroll_area(
children: tuple[Component | AnyElement, ...],
axis: ScrollAxis = "y",
stick_to_end: bool = False,
style: Style = default,
styles: ScrollAreaStyles = ScrollAreaStyles(),
viewport: ElementHandle | None = None,
wheel_lines: int = 3,
page: float = PAGE_FRACTION,
auto_focus: bool = False,
handle: FocusHandle | None = None,
is_focusable: bool = True,
in_focus_order: bool = True,
) -> Div
A bordered box that scrolls its children, built on use_scroll_area,
which takes axis, stick_to_end, viewport, wheel_lines, page and the focus parameters,
handle being a focus handle.
The keys scroll it while it, or something inside it, is focused, a press on it focuses it,
and the wheel scrolls it, and its border shows whether it's focused.
Its children are laid out in a column, unless style says otherwise.
| PARAMETER | DESCRIPTION |
|---|---|
children
|
What scrolls. |
style
|
Merged over the box's style from
TYPE:
|
styles
|
How the box is drawn.
TYPE:
|
counterweight.widgets.ScrollAreaStyles
dataclass
ScrollAreaStyles(
*,
box: Style = border
| border_lightrounded
| border_color("slate", 600)
| pad_x(1),
focused_box: Style = border_color("indigo", 400),
)
How scroll_area draws its box.
The box is styled by box, then the style passed to the widget, then focused_box while it's focused,
as TextFieldStyles styles a field's box.
A browser draws a focus ring around a focused scroll container as an outline, which takes no space;
a terminal has nothing drawn outside a box, so the box has a border whose color shows focus instead,
which keeps its content where it is as focus comes and goes.
For a box without a border, pass box=default with a focused_box that takes no space,
such as a background color.
box
class-attribute
instance-attribute
box: Style = (
border
| border_lightrounded
| border_color("slate", 600)
| pad_x(1)
)
The box around the content.
focused_box
class-attribute
instance-attribute
focused_box: Style = border_color('indigo', 400)
Merged over box and the widget's style while the widget is focused.