Lists and Scrolling

July 10, 2026 · View on GitHub

For displaying collections of things, and for making content areas scrollable.


List — Vertical Scrollable List

The standard list. Only draws the items currently visible on screen, so 10,000 items is fine.

type UI struct {
    scroll proton.Scrollable
}

items := []string{"Apples", "Bananas", "Cherries", "Durian (why)"}

proton.List(ctx, &u.scroll, len(items), func(ctx proton.Context, i int) {
    proton.Label(ctx, items[i])
})

The callback receives the index i. Draw whatever you want for each row.

proton.List(ctx proton.Context, state *proton.Scrollable, length int, draw func(proton.Context, int))

Declare one proton.Scrollable per list. It tracks the scroll position. Don't share one between two lists — they'll fight over scroll position and both lose.


HList — Horizontal Scrollable List

Same as List but items go left to right.

proton.HList(ctx, &u.hscroll, len(items), func(ctx proton.Context, i int) {
    proton.PadH(ctx, 8, func(ctx proton.Context) {
        proton.Label(ctx, items[i])
    })
})
proton.HList(ctx proton.Context, state *proton.Scrollable, length int, draw func(proton.Context, int))

Scroll — Scrollable Content Area

For arbitrary content that might overflow, not indexed items. The content function can call as many widgets as it wants.

type UI struct {
    scroll proton.Scrollable
}

proton.Scroll(ctx, &u.scroll, func(ctx proton.Context) {
    proton.H5(ctx, "A very long page")
    proton.Gap(ctx, 8)
    proton.Label(ctx, "Paragraph one...")
    proton.Gap(ctx, 8)
    proton.Label(ctx, "Paragraph two...")
    proton.Gap(ctx, 8)
    // as many widgets as you need
})
proton.Scroll(ctx proton.Context, state *proton.Scrollable, content func(proton.Context))

Use List when you have indexed data. Use Scroll for a page of mixed content.


TextView — Read-Only Scrollable Text

Displays a large block of text in a scrollable, monospace view. Good for file contents, help text, previewing code.

type UI struct {
    scroll proton.Scrollable
}

proton.TextView(ctx, &u.scroll, longText)
proton.TextView(ctx proton.Context, state *proton.Scrollable, text string)

The text is split on newlines and each line is a virtual list item, so it handles very long documents without issue.


LogView — Auto-Scrolling Log Output

Like TextView but auto-scrolls to the bottom whenever new content is added. Color-codes common log prefixes automatically.

type UI struct {
    logScroll proton.Scrollable
    logText   string
}

// append to logText from anywhere
u.logText += fmt.Sprintf("[OK] Step completed at %s\n", time.Now().Format("15:04:05"))

// draw it — auto-scrolls to the latest line
proton.LogView(ctx, &u.logScroll, u.logText)
proton.LogView(ctx proton.Context, state *proton.Scrollable, text string)

Color coding happens automatically based on line prefix:

PrefixColor
[OK], DONE, SUCCESSGreen
[WARN], WARNYellow
[ERROR], ERRORRed
Anything elseMuted

Making List Rows Look Good

A bare proton.Label in a list row works but doesn't look great. Add some padding and structure.

Padded rows

proton.List(ctx, &u.scroll, len(items), func(ctx proton.Context, i int) {
    proton.PadV(ctx, 8, func(ctx proton.Context) {
        proton.PadH(ctx, 12, func(ctx proton.Context) {
            proton.Label(ctx, items[i].Name)
            proton.Gap(ctx, 2)
            proton.Muted(ctx, items[i].Description)
        })
    })
    proton.Divider(ctx)
})

Two columns of text

type Contact struct {
    Name  string
    Email string
}

proton.List(ctx, &u.scroll, len(contacts), func(ctx proton.Context, i int) {
    c := contacts[i]
    proton.PadV(ctx, 10, func(ctx proton.Context) {
        proton.PadH(ctx, 12, func(ctx proton.Context) {
            proton.Label(ctx, c.Name)
            proton.Gap(ctx, 3)
            proton.Muted(ctx, c.Email)
        })
    })
    proton.Divider(ctx)
})

Clickable rows with hover highlight

type UI struct {
    rows     [256]proton.Clickable
    selected int
    scroll   proton.Scrollable
}

proton.List(ctx, &u.scroll, len(items), func(ctx proton.Context, i int) {
    bg  := proton.RGB(0x2e3440)
    hov := proton.RGB(0x3b4252)
    if u.selected == i {
        bg  = proton.RGB(0x4c566a)
        hov = bg
    }
    proton.PadV(ctx, 2, func(ctx proton.Context) {
        if proton.HoverCard(ctx, &u.rows[i], bg, hov, 6, func(ctx proton.Context) {
            proton.PadV(ctx, 10, func(ctx proton.Context) {
                proton.PadH(ctx, 12, func(ctx proton.Context) {
                    proton.Label(ctx, items[i].Name)
                    proton.Gap(ctx, 2)
                    proton.Muted(ctx, items[i].Sub)
                })
            })
        }) {
            u.selected = i
        }
    })
})

List inside a Card

proton.Card(ctx, proton.RGB(0x1e1e2e), 10, 0, func(ctx proton.Context) {
    proton.List(ctx, &u.scroll, len(items), func(ctx proton.Context, i int) {
        proton.PadV(ctx, 8, func(ctx proton.Context) {
            proton.PadH(ctx, 12, func(ctx proton.Context) {
                proton.Label(ctx, items[i])
            })
        })
        if i < len(items)-1 {
            proton.Divider(ctx)
        }
    })
})

Performance

List and HList use virtual rendering — only visible items get their draw function called. A slice of 50,000 items scrolls at 60fps without breaking a sweat.

Scroll renders everything in the content function every frame. Use it for pages with a reasonable number of widgets, not for huge dynamic datasets.