RFC-005: Axis-Based Internal Model #
Status: Implemented (Phase 1)
Author: floor
Type: API Design
Created: 2026-05-27
Updated: 2026-05-27
Related: RFC-002 Core Architecture
Discussion: #84
Summary #
Introduce an AxisConfig object as the internal model for layout geometry. The existing orientation config property is kept for backward compatibility and user ergonomics — it is resolved into AxisConfig internally. The grid() plugin sets the cross axis when present. This unifies the mental model while preserving the current public API.
interface AxisConfig {
primary: 'x' | 'y';
cross?: 'x' | 'y';
}
{ primary: 'y' }— vertical list (default){ primary: 'x' }— horizontal list{ primary: 'y', cross: 'x' }— vertical grid (adds cross-axis layout dimension){ primary: 'x', cross: 'y' }— horizontal grid
Key distinction: orientation and direction are separate concepts:
orientation— static config: which axis the list is laid out on ('vertical' | 'horizontal')direction— runtime state: which way the user is currently scrolling (emitted in scroll events,1 | -1 | 0internally)
This RFC changes how orientation is resolved internally, and makes direction axis-aware — reporting "left" | "right" for horizontal lists instead of the current "up" | "down".
Scope #
This RFC is scoped to Phase 1: internal model + scroll direction fix. No public API changes.
| Phase | Scope | Status |
|---|---|---|
| Phase 1 (this RFC) | Internal AxisConfig on ResolvedConfig, derive hasCrossAxis, remove horizontal boolean, fix scroll event direction labels |
Implemented |
| Phase 2 (separate RFC) | Whether/how grid config gets a shorthand on CreateVListConfig |
Declined — see note below |
| Phase 3 (future) | z-axis / zoom vocabulary — only when a zoom plugin motivates it |
Deferred |
Phase 2 disposition: Declined. The v2 API (createVList(config, [grid({ columns: 3 })])) is already a single explicit call — there is no builder ceremony to shortcut around. Promoting grid config to CreateVListConfig would either break plugin isolation (core knows about grid) or require auto-detection that obscures what's happening. The original deferral reasons still hold: gap precedence confusion with item.gap, asymmetry with masonry/table column semantics, and no user demand. If users want shorter syntax, the right place is framework adapters — React/Vue/Svelte/Solid wrappers already translate convenience props into plugin arrays. A <VList grid={3} scrollbar /> prop is natural in JSX/templates without polluting the core config. The core stays explicit; each adapter provides the DX its community expects.
Motivation #
Current API #
The v2 API uses two separate mechanisms for layout geometry:
// Vertical list (default)
createVList({ orientation: 'vertical', ... })
// Horizontal list
createVList({ orientation: 'horizontal', ... })
// Grid — entirely separate plugin with its own config
createVList({ ... }, [grid({ columns: 4, gap: 16 })])
Problems:
- Grid is a separate concept — but conceptually it's just "add a cross-axis layout dimension"
horizontal: booleanappears in resolved config, disconnected from theorientationstring the user passed- No unified model —
orientationandgrid()exist on different planes with no shared abstraction
Proposed Internal Model #
Internally, all layout geometry resolves to an AxisConfig object:
| User writes | Resolves to |
|---|---|
orientation: 'vertical' (default) |
{ primary: 'y' } |
orientation: 'horizontal' |
{ primary: 'x' } |
grid({ columns: 4 }) plugin |
{ primary: 'y', cross: 'x' } |
orientation: 'horizontal' + grid() |
{ primary: 'x', cross: 'y' } |
The mental model: axis names exactly what you're laying out.
Design #
Public API (unchanged) #
orientation stays as the user-facing config property. No new fields are added to CreateVListConfig — grid configuration stays exclusively on the grid() plugin.
interface CreateVListConfig<T> {
orientation?: 'vertical' | 'horizontal';
// ... all other fields unchanged
}
Why no top-level columns / gap:
gapalready lives underitem.gapin the current config. A top-levelgapwould create three-way precedence confusion (top-level vsitem.gapvs plugin gap).- Grid, masonry, and table all have their own column semantics. Promoting
columnsto core creates an asymmetry where grid is half-core/half-plugin while others stay fully plugin. - This decision may be revisited in Phase 2 via a nested
grid: { columns, gap }key — but that's a separate conversation.
Resolved Config (internal) #
Internally, the resolved config adds axis and hasCrossAxis. The horizontal boolean is not kept — all code derives axis information from config.axis.primary via a local const isX = config.axis.primary === "x":
type Axis = 'x' | 'y';
interface AxisConfig {
readonly primary: Axis;
readonly cross?: Axis;
}
interface ResolvedConfig {
readonly axis: AxisConfig;
readonly hasCrossAxis: boolean; // axis.cross !== undefined
readonly overscan: number;
readonly reverse: boolean;
readonly classPrefix: string;
readonly interactive: boolean;
// ... padding, striped, gap fields unchanged
}
Resolution logic:
function resolveAxis(
orientation: 'vertical' | 'horizontal' | undefined,
plugins: VListPlugin[],
): AxisConfig {
const primary: Axis = orientation === 'horizontal' ? 'x' : 'y';
const hasGridPlugin = plugins.some(p => p.name === 'grid');
if (hasGridPlugin) {
const cross: Axis = primary === 'x' ? 'y' : 'x';
return { primary, cross };
}
return { primary };
}
function resolveConfig(raw: CreateVListConfig, plugins: VListPlugin[]): ResolvedConfig {
const axis = resolveAxis(raw.orientation, plugins);
return {
axis,
hasCrossAxis: axis.cross !== undefined,
// ...other fields unchanged
};
}
Consumers derive axis booleans locally where needed:
const isX = config.axis.primary === "x";
Why hasCrossAxis over isGrid: The field describes what it means (a cross-axis layout dimension exists), not where it comes from (the grid plugin). This is more accurate — if a future plugin also sets a cross axis, the name still holds.
Why no horizontal shorthand: An earlier draft kept horizontal: boolean on ResolvedConfig as a hot-path shorthand. During implementation, this was removed — having both axis and horizontal creates two sources of truth for the same concept. The isX local variable pattern is just as fast (the JIT inlines it) and eliminates the redundancy.
Why a named object over an array: An earlier draft used axis: ['x', 'y'] with positional semantics (primary first). Reviewers correctly identified this as error-prone — reading axis[0] vs axis.primary in plugin code is a meaningful clarity difference. The named shape is self-documenting and eliminates ordering bugs.
Grid Plugin Interaction #
The grid() plugin adds a cross-axis layout dimension when present. Grid configuration stays on the plugin:
// Grid via plugin — the only way to get a grid
createVList({ ... }, [grid({ columns: 4, gap: 16 })])
// Horizontal grid
createVList({ orientation: 'horizontal', ... }, [grid({ columns: 3, gap: 12 })])
The grid() plugin does not "virtualize both axes" — it virtualizes rows along the primary scroll axis and lays out fixed cross-axis divisions. The cross axis in AxisConfig represents a layout dimension, not independent viewporting.
What About Masonry and Table? #
Masonry uses columns and gap, but its layout algorithm (shortest-lane packing) is fundamentally different from grid (uniform rows). It stays a plugin on a single axis — it distributes items across lanes but the scroll is still 1D:
createVList({ ... }, [masonry({ columns: 3, gap: 12 })])
Masonry does not set the cross-axis because it doesn't add a cross-axis layout dimension.
Table has its own column model (resizable, typed columns with headers). It stays a plugin on a single axis:
createVList({ ... }, [table({ columns: tableColumns })])
Compatibility Restrictions #
| Combination | Allowed? | Notes |
|---|---|---|
| Default | Yes | Vertical list ({ primary: 'y' }) |
orientation: 'horizontal' |
Yes | Horizontal list ({ primary: 'x' }) |
grid() plugin |
Yes | Grid ({ primary: 'y', cross: 'x' }) |
Grid + groups() |
Yes | Grouped grid |
Grid + masonry() |
No | Masonry has its own layout |
Grid + table() |
No | Table has its own layout |
orientation: 'horizontal' + groups() |
No | Groups require vertical |
orientation: 'horizontal' + reverse: true |
No | Reverse requires vertical |
Item Size Resolution #
The height / width naming on item config stays — it maps to physical dimensions:
// Vertical list — height is main axis size
createVList({ item: { height: 48, ... } })
// Horizontal list — width is main axis size
createVList({ orientation: 'horizontal', item: { width: 200, height: 400, ... } })
// Grid — height is row size, columns from plugin
createVList({ item: { height: 200, ... } }, [grid({ columns: 4 })])
Axis-Aware Direction #
Currently, the scroll event always reports direction: "up" | "down" regardless of orientation. This is semantically incorrect for horizontal lists. With the axis model, direction maps to the active axis:
Direction by Axis #
| Axis | 1 (forward) |
-1 (backward) |
|---|---|---|
y |
"down" |
"up" |
x |
"right" |
"left" |
Scroll Event Type #
// Current
scroll: { scrollPosition: number; direction: "up" | "down" }
// Proposed
scroll: { scrollPosition: number; direction: "up" | "down" | "left" | "right" }
Implementation #
The internal scrollDirection stays as 1 | -1 | 0. Only the emitted event label changes based on the primary axis:
const isX = config.axis.primary === "x";
function emitScrollEvents(): void {
_scrollEvt.scrollPosition = state.scrollPosition;
if (isX) {
_scrollEvt.direction = state.scrollDirection > 0 ? "right" : "left";
} else {
_scrollEvt.direction = state.scrollDirection > 0 ? "down" : "up";
}
emitter.emit("scroll", _scrollEvt);
}
Affected Modules #
src/core/create.ts—emitScrollEvents()mapsscrollDirectionto the correct labelsrc/types.ts—VListEvents.scroll.directiontype widens to"up" | "down" | "left" | "right"src/plugins/scrollbar/controller.ts—ScrollDirectiontype updated
Internal consumers that use the numeric scrollDirection (1 | -1 | 0) on EngineState are unaffected — only the string label in the public event changes.
AfterScrollHook #
The internal AfterScrollHook signature stays numeric:
type AfterScrollHook = (scrollPosition: number, direction: number) => void;
Plugins that need the label can derive it from config.axis.primary + the numeric direction. No hook signature change needed.
Future: The Z-Axis (Zoom) #
Note: This section is aspirational context only. Z-axis support is deferred to a future RFC and will only be pursued when a concrete zoom plugin motivates it.
The axis model could extend to a z-axis representing zoom/scale:
| Axis | Input | Direction |
|---|---|---|
x |
Horizontal scroll | "left" / "right" |
y |
Vertical scroll | "up" / "down" |
z |
Pinch / scroll wheel + modifier | "in" / "out" |
Use cases: photo gallery zoom, semantic zoom, timeline zoom, maps-style LOD.
The current Axis type is intentionally limited to 'x' | 'y'. Widening to include 'z' would happen in a separate RFC alongside the zoom plugin implementation.
Migration #
From v2-current to v2-axis (Phase 1) #
No breaking changes for users — orientation is preserved, no new config fields.
The only user-visible change is the scroll event direction label: horizontal lists will correctly report "left" / "right" instead of "up" / "down". This is a bug fix, not a breaking change — any code that was checking direction === "down" on a horizontal list was already getting incorrect semantics.
Internal Migration #
The horizontal boolean was fully removed from ResolvedConfig and all internal code. Every reference was replaced with config.axis.primary === "x" (aliased as isX locally). This affected 37 files (+347/−304 lines):
src/core/types.ts— AddAxis,AxisConfigtypes; addaxisandhasCrossAxistoResolvedConfig; removehorizontalsrc/core/create.ts— AddresolveAxis(); fixemitScrollEvents()direction labels; renamehorizontal→isXsrc/core/pipeline.ts— Replacehorizontal: booleanwithsizeProp: "width" | "height"onRenderConfig(pre-resolved at init, zero branching on hot path)src/core/dom.ts— Rename parameterhorizontal→isX(CSS class strings--horizontalandaria-orientation="horizontal"preserved)src/core/scroll.ts— RenameScrollHandlerConfig.horizontal→isXsrc/plugins/*/plugin.ts— All 12 plugins:ctx.config.horizontal→ctx.config.axis.primary === "x"src/rendering/*.ts— Renderer params renamedhorizontal→isXsrc/types.ts— Widen scroll eventdirectiontype to"up" | "down" | "left" | "right"src/plugins/scrollbar/controller.ts— Direction assignments made axis-aware; renamehorizontal→isXsrc/index.ts,src/core/index.ts— ExportAxisandAxisConfigtypes
Open Questions #
ShouldResolved: Neither for Phase 1. Grid config stays on thecolumnsandgaplive on the top-level config or nested under agridkey?grid()plugin. A nested shorthand may be explored in Phase 2.Naming:Resolved: Not applicable to Phase 1 since no top-level config is added. ThecolumnsvscrossCount?grid()plugin keeps its existingcolumnsnaming.Should masonry support
orientation: 'horizontal'for horizontal masonry? Currently masonry always scrolls vertically. This could be a separate follow-up.
Decision #
Phase 1 implemented on branch feat/axis-config (3 commits, 37 files, all 3326 tests pass, typecheck clean).
Key decisions from the review and implementation:
- Named
AxisConfigobject over array-based axis ordering (consensus from all reviewers) - No public config changes in Phase 1 —
columns/gapstay offCreateVListConfigto avoid config pollution and preserve plugin symmetry - "Adds a cross-axis layout dimension" over "virtualizes both axes" — the grid does not independently viewport the cross-axis
- Phased rollout — Phase 1 is internal model + scroll direction fix; public API shorthand deferred to Phase 2
hasCrossAxisoverisGrid— describes the semantic (cross-axis exists) rather than the source (grid plugin). Decided during implementation.- Full
horizontalremoval — the RFC originally proposed keepinghorizontal: booleanas a hot-path shorthand. During implementation, this was dropped in favor of localisXderivation to avoid two sources of truth. No performance cost (JIT inlines the comparison).