Documentation

Lists

LazyColumn and LazyRow draw only the rows on screen. @for inside one becomes items(), not a loop that draws everything up front.

LazyColumn

A vertical list that renders only its visible rows:

<LazyColumn class="gap-12">
  @for (item in items) {
    <Card>{item.name}</Card>
  }
</LazyColumn>

Inside a LazyColumn or LazyRow, @for compiles to items(), and each direct child becomes an item { }.

LazyRow

A horizontal list, the same shape:

<LazyRow class="gap-8">
  @for (category in categories) {
    <Card class="w-120">
      <Image src={category.icon} />
      <Text>{category.name}</Text>
    </Card>
  }
</LazyRow>

key

key is part of @for, not a prop on the child element. It gives each row an identity so its state moves with it when the list reorders; without one, state stays with the position:

@for (item in items, key = { it.id }) {
  <Card>{item.name}</Card>
}

Empty state

empty { } runs when the collection has nothing in it:

<LazyColumn>
  @for (item in items, key = { it.id }) {
    <Card>{item.name}</Card>
  } empty {
    <Column class="w-full h-full items-center justify-center gap-8">
      <Icon name="lucide:inbox" size={48} color="muted-foreground" />
      <Text class="text-muted-foreground">No items yet</Text>
    </Column>
  }
</LazyColumn>

See it happen

Switch between these to see identity and the empty state added one step at a time, compiled live.

ItemList.wh
data class Item(val id: String, val name: String)

var items: List<Item> = $state(listOf())

<LazyColumn class="gap-12">
  @for (item in items) {
    <Card><Text>{item.name}</Text></Card>
  }
</LazyColumn>
Kotlin waiting
Scroll into view to compile.

@for repeats one Card per item, inside a LazyColumn so only the visible rows are drawn.

Item animation

Use animate:flip for reordering, on a keyed row:

<LazyColumn class="gap-8">
  @for (item in items, key = { it.id }) {
    <Card animate:flip={{ duration: 200 }}>
      <Text>{item.name}</Text>
    </Card>
  }
</LazyColumn>

Examples

News feed

data class Article(val id: String, val title: String, val summary: String, val image: String)

var articles: List<Article> = $state(listOf())

$onMount { articles = $fetch("https://api.example.com/articles") }

<LazyColumn class="gap-16 p-16">
  @for (article in articles, key = { it.id }) {
    <Card onClick={() => $navigate("/article/${article.id}")}>
      <Image src={article.image} class="h-200" />
      <Column class="p-16 gap-8">
        <Text class="text-lg font-bold">{article.title}</Text>
        <Text class="text-sm text-muted-foreground">{article.summary}</Text>
      </Column>
    </Card>
  }
</LazyColumn>

Horizontal category list

<Column class="gap-16">
  <Text class="text-xl font-bold px-16">Categories</Text>

  <LazyRow class="gap-12 px-16">
    @for (category in categories, key = { it.id }) {
      <Card class="w-100" onClick={() => selectCategory(category.id)}>
        <Image src={category.icon} class="w-100 h-100" />
        <Text class="text-xs text-center p-8">{category.name}</Text>
      </Card>
    }
  </LazyRow>
</Column>

Contacts with headers

A sticky section header is markup ahead of the emitted items — there is no sticky prop:

data class Group(val letter: String, val contacts: List<Contact>)

var contacts: List<Group> = $state(groupContactsByLetter())

<LazyColumn>
  @for (group in contacts, key = { it.letter }) {
    <Text class="font-bold bg-muted px-16 py-8">{group.letter}</Text>

    @for (contact in group.contacts, key = { it.id }) {
      <Row class="gap-12 p-16 items-center" onClick={() => $navigate("/contact/${contact.id}")}>
        <Image src={contact.avatar} class="w-48 h-48 rounded-full" />
        <Column>
          <Text>{contact.name}</Text>
          <Text class="text-sm text-muted-foreground">{contact.phone}</Text>
        </Column>
      </Row>
    }
  }
</LazyColumn>

Search results

data class Result(val id: String, val title: String, val description: String, val thumbnail: String)

var query = $state("")
var results: List<Result> = $state(listOf())

suspend fun search() {
  results = $fetch.get(url = "https://api.example.com/search", params = { q: query })
}

<Column class="w-full h-full">
  <TextField bind:value={query} placeholder="Search..." />

  <LazyColumn class="gap-12 p-16">
    @for (result in results, key = { it.id }) {
      <Card onClick={() => $navigate("/item/${result.id}")}>
        <Row class="gap-12 p-12">
          <Image src={result.thumbnail} class="w-60 h-60" />
          <Column class="flex-1">
            <Text class="font-medium">{result.title}</Text>
            <Text class="text-sm text-muted-foreground">{result.description}</Text>
          </Column>
        </Row>
      </Card>
    } empty {
      <Column class="w-full items-center pt-60 gap-8">
        <Icon name="lucide:search-x" size={48} color="muted-foreground" />
        <Text class="text-muted-foreground">No results found</Text>
      </Column>
    }
  </LazyColumn>
</Column>

Performance

  • Give @for a key whenever rows carry their own state or an animation moves them
  • Keep row layouts simple for smooth scrolling
  • Avoid heavy computation inside the loop body — compute it once, above the list

See also

  • Layout — Column, Row, Box
  • Classes — the Tailwind-style class table