# Search Reveal

A scrolling list with a search field tucked above it that appears when the user scrolls up.

Search Reveal is an open-source interaction from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/interactions/search-reveal

**React**

**Example**

```tsx
import * as React from "react"
import {
  SearchReveal,
  SearchRevealContent,
  SearchRevealField,
} from "@/components/interactions/search-reveal"

const allNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry" },
  { id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday" },
  {
    id: 8,
    title: "Movies to watch",
    preview: "Two documentaries and a classic",
  },
  { id: 9, title: "Garden", preview: "Plant tomatoes after the frost" },
  { id: 10, title: "Packing list", preview: "Charger, passport, rain jacket" },
  { id: 11, title: "Podcast notes", preview: "Episode about city design" },
  { id: 12, title: "Budget", preview: "Rent, groceries, savings" },
]

function NotesExample({ count }: { count: number }) {
  const [query, setQuery] = React.useState("")
  const notes = allNotes
    .slice(0, count)
    .filter((note) =>
      `${note.title} ${note.preview}`
        .toLowerCase()
        .includes(query.trim().toLowerCase())
    )

  return (
    <div className="mx-auto flex h-[min(36rem,calc(100dvh-4rem))] w-[min(28rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border">
      <h2 className="px-4 pt-4 pb-2 text-3xl font-bold tracking-tight">
        Notes
      </h2>
      <SearchReveal className="min-h-0 flex-1">
        <SearchRevealField value={query} onValueChange={setQuery} />
        <SearchRevealContent>
          {notes.length === 0 ? (
            <p className="px-4 py-16 text-center text-sm text-muted-foreground">
              No results for “{query}”
            </p>
          ) : (
            <ul className="mx-4 overflow-hidden rounded-xl bg-muted">
              {notes.map((note) => (
                <li key={note.id} className="not-first:border-t">
                  <button
                    type="button"
                    className="flex w-full flex-col px-4 py-3 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
                  >
                    <span className="truncate font-semibold">{note.title}</span>
                    <span className="truncate text-sm text-muted-foreground">
                      {note.preview}
                    </span>
                  </button>
                </li>
              ))}
            </ul>
          )}
          <p className="px-4 py-3 text-center text-xs text-muted-foreground">
            {notes.length} {notes.length === 1 ? "note" : "notes"}
          </p>
        </SearchRevealContent>
      </SearchReveal>
    </div>
  )
}

export default function SearchRevealDemo() {
  return <NotesExample count={12} />
}
```

`SearchReveal` recreates the iOS pattern where a search field hides above a list. The container starts scrolled so the field is just out of view; scrolling up reveals it and fades it in. When scrolling stops partway, the container settles to fully shown or fully hidden, and it stays revealed while the field holds a query. Focusing the field scrolls it into view.

`SearchRevealField` is a controlled search input with a magnifying glass and a clear button. Use the pattern for lists such as notes, messages, or contacts where search should be available without taking up space.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/search-reveal
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add interactions search-reveal
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Copy the source into `components/interactions/search-reveal.tsx`:

```tsx title="components/interactions/search-reveal.tsx"
"use client"

import { MagnifyingGlassIcon, XCircleIcon } from "@phosphor-icons/react"
import * as React from "react"
import { cn } from "@/lib/utils"

const SETTLE_DELAY = 140

function scrollBehavior(): ScrollBehavior {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ? "auto"
    : "smooth"
}

function fieldOf(root: HTMLElement | null) {
  return root?.querySelector<HTMLElement>("[data-slot=search-reveal-field]")
}

function SearchReveal({
  className,
  ref: forwardedRef,
  ...props
}: React.ComponentProps<"div">) {
  const ref = React.useRef<HTMLDivElement>(null)

  React.useLayoutEffect(() => {
    const root = ref.current
    const field = fieldOf(root)
    if (!root || !field) return
    const height = () => field.offsetHeight
    const hasQuery = () =>
      Boolean(field.querySelector<HTMLInputElement>("input")?.value)
    let touching = false
    let timer = 0
    let frame = 0

    const update = () => {
      frame = 0
      const reveal = Math.min(Math.max(1 - root.scrollTop / height(), 0), 1)
      root.style.setProperty("--search-reveal", String(reveal))
    }

    const settle = () => {
      const top = root.scrollTop
      const size = height()
      if (touching || top <= 0 || top >= size) return
      root.scrollTo({
        top: top < size / 2 || hasQuery() ? 0 : size,
        behavior: scrollBehavior(),
      })
    }

    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update)
      window.clearTimeout(timer)
      timer = window.setTimeout(settle, SETTLE_DELAY)
    }
    const onTouchStart = () => {
      touching = true
    }
    const onTouchEnd = () => {
      touching = false
      window.clearTimeout(timer)
      timer = window.setTimeout(settle, SETTLE_DELAY)
    }

    root.scrollTop = hasQuery() ? 0 : height()
    update()
    root.addEventListener("scroll", onScroll, { passive: true })
    root.addEventListener("touchstart", onTouchStart, { passive: true })
    root.addEventListener("touchend", onTouchEnd)
    root.addEventListener("touchcancel", onTouchEnd)
    return () => {
      window.clearTimeout(timer)
      cancelAnimationFrame(frame)
      root.removeEventListener("scroll", onScroll)
      root.removeEventListener("touchstart", onTouchStart)
      root.removeEventListener("touchend", onTouchEnd)
      root.removeEventListener("touchcancel", onTouchEnd)
    }
  }, [])

  return (
    <div
      ref={(node) => {
        ref.current = node
        if (typeof forwardedRef === "function") return forwardedRef(node)
        if (forwardedRef) forwardedRef.current = node
      }}
      data-slot="search-reveal"
      className={cn(
        "overflow-y-auto overscroll-y-contain [--search-reveal:0]",
        className
      )}
      {...props}
    />
  )
}

function SearchRevealField({
  value,
  onValueChange,
  placeholder = "Search",
  className,
  onFocus,
  onChange,
  ref,
  ...props
}: Omit<React.ComponentProps<"input">, "value" | "type"> & {
  value: string
  onValueChange: (value: string) => void
}) {
  const inputRef = React.useRef<HTMLInputElement>(null)

  return (
    <div
      data-slot="search-reveal-field"
      className="px-4 pb-2 opacity-(--search-reveal)"
    >
      <div className="flex h-9 items-center gap-1.5 rounded-lg bg-secondary px-2 text-muted-foreground focus-within:ring-3 focus-within:ring-ring/50">
        <MagnifyingGlassIcon aria-hidden className="size-4.5 shrink-0" />
        <input
          ref={(node) => {
            inputRef.current = node
            if (typeof ref === "function") return ref(node)
            if (ref) ref.current = node
          }}
          type="search"
          data-slot="search-reveal-input"
          value={value}
          placeholder={placeholder}
          aria-label={placeholder}
          onChange={(event) => {
            onChange?.(event)
            onValueChange(event.target.value)
          }}
          onFocus={(event) => {
            event.currentTarget
              .closest("[data-slot=search-reveal]")
              ?.scrollTo({ top: 0, behavior: scrollBehavior() })
            onFocus?.(event)
          }}
          className={cn(
            "h-full min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none",
            className
          )}
          {...props}
        />
        {value && (
          <button
            type="button"
            aria-label="Clear search"
            onClick={() => {
              onValueChange("")
              inputRef.current?.focus()
            }}
            className="relative -me-1 flex size-7 shrink-0 items-center justify-center rounded-full outline-none after:absolute after:-inset-2 after:content-[''] focus-visible:ring-3 focus-visible:ring-ring/50"
          >
            <XCircleIcon
              className="size-4.5 fill-muted-foreground text-background"
            />
          </button>
        )}
      </div>
    </div>
  )
}

function SearchRevealContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="search-reveal-content"
      className={cn("min-h-full", className)}
      {...props}
    />
  )
}

export { SearchReveal, SearchRevealContent, SearchRevealField }
```

3. Update the import paths to match your project.

## Usage

```tsx
import { SearchReveal, SearchRevealField, SearchRevealContent } from "@/components/interactions/search-reveal"
```

```tsx
<NotesExample count={12} />
```

- Place `SearchRevealField` first and `SearchRevealContent` after it inside `SearchReveal`, and give the container a bounded height.
- The field is controlled: pass `value` and update it in `onValueChange`. Filter the content from that value.
- `SearchRevealContent` fills at least the container's height, so the field can hide even when the list is short.
- `placeholder` defaults to `Search` and also labels the input.

## Examples

### Short list

A short list still hides the field above it and reveals it on focus.

**Example**

```tsx
import * as React from "react"
import {
  SearchReveal,
  SearchRevealContent,
  SearchRevealField,
} from "@/components/interactions/search-reveal"

const allNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry" },
  { id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday" },
  {
    id: 8,
    title: "Movies to watch",
    preview: "Two documentaries and a classic",
  },
  { id: 9, title: "Garden", preview: "Plant tomatoes after the frost" },
  { id: 10, title: "Packing list", preview: "Charger, passport, rain jacket" },
  { id: 11, title: "Podcast notes", preview: "Episode about city design" },
  { id: 12, title: "Budget", preview: "Rent, groceries, savings" },
]

function NotesExample({ count }: { count: number }) {
  const [query, setQuery] = React.useState("")
  const notes = allNotes
    .slice(0, count)
    .filter((note) =>
      `${note.title} ${note.preview}`
        .toLowerCase()
        .includes(query.trim().toLowerCase())
    )

  return (
    <div className="mx-auto flex h-[min(36rem,calc(100dvh-4rem))] w-[min(28rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border">
      <h2 className="px-4 pt-4 pb-2 text-3xl font-bold tracking-tight">
        Notes
      </h2>
      <SearchReveal className="min-h-0 flex-1">
        <SearchRevealField value={query} onValueChange={setQuery} />
        <SearchRevealContent>
          {notes.length === 0 ? (
            <p className="px-4 py-16 text-center text-sm text-muted-foreground">
              No results for “{query}”
            </p>
          ) : (
            <ul className="mx-4 overflow-hidden rounded-xl bg-muted">
              {notes.map((note) => (
                <li key={note.id} className="not-first:border-t">
                  <button
                    type="button"
                    className="flex w-full flex-col px-4 py-3 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
                  >
                    <span className="truncate font-semibold">{note.title}</span>
                    <span className="truncate text-sm text-muted-foreground">
                      {note.preview}
                    </span>
                  </button>
                </li>
              ))}
            </ul>
          )}
          <p className="px-4 py-3 text-center text-xs text-muted-foreground">
            {notes.length} {notes.length === 1 ? "note" : "notes"}
          </p>
        </SearchRevealContent>
      </SearchReveal>
    </div>
  )
}

export default function SearchRevealShortList() {
  return <NotesExample count={3} />
}
```

## API Reference

### SearchReveal

The scroll container that hides, reveals, and snaps the search field.

Also accepts the props of `React.ComponentProps<"div">`.

### SearchRevealField

The search input with a clear button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | Required | The current query. |
| `onValueChange` | `(value: string) => void` | Required | Called with the new query when the user types or clears the field. |

Also accepts the props of `React.ComponentProps<"input">`.

### SearchRevealContent

The scrolling content below the field.

Also accepts the props of `React.ComponentProps<"div">`.

## Accessibility

- The input has `type="search"` and is labelled by its placeholder through `aria-label`.
- The clear button is labelled `Clear search` and returns focus to the input.
- Focusing the field, including with the keyboard, scrolls it into view.

## Notes

- Parts use `data-slot` values `search-reveal`, `search-reveal-field`, `search-reveal-input`, and `search-reveal-content`.
- The `--search-reveal` custom property runs from `0` to `1` as the field is revealed and drives its opacity.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/interactions-search-reveal--default.light.webp)

```tsx
import * as React from "react"
import { Pressable, View } from "react-native"
import { SearchReveal, SearchRevealContent, SearchRevealField } from "@/components/interactions/search-reveal"
import { Text } from "@/components/ui/text"

const allNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry" },
  { id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday" },
  {
    id: 8,
    title: "Movies to watch",
    preview: "Two documentaries and a classic",
  },
  { id: 9, title: "Garden", preview: "Plant tomatoes after the frost" },
  { id: 10, title: "Packing list", preview: "Charger, passport, rain jacket" },
  { id: 11, title: "Podcast notes", preview: "Episode about city design" },
  { id: 12, title: "Budget", preview: "Rent, groceries, savings" },
]

function NotesExample({
  count,
  defaultQuery = "",
}: {
  count: number
  defaultQuery?: string
}) {
  const [query, setQuery] = React.useState(defaultQuery)
  const notes = allNotes
    .slice(0, count)
    .filter((note) =>
      `${note.title} ${note.preview}`
        .toLowerCase()
        .includes(query.trim().toLowerCase())
    )

  return (
    <View className="flex-1 overflow-hidden rounded-2xl border border-border">
      <Text variant="h1" className="px-4 pt-4 pb-2">
        Notes
      </Text>
      <SearchReveal>
        <SearchRevealField value={query} onValueChange={setQuery} />
        <SearchRevealContent>
          {notes.length === 0 ? (
            <Text className="px-4 py-16 text-center text-sm text-muted-foreground">
              No results for “{query}”
            </Text>
          ) : (
            <View className="mx-4 overflow-hidden rounded-xl bg-muted">
              {notes.map((note, index) => (
                <Pressable
                  key={note.id}
                  className={
                    index > 0
                      ? "border-t border-border px-4 py-3 active:opacity-80"
                      : "px-4 py-3 active:opacity-80"
                  }
                >
                  <Text numberOfLines={1} className="font-semibold">
                    {note.title}
                  </Text>
                  <Text
                    numberOfLines={1}
                    className="text-sm text-muted-foreground"
                  >
                    {note.preview}
                  </Text>
                </Pressable>
              ))}
            </View>
          )}
          <Text className="px-4 py-3 text-center text-xs text-muted-foreground">
            {notes.length} {notes.length === 1 ? "note" : "notes"}
          </Text>
        </SearchRevealContent>
      </SearchReveal>
    </View>
  )
}

export function SearchRevealExample() {
  return (
    <NotesExample count={12} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchRevealExample: View {
  var body: some View {
    SearchRevealNotes(count: 12)
  }
}

struct SearchRevealNote: Identifiable {
  let id: Int
  let title: String
  let preview: String
}

let searchRevealNotes = [
  SearchRevealNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  SearchRevealNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  SearchRevealNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  SearchRevealNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
  SearchRevealNote(id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
  SearchRevealNote(id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry"),
  SearchRevealNote(id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday"),
  SearchRevealNote(id: 8, title: "Movies to watch", preview: "Two documentaries and a classic"),
  SearchRevealNote(id: 9, title: "Garden", preview: "Plant tomatoes after the frost"),
  SearchRevealNote(id: 10, title: "Packing list", preview: "Charger, passport, rain jacket"),
  SearchRevealNote(id: 11, title: "Podcast notes", preview: "Episode about city design"),
  SearchRevealNote(id: 12, title: "Budget", preview: "Rent, groceries, savings"),
]

struct SearchRevealNotes: View {
  let count: Int

  @State private var query: String

  init(count: Int, query: String = "") {
    self.count = count
    _query = State(initialValue: query)
  }

  var notes: [SearchRevealNote] {
    let needle = query.trimmingCharacters(in: .whitespaces).lowercased()
    return searchRevealNotes.prefix(count).filter { note in
      needle.isEmpty || "\(note.title) \(note.preview)".lowercased().contains(needle)
    }
  }

  var body: some View {
    let notes = notes
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 0) {
      Text("Notes")
        .textVariant(.h1)
        .padding(.horizontal, 16)
        .padding(.top, 16)
        .padding(.bottom, 8)
      SearchReveal {
        SearchRevealField(text: $query)
        SearchRevealContent {
          if notes.isEmpty {
            Text(verbatim: "No results for “\(query)”")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .multilineTextAlignment(.center)
              .padding(.horizontal, 16)
              .padding(.vertical, 64)
              .frame(maxWidth: .infinity)
          } else {
            VStack(spacing: 0) {
              ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
                Button {} label: {
                  VStack(alignment: .leading, spacing: 0) {
                    Text(verbatim: note.title)
                      .textStyle(.base, weight: .semibold)
                      .foregroundStyle(EsuissColor.foreground)
                      .lineLimit(1)
                    Text(verbatim: note.preview)
                      .textStyle(.sm)
                      .foregroundStyle(EsuissColor.mutedForeground)
                      .lineLimit(1)
                  }
                  .padding(.horizontal, 16)
                  .padding(.vertical, 12)
                  .frame(maxWidth: .infinity, alignment: .leading)
                  .contentShape(Rectangle())
                }
                .buttonStyle(SearchRevealRowStyle())
                .overlay(alignment: .top) {
                  if index > 0 {
                    EsuissColor.border.frame(height: 1)
                  }
                }
              }
            }
            .background(EsuissColor.muted)
            .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
            .padding(.horizontal, 16)
          }
          Text(verbatim: "\(notes.count) \(notes.count == 1 ? "note" : "notes")")
            .textStyle(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
            .multilineTextAlignment(.center)
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .frame(maxWidth: .infinity)
        }
      }
    }
    .clipShape(shape)
    .overlay { shape.strokeBorder(EsuissColor.border) }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}

struct SearchRevealRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SearchReveal
import com.esuiss.ui.interactions.SearchRevealContent
import com.esuiss.ui.interactions.SearchRevealField
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun SearchRevealExample() {
    SearchRevealNotes(count = 12)
}

class SearchRevealNote(val id: Int, val title: String, val preview: String)

val searchRevealAllNotes = listOf(
    SearchRevealNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    SearchRevealNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    SearchRevealNote(3, "Book list", "Three novels for the summer"),
    SearchRevealNote(4, "Meeting notes", "Ship the beta on Friday"),
    SearchRevealNote(5, "Gift ideas", "Headphones, a plant, a scarf"),
    SearchRevealNote(6, "Recipes", "Lemon pasta and a quick curry"),
    SearchRevealNote(7, "Workout plan", "Run on Monday, swim on Thursday"),
    SearchRevealNote(8, "Movies to watch", "Two documentaries and a classic"),
    SearchRevealNote(9, "Garden", "Plant tomatoes after the frost"),
    SearchRevealNote(10, "Packing list", "Charger, passport, rain jacket"),
    SearchRevealNote(11, "Podcast notes", "Episode about city design"),
    SearchRevealNote(12, "Budget", "Rent, groceries, savings"),
)

@Composable
fun SearchRevealNotes(count: Int, defaultQuery: String = "") {
    val colors = EsuissTheme.colors
    var query by rememberSaveable { mutableStateOf(defaultQuery) }
    val needle = query.trim().lowercase()
    val notes = searchRevealAllNotes.take(count).filter { "${it.title} ${it.preview}".lowercase().contains(needle) }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(
        Modifier
            .fillMaxWidth()
            .height(600.dp)
            .clip(shape)
            .border(1.dp, colors.border, shape),
    ) {
        Text("Notes", modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), variant = TextVariant.H1)
        SearchReveal(Modifier.weight(1f)) {
            SearchRevealField(value = query, onValueChange = { query = it })
            SearchRevealContent {
                if (notes.isEmpty()) {
                    Text(
                        "No results for “$query”",
                        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 64.dp),
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        textAlign = TextAlign.Center,
                    )
                } else {
                    Column(
                        Modifier
                            .padding(horizontal = 16.dp)
                            .fillMaxWidth()
                            .clip(RoundedCornerShape(Radius.xl))
                            .background(colors.muted),
                    ) {
                        notes.forEachIndexed { index, note ->
                            if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                            SearchRevealRow(note)
                        }
                    }
                }
                Text(
                    "${notes.size} ${if (notes.size == 1) "note" else "notes"}",
                    modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
                    style = Typography.xs.copy(color = colors.mutedForeground),
                    textAlign = TextAlign.Center,
                )
            }
        }
    }
}

@Composable
fun SearchRevealRow(note: SearchRevealNote) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        Modifier
            .fillMaxWidth()
            .alpha(if (pressed) 0.8f else 1f)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button) {}
            .padding(horizontal = 16.dp, vertical = 12.dp),
    ) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = colors.mutedForeground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}
```

`SearchReveal` is a scroll view that starts scrolled just past its `SearchRevealField`, so the list shows first and the field is out of view. Pulling down reveals the field, which fades in as it appears, and the scroll snaps either fully open or fully hidden.

Focusing the field scrolls it into view. While there is a query, snapping is turned off so the field stays visible. Use it for notes, mail or settings lists where search should be close but not take space.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/search-reveal
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add search-reveal
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add search-reveal --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add search-reveal --platform kotlin
```

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon).

3. Copy the source into `components/interactions/search-reveal.tsx`:

```tsx title="uniwind/components/interactions/search-reveal.tsx"
import { cn } from "cn"
import { MagnifyingGlassIcon, XCircleIcon } from "phosphor-react-native"
import * as React from "react"
import {
  type LayoutChangeEvent,
  Pressable,
  type ScrollView,
  type ScrollViewProps,
  TextInput,
  View,
  type ViewProps,
} from "react-native"
import Animated, {
  type SharedValue,
  useAnimatedRef,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
} from "react-native-reanimated"
import { Icon } from "@/components/ui/icon"

type SearchRevealContextProps = {
  offset: SharedValue<number>
  fieldHeight: number
  viewportHeight: number
  setFieldHeight: (height: number) => void
  setHasQuery: (hasQuery: boolean) => void
  reveal: () => void
}

const SearchRevealContext =
  React.createContext<SearchRevealContextProps | null>(null)

function useSearchReveal(componentName: string) {
  const context = React.useContext(SearchRevealContext)
  if (!context) {
    throw new Error(`${componentName} must be used within a <SearchReveal />`)
  }
  return context
}

function SearchReveal({
  className,
  onLayout,
  children,
  ...props
}: Omit<ScrollViewProps, "onScroll">) {
  const scrollRef = useAnimatedRef<Animated.ScrollView>()
  const offset = useSharedValue(0)
  const [fieldHeight, setFieldHeight] = React.useState(0)
  const [viewportHeight, setViewportHeight] = React.useState(0)
  const [hasQuery, setHasQuery] = React.useState(false)
  const positioned = React.useRef(false)

  const onScroll = useAnimatedScrollHandler((event) => {
    offset.value = event.contentOffset.y
  })

  React.useEffect(() => {
    if (positioned.current || fieldHeight <= 0 || viewportHeight <= 0) return
    if (hasQuery) {
      positioned.current = true
      return
    }
    const frame = requestAnimationFrame(() => {
      positioned.current = true
      const scroll = scrollRef.current as React.ComponentRef<
        typeof ScrollView
      > | null
      scroll?.scrollTo({ y: fieldHeight, animated: false })
    })
    return () => cancelAnimationFrame(frame)
  }, [fieldHeight, viewportHeight, hasQuery, scrollRef])

  const reveal = React.useCallback(() => {
    const scroll = scrollRef.current as React.ComponentRef<
      typeof ScrollView
    > | null
    scroll?.scrollTo({ y: 0, animated: true })
  }, [scrollRef])

  const contextValue = React.useMemo(
    () => ({
      offset,
      fieldHeight,
      viewportHeight,
      setFieldHeight,
      setHasQuery,
      reveal,
    }),
    [offset, fieldHeight, viewportHeight, reveal]
  )

  return (
    <SearchRevealContext.Provider value={contextValue}>
      <Animated.ScrollView
        ref={scrollRef}
        onScroll={onScroll}
        scrollEventThrottle={16}
        snapToOffsets={
          fieldHeight > 0 && !hasQuery ? [0, fieldHeight] : undefined
        }
        snapToEnd={false}
        keyboardShouldPersistTaps="handled"
        className={cn("flex-1", className)}
        onLayout={(event: LayoutChangeEvent) => {
          setViewportHeight(event.nativeEvent.layout.height)
          onLayout?.(event)
        }}
        {...props}
      >
        {children}
      </Animated.ScrollView>
    </SearchRevealContext.Provider>
  )
}

function SearchRevealField({
  value,
  onValueChange,
  placeholder = "Search",
  className,
  onFocus,
  ...props
}: Omit<React.ComponentProps<typeof TextInput>, "value" | "onChangeText"> & {
  value: string
  onValueChange: (value: string) => void
}) {
  const { offset, fieldHeight, setFieldHeight, setHasQuery, reveal } =
    useSearchReveal("SearchRevealField")
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)

  React.useEffect(() => setHasQuery(value.length > 0), [value, setHasQuery])

  const revealStyle = useAnimatedStyle(() => {
    if (fieldHeight <= 0) return { opacity: 1 }
    return {
      opacity: Math.min(Math.max(1 - offset.value / fieldHeight, 0), 1),
    }
  }, [fieldHeight])

  return (
    <Animated.View
      className="px-4 pb-2"
      style={revealStyle}
      onLayout={(event) => setFieldHeight(event.nativeEvent.layout.height)}
    >
      <View className="h-9 flex-row items-center gap-1.5 rounded-lg bg-secondary px-2">
        <Icon
          as={MagnifyingGlassIcon}
          className="size-4.5 shrink-0 text-muted-foreground"
        />
        <TextInput
          ref={inputRef}
          role="searchbox"
          value={value}
          placeholder={placeholder}
          accessibilityLabel={placeholder}
          onChangeText={onValueChange}
          onFocus={(event) => {
            reveal()
            onFocus?.(event)
          }}
          autoCorrect={false}
          autoCapitalize="none"
          returnKeyType="search"
          className={cn(
            "h-full min-w-0 flex-1 py-0 text-[16px] text-foreground",
            className
          )}
          placeholderTextColorClassName="accent-muted-foreground"
          cursorColorClassName="accent-primary"
          selectionColorClassName="accent-primary"
          {...props}
        />
        {value ? (
          <Pressable
            role="button"
            accessibilityLabel="Clear search"
            hitSlop={8}
            onPress={() => {
              onValueChange("")
              inputRef.current?.focus()
            }}
            className="-me-1 size-7 shrink-0 items-center justify-center rounded-full"
          >
            <Icon
              as={XCircleIcon}
              weight="fill"
              className="size-4.5 text-muted-foreground"
            />
          </Pressable>
        ) : null}
      </View>
    </Animated.View>
  )
}

function SearchRevealContent({ className, style, ...props }: ViewProps) {
  const { viewportHeight } = useSearchReveal("SearchRevealContent")
  return (
    <View
      className={className}
      style={[{ minHeight: viewportHeight }, style]}
      {...props}
    />
  )
}

export { SearchReveal, SearchRevealContent, SearchRevealField }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn nativewind phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon).

3. Copy the source into `components/interactions/search-reveal.tsx`:

```tsx title="nativewind/components/interactions/search-reveal.tsx"
import { cn } from "cn"
import { cssInterop } from "nativewind"
import { MagnifyingGlassIcon, XCircleIcon } from "phosphor-react-native"
import * as React from "react"
import {
  type LayoutChangeEvent,
  Pressable,
  type ScrollView,
  type ScrollViewProps,
  TextInput,
  View,
  type ViewProps,
} from "react-native"
import Animated, {
  type SharedValue,
  useAnimatedRef,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
} from "react-native-reanimated"
import { Icon } from "@/components/ui/icon"

declare module "react-native" {
  interface TextInputProps {
    cursorColorClassName?: string
    selectionColorClassName?: string
  }
}

cssInterop(TextInput, {
  className: { target: "style", nativeStyleToProp: { textAlign: true } },
  placeholderClassName: {
    target: false,
    nativeStyleToProp: { color: "placeholderTextColor" },
  },
  cursorColorClassName: {
    target: false,
    nativeStyleToProp: { color: "cursorColor" },
  },
  selectionColorClassName: {
    target: false,
    nativeStyleToProp: { color: "selectionColor" },
  },
})

type SearchRevealContextProps = {
  offset: SharedValue<number>
  fieldHeight: number
  viewportHeight: number
  setFieldHeight: (height: number) => void
  setHasQuery: (hasQuery: boolean) => void
  reveal: () => void
}

const SearchRevealContext =
  React.createContext<SearchRevealContextProps | null>(null)

function useSearchReveal(componentName: string) {
  const context = React.useContext(SearchRevealContext)
  if (!context) {
    throw new Error(`${componentName} must be used within a <SearchReveal />`)
  }
  return context
}

function SearchReveal({
  className,
  onLayout,
  children,
  ...props
}: Omit<ScrollViewProps, "onScroll">) {
  const scrollRef = useAnimatedRef<Animated.ScrollView>()
  const offset = useSharedValue(0)
  const [fieldHeight, setFieldHeight] = React.useState(0)
  const [viewportHeight, setViewportHeight] = React.useState(0)
  const [hasQuery, setHasQuery] = React.useState(false)
  const positioned = React.useRef(false)

  const onScroll = useAnimatedScrollHandler((event) => {
    offset.value = event.contentOffset.y
  })

  React.useEffect(() => {
    if (positioned.current || fieldHeight <= 0 || viewportHeight <= 0) return
    if (hasQuery) {
      positioned.current = true
      return
    }
    const frame = requestAnimationFrame(() => {
      positioned.current = true
      const scroll = scrollRef.current as React.ComponentRef<
        typeof ScrollView
      > | null
      scroll?.scrollTo({ y: fieldHeight, animated: false })
    })
    return () => cancelAnimationFrame(frame)
  }, [fieldHeight, viewportHeight, hasQuery, scrollRef])

  const reveal = React.useCallback(() => {
    const scroll = scrollRef.current as React.ComponentRef<
      typeof ScrollView
    > | null
    scroll?.scrollTo({ y: 0, animated: true })
  }, [scrollRef])

  const contextValue = React.useMemo(
    () => ({
      offset,
      fieldHeight,
      viewportHeight,
      setFieldHeight,
      setHasQuery,
      reveal,
    }),
    [offset, fieldHeight, viewportHeight, reveal]
  )

  return (
    <SearchRevealContext.Provider value={contextValue}>
      <Animated.ScrollView
        ref={scrollRef}
        onScroll={onScroll}
        scrollEventThrottle={16}
        snapToOffsets={
          fieldHeight > 0 && !hasQuery ? [0, fieldHeight] : undefined
        }
        snapToEnd={false}
        keyboardShouldPersistTaps="handled"
        className={cn("flex-1", className)}
        onLayout={(event: LayoutChangeEvent) => {
          setViewportHeight(event.nativeEvent.layout.height)
          onLayout?.(event)
        }}
        {...props}
      >
        {children}
      </Animated.ScrollView>
    </SearchRevealContext.Provider>
  )
}

function SearchRevealField({
  value,
  onValueChange,
  placeholder = "Search",
  className,
  onFocus,
  ...props
}: Omit<React.ComponentProps<typeof TextInput>, "value" | "onChangeText"> & {
  value: string
  onValueChange: (value: string) => void
}) {
  const { offset, fieldHeight, setFieldHeight, setHasQuery, reveal } =
    useSearchReveal("SearchRevealField")
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)

  React.useEffect(() => setHasQuery(value.length > 0), [value, setHasQuery])

  const revealStyle = useAnimatedStyle(() => {
    if (fieldHeight <= 0) return { opacity: 1 }
    return {
      opacity: Math.min(Math.max(1 - offset.value / fieldHeight, 0), 1),
    }
  }, [fieldHeight])

  return (
    <Animated.View
      className="px-4 pb-2"
      style={revealStyle}
      onLayout={(event) => setFieldHeight(event.nativeEvent.layout.height)}
    >
      <View className="h-9 flex-row items-center gap-1.5 rounded-lg bg-secondary px-2">
        <Icon
          as={MagnifyingGlassIcon}
          className="size-[1.125rem] shrink-0 text-muted-foreground"
        />
        <TextInput
          ref={inputRef}
          role="searchbox"
          value={value}
          placeholder={placeholder}
          accessibilityLabel={placeholder}
          onChangeText={onValueChange}
          onFocus={(event) => {
            reveal()
            onFocus?.(event)
          }}
          autoCorrect={false}
          autoCapitalize="none"
          returnKeyType="search"
          className={cn(
            "h-full min-w-0 flex-1 py-0 text-[16px] text-foreground",
            className
          )}
          placeholderClassName="text-muted-foreground"
          cursorColorClassName="text-primary"
          selectionColorClassName="text-primary"
          {...props}
        />
        {value ? (
          <Pressable
            role="button"
            accessibilityLabel="Clear search"
            hitSlop={8}
            onPress={() => {
              onValueChange("")
              inputRef.current?.focus()
            }}
            className="-me-1 size-7 shrink-0 items-center justify-center rounded-full"
          >
            <Icon
              as={XCircleIcon}
              weight="fill"
              className="size-[1.125rem] text-muted-foreground"
            />
          </Pressable>
        ) : null}
      </View>
    </Animated.View>
  )
}

function SearchRevealContent({ className, style, ...props }: ViewProps) {
  const { viewportHeight } = useSearchReveal("SearchRevealContent")
  return (
    <View
      className={className}
      style={[{ minHeight: viewportHeight }, style]}
      {...props}
    />
  )
}

export { SearchReveal, SearchRevealContent, SearchRevealField }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon).

3. Copy the source into `components/interactions/search-reveal.tsx`:

```tsx title="stylesheet/components/interactions/search-reveal.tsx"
import { MagnifyingGlassIcon, XCircleIcon } from "phosphor-react-native"
import * as React from "react"
import {
  type LayoutChangeEvent,
  Pressable,
  type ScrollView,
  type ScrollViewProps,
  StyleSheet,
  TextInput,
  View,
  type ViewProps,
} from "react-native"
import Animated, {
  type SharedValue,
  useAnimatedRef,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
} from "react-native-reanimated"
import { Icon } from "@/components/ui/icon"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

const styles = StyleSheet.create({
  root: { flex: 1 },
  field: { paddingHorizontal: 16, paddingBottom: 8 },
  box: {
    height: 36,
    flexDirection: "row",
    alignItems: "center",
    gap: 6,
    borderRadius: radius.lg,
    paddingHorizontal: 8,
  },
  icon: { flexShrink: 0 },
  input: {
    height: "100%",
    minWidth: 0,
    flex: 1,
    paddingVertical: 0,
    fontSize: fontSize.base.fontSize,
  },
  clear: {
    marginEnd: -4,
    width: 28,
    height: 28,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
})

type SearchRevealContextProps = {
  offset: SharedValue<number>
  fieldHeight: number
  viewportHeight: number
  setFieldHeight: (height: number) => void
  setHasQuery: (hasQuery: boolean) => void
  reveal: () => void
}

const SearchRevealContext =
  React.createContext<SearchRevealContextProps | null>(null)

function useSearchReveal(componentName: string) {
  const context = React.useContext(SearchRevealContext)
  if (!context) {
    throw new Error(`${componentName} must be used within a <SearchReveal />`)
  }
  return context
}

function SearchReveal({
  style,
  onLayout,
  children,
  ...props
}: Omit<ScrollViewProps, "onScroll">) {
  const scrollRef = useAnimatedRef<Animated.ScrollView>()
  const offset = useSharedValue(0)
  const [fieldHeight, setFieldHeight] = React.useState(0)
  const [viewportHeight, setViewportHeight] = React.useState(0)
  const [hasQuery, setHasQuery] = React.useState(false)
  const positioned = React.useRef(false)

  const onScroll = useAnimatedScrollHandler((event) => {
    offset.value = event.contentOffset.y
  })

  React.useEffect(() => {
    if (positioned.current || fieldHeight <= 0 || viewportHeight <= 0) return
    if (hasQuery) {
      positioned.current = true
      return
    }
    const frame = requestAnimationFrame(() => {
      positioned.current = true
      const scroll = scrollRef.current as React.ComponentRef<
        typeof ScrollView
      > | null
      scroll?.scrollTo({ y: fieldHeight, animated: false })
    })
    return () => cancelAnimationFrame(frame)
  }, [fieldHeight, viewportHeight, hasQuery, scrollRef])

  const reveal = React.useCallback(() => {
    const scroll = scrollRef.current as React.ComponentRef<
      typeof ScrollView
    > | null
    scroll?.scrollTo({ y: 0, animated: true })
  }, [scrollRef])

  const contextValue = React.useMemo(
    () => ({
      offset,
      fieldHeight,
      viewportHeight,
      setFieldHeight,
      setHasQuery,
      reveal,
    }),
    [offset, fieldHeight, viewportHeight, reveal]
  )

  return (
    <SearchRevealContext.Provider value={contextValue}>
      <Animated.ScrollView
        ref={scrollRef}
        onScroll={onScroll}
        scrollEventThrottle={16}
        snapToOffsets={
          fieldHeight > 0 && !hasQuery ? [0, fieldHeight] : undefined
        }
        snapToEnd={false}
        keyboardShouldPersistTaps="handled"
        style={[styles.root, style]}
        onLayout={(event: LayoutChangeEvent) => {
          setViewportHeight(event.nativeEvent.layout.height)
          onLayout?.(event)
        }}
        {...props}
      >
        {children}
      </Animated.ScrollView>
    </SearchRevealContext.Provider>
  )
}

function SearchRevealField({
  value,
  onValueChange,
  placeholder = "Search",
  style,
  onFocus,
  ...props
}: Omit<React.ComponentProps<typeof TextInput>, "value" | "onChangeText"> & {
  value: string
  onValueChange: (value: string) => void
}) {
  const { colors } = useTheme()
  const { offset, fieldHeight, setFieldHeight, setHasQuery, reveal } =
    useSearchReveal("SearchRevealField")
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)

  React.useEffect(() => setHasQuery(value.length > 0), [value, setHasQuery])

  const revealStyle = useAnimatedStyle(() => {
    if (fieldHeight <= 0) return { opacity: 1 }
    return {
      opacity: Math.min(Math.max(1 - offset.value / fieldHeight, 0), 1),
    }
  }, [fieldHeight])

  return (
    <Animated.View
      style={[styles.field, revealStyle]}
      onLayout={(event) => setFieldHeight(event.nativeEvent.layout.height)}
    >
      <View style={[styles.box, { backgroundColor: colors.secondary }]}>
        <Icon
          as={MagnifyingGlassIcon}
          size={18}
          color={colors.mutedForeground}
          style={styles.icon}
        />
        <TextInput
          ref={inputRef}
          role="searchbox"
          value={value}
          placeholder={placeholder}
          accessibilityLabel={placeholder}
          onChangeText={onValueChange}
          onFocus={(event) => {
            reveal()
            onFocus?.(event)
          }}
          autoCorrect={false}
          autoCapitalize="none"
          returnKeyType="search"
          style={[styles.input, { color: colors.foreground }, style]}
          placeholderTextColor={colors.mutedForeground}
          cursorColor={colors.primary}
          selectionColor={colors.primary}
          {...props}
        />
        {value ? (
          <Pressable
            role="button"
            accessibilityLabel="Clear search"
            hitSlop={8}
            onPress={() => {
              onValueChange("")
              inputRef.current?.focus()
            }}
            style={styles.clear}
          >
            <Icon
              as={XCircleIcon}
              weight="fill"
              size={18}
              color={colors.mutedForeground}
            />
          </Pressable>
        ) : null}
      </View>
    </Animated.View>
  )
}

function SearchRevealContent({ style, ...props }: ViewProps) {
  const { viewportHeight } = useSearchReveal("SearchRevealContent")
  return <View style={[{ minHeight: viewportHeight }, style]} {...props} />
}

export { SearchReveal, SearchRevealContent, SearchRevealField }
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Interactions/SearchReveal.swift"
import PhosphorSwift
import SwiftUI

enum SearchRevealAnchor: Hashable, Sendable {
  case field, content
}

struct SearchRevealFieldInfo: Equatable {
  var height: CGFloat = 0
  var hasQuery = false
}

struct SearchRevealFieldKey: PreferenceKey {
  static var defaultValue: SearchRevealFieldInfo { SearchRevealFieldInfo() }

  static func reduce(value: inout SearchRevealFieldInfo, nextValue: () -> SearchRevealFieldInfo) {
    let next = nextValue()
    if next.height > 0 { value = next }
  }
}

struct SearchRevealContext {
  var viewport: CGFloat
  var reveal: () -> Void
}

extension EnvironmentValues {
  @Entry var searchReveal: SearchRevealContext?
}

struct SearchRevealSnap: ScrollTargetBehavior {
  let height: CGFloat
  let hasQuery: Bool

  static func target(for offset: CGFloat, height: CGFloat, hasQuery: Bool) -> CGFloat? {
    guard height > 0, offset > 0, offset < height else { return nil }
    return offset < height / 2 || hasQuery ? 0 : height
  }

  func updateTarget(_ target: inout ScrollTarget, context: TargetContext) {
    if let snapped = Self.target(for: target.rect.minY, height: height, hasQuery: hasQuery) {
      target.rect.origin.y = snapped
    }
  }
}

public struct SearchReveal<Content: View>: View {
  let content: Content

  @State private var field = SearchRevealFieldInfo()
  @State private var position: SearchRevealAnchor?
  @State private var viewport: CGFloat = 0
  @State private var settled = false
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    let context = SearchRevealContext(viewport: viewport) {
      if reduceMotion {
        position = .field
      } else {
        withAnimation(.smooth(duration: 0.3)) { position = .field }
      }
    }
    ScrollView {
      VStack(spacing: 0) {
        content
      }
      .scrollTargetLayout()
    }
    .scrollPosition(id: $position, anchor: .top)
    .scrollTargetBehavior(SearchRevealSnap(height: field.height, hasQuery: field.hasQuery))
    .scrollBounceBehavior(.always)
    .environment(\.searchReveal, context)
    .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { viewport = $0 }
    .onPreferenceChange(SearchRevealFieldKey.self) { info in
      field = info
      if !settled, info.height > 0 {
        settled = true
        position = info.hasQuery ? .field : .content
      }
    }
  }
}

public struct SearchRevealField: View {
  let text: Binding<String>
  let placeholder: LocalizedStringKey

  @FocusState private var focused: Bool
  @Environment(\.searchReveal) private var reveal

  public init(text: Binding<String>, placeholder: LocalizedStringKey = "Search") {
    self.text = text
    self.placeholder = placeholder
  }

  public var body: some View {
    HStack(spacing: 6) {
      Ph.magnifyingGlass.regular
        .frame(width: 18, height: 18)
        .accessibilityHidden(true)
      TextField(placeholder, text: text, prompt: Text(placeholder).foregroundStyle(EsuissColor.mutedForeground))
        .focused($focused)
        .submitLabel(.search)
        .autocorrectionDisabled()
        .scaledFont(.base)
        .foregroundStyle(EsuissColor.foreground)
        .tint(EsuissColor.primary)
      if !text.wrappedValue.isEmpty {
        Button {
          text.wrappedValue = ""
          focused = true
        } label: {
          Ph.xCircle.fill
            .frame(width: 18, height: 18)
            .frame(width: 28, height: 28)
            .contentShape(Circle().inset(by: -8))
        }
        .buttonStyle(.plain)
        .padding(.trailing, -4)
        .accessibilityLabel(Text("Clear search"))
      }
    }
    .foregroundStyle(EsuissColor.mutedForeground)
    .padding(.horizontal, 8)
    .frame(height: 36)
    .background(EsuissColor.secondary, in: RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
    .padding(.horizontal, 16)
    .padding(.bottom, 8)
    .visualEffect { view, proxy in
      let height = proxy.size.height
      let top = proxy.frame(in: .scrollView).minY
      return view.opacity(height > 0 ? min(max(1 + top / height, 0), 1) : 1)
    }
    .background {
      GeometryReader { proxy in
        Color.clear.preference(
          key: SearchRevealFieldKey.self,
          value: SearchRevealFieldInfo(height: proxy.size.height, hasQuery: !text.wrappedValue.isEmpty)
        )
      }
    }
    .id(SearchRevealAnchor.field)
    .onChange(of: focused) { _, value in
      if value { reveal?.reveal() }
    }
  }
}

public struct SearchRevealContent<Content: View>: View {
  let content: Content

  @Environment(\.searchReveal) private var reveal

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    VStack(spacing: 0) {
      content
    }
    .frame(maxWidth: .infinity, minHeight: reveal?.viewport, alignment: .top)
    .id(SearchRevealAnchor.content)
  }
}
```

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/interactions/SearchReveal.kt"
package com.esuiss.ui.interactions

import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.XCircle
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.esuiss.ui.components.Icon
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest

private const val SettleDelay = 140L

@Stable
private class SearchRevealContext(val scroll: ScrollState) {
    var fieldHeight by mutableIntStateOf(0)
    var viewportHeight by mutableIntStateOf(0)
    var hasQuery by mutableStateOf(false)
    var positioned = false

    val reveal: Float
        get() = if (fieldHeight == 0) 1f else (1f - scroll.value.toFloat() / fieldHeight).coerceIn(0f, 1f)
}

private val LocalSearchReveal = staticCompositionLocalOf<SearchRevealContext?> { null }

@Composable
fun SearchReveal(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    val scroll = rememberScrollState()
    val context = remember(scroll) { SearchRevealContext(scroll) }
    LaunchedEffect(context, context.fieldHeight) {
        val height = context.fieldHeight
        if (height == 0 || context.positioned) return@LaunchedEffect
        context.positioned = true
        scroll.scrollTo(if (context.hasQuery) 0 else height)
    }
    LaunchedEffect(context) {
        snapshotFlow { scroll.isScrollInProgress }.collectLatest { scrolling ->
            if (scrolling) return@collectLatest
            delay(SettleDelay)
            val top = scroll.value
            val size = context.fieldHeight
            if (top <= 0 || top >= size) return@collectLatest
            scroll.animateScrollTo(if (top < size / 2 || context.hasQuery) 0 else size)
        }
    }
    CompositionLocalProvider(LocalSearchReveal provides context) {
        Column(
            modifier = modifier
                .onSizeChanged { context.viewportHeight = it.height }
                .verticalScroll(scroll),
            content = content,
        )
    }
}

@Composable
fun SearchRevealField(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    placeholder: String = "Search",
    enabled: Boolean = true,
) {
    val context = LocalSearchReveal.current
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val focused by interaction.collectIsFocusedAsState()
    val focus = remember { FocusRequester() }
    SideEffect { context?.hasQuery = value.isNotEmpty() }
    LaunchedEffect(focused) {
        if (focused) context?.scroll?.animateScrollTo(0)
    }
    val textStyle = Typography.base.copy(color = colors.foreground)
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { context?.fieldHeight = it.height }
            .graphicsLayer { alpha = context?.reveal ?: 1f }
            .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(36.dp)
                .background(colors.secondary, RoundedCornerShape(Radius.lg))
                .padding(horizontal = 8.dp),
            horizontalArrangement = Arrangement.spacedBy(6.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = null, size = 18.dp, tint = colors.mutedForeground)
            CompositionLocalProvider(
                LocalTextSelectionColors provides TextSelectionColors(colors.primary, colors.primary.copy(alpha = 0.3f)),
            ) {
                BasicTextField(
                    value = value,
                    onValueChange = onValueChange,
                    modifier = modifier
                        .weight(1f)
                        .focusRequester(focus)
                        .semantics { contentDescription = placeholder },
                    enabled = enabled,
                    singleLine = true,
                    textStyle = textStyle,
                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
                    interactionSource = interaction,
                    cursorBrush = SolidColor(colors.primary),
                    decorationBox = { inner ->
                        Box(contentAlignment = Alignment.CenterStart) {
                            if (value.isEmpty()) {
                                BasicText(placeholder, style = textStyle.copy(color = colors.mutedForeground), maxLines = 1)
                            }
                            inner()
                        }
                    },
                )
            }
            if (value.isNotEmpty()) {
                Box(
                    modifier = Modifier
                        .size(28.dp)
                        .clickable(
                            interactionSource = remember { MutableInteractionSource() },
                            indication = null,
                            role = Role.Button,
                            onClickLabel = null,
                        ) {
                            onValueChange("")
                            focus.requestFocus()
                        }
                        .semantics { contentDescription = "Clear search" },
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(PhosphorIcons.Fill.XCircle, contentDescription = null, size = 18.dp, tint = colors.mutedForeground)
                }
            }
        }
    }
}

@Composable
fun SearchRevealContent(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    val context = LocalSearchReveal.current
    val minHeight = with(LocalDensity.current) { (context?.viewportHeight ?: 0).toDp() }
    Column(
        modifier = modifier
            .fillMaxWidth()
            .heightIn(min = minHeight),
        content = content,
    )
}
```

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SearchReveal, SearchRevealField, SearchRevealContent } from "@/components/interactions/search-reveal"
```

```tsx
<NotesExample count={12} />
```

Put a `SearchRevealField` first, then the list inside a `SearchRevealContent`, all inside `SearchReveal`. The field is controlled: pass `value` and update it in `onValueChange`, then filter your list with it.

`SearchRevealContent` is at least as tall as the scroll view, so the field can be hidden even when the list is short. Other `ScrollView` props, except `onScroll`, pass through to `SearchReveal`, and other `TextInput` props pass through to the field.

## Examples

### Short list

A three-item list that still hides the field.

![Preview](https://ui.suiss.app/previews/interactions-search-reveal--short-list.light.webp)

```tsx
import * as React from "react"
import { Pressable, View } from "react-native"
import { SearchReveal, SearchRevealContent, SearchRevealField } from "@/components/interactions/search-reveal"
import { Text } from "@/components/ui/text"

const allNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry" },
  { id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday" },
  {
    id: 8,
    title: "Movies to watch",
    preview: "Two documentaries and a classic",
  },
  { id: 9, title: "Garden", preview: "Plant tomatoes after the frost" },
  { id: 10, title: "Packing list", preview: "Charger, passport, rain jacket" },
  { id: 11, title: "Podcast notes", preview: "Episode about city design" },
  { id: 12, title: "Budget", preview: "Rent, groceries, savings" },
]

function NotesExample({
  count,
  defaultQuery = "",
}: {
  count: number
  defaultQuery?: string
}) {
  const [query, setQuery] = React.useState(defaultQuery)
  const notes = allNotes
    .slice(0, count)
    .filter((note) =>
      `${note.title} ${note.preview}`
        .toLowerCase()
        .includes(query.trim().toLowerCase())
    )

  return (
    <View className="flex-1 overflow-hidden rounded-2xl border border-border">
      <Text variant="h1" className="px-4 pt-4 pb-2">
        Notes
      </Text>
      <SearchReveal>
        <SearchRevealField value={query} onValueChange={setQuery} />
        <SearchRevealContent>
          {notes.length === 0 ? (
            <Text className="px-4 py-16 text-center text-sm text-muted-foreground">
              No results for “{query}”
            </Text>
          ) : (
            <View className="mx-4 overflow-hidden rounded-xl bg-muted">
              {notes.map((note, index) => (
                <Pressable
                  key={note.id}
                  className={
                    index > 0
                      ? "border-t border-border px-4 py-3 active:opacity-80"
                      : "px-4 py-3 active:opacity-80"
                  }
                >
                  <Text numberOfLines={1} className="font-semibold">
                    {note.title}
                  </Text>
                  <Text
                    numberOfLines={1}
                    className="text-sm text-muted-foreground"
                  >
                    {note.preview}
                  </Text>
                </Pressable>
              ))}
            </View>
          )}
          <Text className="px-4 py-3 text-center text-xs text-muted-foreground">
            {notes.length} {notes.length === 1 ? "note" : "notes"}
          </Text>
        </SearchRevealContent>
      </SearchReveal>
    </View>
  )
}

export function SearchRevealShortListExample() {
  return (
    <NotesExample count={3} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchRevealShortListExample: View {
  var body: some View {
    SearchRevealNotes(count: 3)
  }
}

struct SearchRevealNote: Identifiable {
  let id: Int
  let title: String
  let preview: String
}

let searchRevealNotes = [
  SearchRevealNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  SearchRevealNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  SearchRevealNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  SearchRevealNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
  SearchRevealNote(id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
  SearchRevealNote(id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry"),
  SearchRevealNote(id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday"),
  SearchRevealNote(id: 8, title: "Movies to watch", preview: "Two documentaries and a classic"),
  SearchRevealNote(id: 9, title: "Garden", preview: "Plant tomatoes after the frost"),
  SearchRevealNote(id: 10, title: "Packing list", preview: "Charger, passport, rain jacket"),
  SearchRevealNote(id: 11, title: "Podcast notes", preview: "Episode about city design"),
  SearchRevealNote(id: 12, title: "Budget", preview: "Rent, groceries, savings"),
]

struct SearchRevealNotes: View {
  let count: Int

  @State private var query: String

  init(count: Int, query: String = "") {
    self.count = count
    _query = State(initialValue: query)
  }

  var notes: [SearchRevealNote] {
    let needle = query.trimmingCharacters(in: .whitespaces).lowercased()
    return searchRevealNotes.prefix(count).filter { note in
      needle.isEmpty || "\(note.title) \(note.preview)".lowercased().contains(needle)
    }
  }

  var body: some View {
    let notes = notes
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 0) {
      Text("Notes")
        .textVariant(.h1)
        .padding(.horizontal, 16)
        .padding(.top, 16)
        .padding(.bottom, 8)
      SearchReveal {
        SearchRevealField(text: $query)
        SearchRevealContent {
          if notes.isEmpty {
            Text(verbatim: "No results for “\(query)”")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .multilineTextAlignment(.center)
              .padding(.horizontal, 16)
              .padding(.vertical, 64)
              .frame(maxWidth: .infinity)
          } else {
            VStack(spacing: 0) {
              ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
                Button {} label: {
                  VStack(alignment: .leading, spacing: 0) {
                    Text(verbatim: note.title)
                      .textStyle(.base, weight: .semibold)
                      .foregroundStyle(EsuissColor.foreground)
                      .lineLimit(1)
                    Text(verbatim: note.preview)
                      .textStyle(.sm)
                      .foregroundStyle(EsuissColor.mutedForeground)
                      .lineLimit(1)
                  }
                  .padding(.horizontal, 16)
                  .padding(.vertical, 12)
                  .frame(maxWidth: .infinity, alignment: .leading)
                  .contentShape(Rectangle())
                }
                .buttonStyle(SearchRevealRowStyle())
                .overlay(alignment: .top) {
                  if index > 0 {
                    EsuissColor.border.frame(height: 1)
                  }
                }
              }
            }
            .background(EsuissColor.muted)
            .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
            .padding(.horizontal, 16)
          }
          Text(verbatim: "\(notes.count) \(notes.count == 1 ? "note" : "notes")")
            .textStyle(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
            .multilineTextAlignment(.center)
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .frame(maxWidth: .infinity)
        }
      }
    }
    .clipShape(shape)
    .overlay { shape.strokeBorder(EsuissColor.border) }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}

struct SearchRevealRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SearchReveal
import com.esuiss.ui.interactions.SearchRevealContent
import com.esuiss.ui.interactions.SearchRevealField
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun SearchRevealShortListExample() {
    SearchRevealNotes(count = 3)
}

class SearchRevealNote(val id: Int, val title: String, val preview: String)

val searchRevealAllNotes = listOf(
    SearchRevealNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    SearchRevealNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    SearchRevealNote(3, "Book list", "Three novels for the summer"),
    SearchRevealNote(4, "Meeting notes", "Ship the beta on Friday"),
    SearchRevealNote(5, "Gift ideas", "Headphones, a plant, a scarf"),
    SearchRevealNote(6, "Recipes", "Lemon pasta and a quick curry"),
    SearchRevealNote(7, "Workout plan", "Run on Monday, swim on Thursday"),
    SearchRevealNote(8, "Movies to watch", "Two documentaries and a classic"),
    SearchRevealNote(9, "Garden", "Plant tomatoes after the frost"),
    SearchRevealNote(10, "Packing list", "Charger, passport, rain jacket"),
    SearchRevealNote(11, "Podcast notes", "Episode about city design"),
    SearchRevealNote(12, "Budget", "Rent, groceries, savings"),
)

@Composable
fun SearchRevealNotes(count: Int, defaultQuery: String = "") {
    val colors = EsuissTheme.colors
    var query by rememberSaveable { mutableStateOf(defaultQuery) }
    val needle = query.trim().lowercase()
    val notes = searchRevealAllNotes.take(count).filter { "${it.title} ${it.preview}".lowercase().contains(needle) }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(
        Modifier
            .fillMaxWidth()
            .height(600.dp)
            .clip(shape)
            .border(1.dp, colors.border, shape),
    ) {
        Text("Notes", modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), variant = TextVariant.H1)
        SearchReveal(Modifier.weight(1f)) {
            SearchRevealField(value = query, onValueChange = { query = it })
            SearchRevealContent {
                if (notes.isEmpty()) {
                    Text(
                        "No results for “$query”",
                        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 64.dp),
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        textAlign = TextAlign.Center,
                    )
                } else {
                    Column(
                        Modifier
                            .padding(horizontal = 16.dp)
                            .fillMaxWidth()
                            .clip(RoundedCornerShape(Radius.xl))
                            .background(colors.muted),
                    ) {
                        notes.forEachIndexed { index, note ->
                            if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                            SearchRevealRow(note)
                        }
                    }
                }
                Text(
                    "${notes.size} ${if (notes.size == 1) "note" else "notes"}",
                    modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
                    style = Typography.xs.copy(color = colors.mutedForeground),
                    textAlign = TextAlign.Center,
                )
            }
        }
    }
}

@Composable
fun SearchRevealRow(note: SearchRevealNote) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        Modifier
            .fillMaxWidth()
            .alpha(if (pressed) 0.8f else 1f)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button) {}
            .padding(horizontal = 16.dp, vertical = 12.dp),
    ) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = colors.mutedForeground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}
```

### With query

Starts with a query, so the field is visible and the list is filtered.

![Preview](https://ui.suiss.app/previews/interactions-search-reveal--with-query.light.webp)

```tsx
import * as React from "react"
import { Pressable, View } from "react-native"
import { SearchReveal, SearchRevealContent, SearchRevealField } from "@/components/interactions/search-reveal"
import { Text } from "@/components/ui/text"

const allNotes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
  { id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf" },
  { id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry" },
  { id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday" },
  {
    id: 8,
    title: "Movies to watch",
    preview: "Two documentaries and a classic",
  },
  { id: 9, title: "Garden", preview: "Plant tomatoes after the frost" },
  { id: 10, title: "Packing list", preview: "Charger, passport, rain jacket" },
  { id: 11, title: "Podcast notes", preview: "Episode about city design" },
  { id: 12, title: "Budget", preview: "Rent, groceries, savings" },
]

function NotesExample({
  count,
  defaultQuery = "",
}: {
  count: number
  defaultQuery?: string
}) {
  const [query, setQuery] = React.useState(defaultQuery)
  const notes = allNotes
    .slice(0, count)
    .filter((note) =>
      `${note.title} ${note.preview}`
        .toLowerCase()
        .includes(query.trim().toLowerCase())
    )

  return (
    <View className="flex-1 overflow-hidden rounded-2xl border border-border">
      <Text variant="h1" className="px-4 pt-4 pb-2">
        Notes
      </Text>
      <SearchReveal>
        <SearchRevealField value={query} onValueChange={setQuery} />
        <SearchRevealContent>
          {notes.length === 0 ? (
            <Text className="px-4 py-16 text-center text-sm text-muted-foreground">
              No results for “{query}”
            </Text>
          ) : (
            <View className="mx-4 overflow-hidden rounded-xl bg-muted">
              {notes.map((note, index) => (
                <Pressable
                  key={note.id}
                  className={
                    index > 0
                      ? "border-t border-border px-4 py-3 active:opacity-80"
                      : "px-4 py-3 active:opacity-80"
                  }
                >
                  <Text numberOfLines={1} className="font-semibold">
                    {note.title}
                  </Text>
                  <Text
                    numberOfLines={1}
                    className="text-sm text-muted-foreground"
                  >
                    {note.preview}
                  </Text>
                </Pressable>
              ))}
            </View>
          )}
          <Text className="px-4 py-3 text-center text-xs text-muted-foreground">
            {notes.length} {notes.length === 1 ? "note" : "notes"}
          </Text>
        </SearchRevealContent>
      </SearchReveal>
    </View>
  )
}

export function SearchRevealWithQueryExample() {
  return (
    <NotesExample count={12} defaultQuery="porto" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchRevealWithQueryExample: View {
  var body: some View {
    SearchRevealNotes(count: 12, query: "porto")
  }
}

struct SearchRevealNote: Identifiable {
  let id: Int
  let title: String
  let preview: String
}

let searchRevealNotes = [
  SearchRevealNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  SearchRevealNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  SearchRevealNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  SearchRevealNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
  SearchRevealNote(id: 5, title: "Gift ideas", preview: "Headphones, a plant, a scarf"),
  SearchRevealNote(id: 6, title: "Recipes", preview: "Lemon pasta and a quick curry"),
  SearchRevealNote(id: 7, title: "Workout plan", preview: "Run on Monday, swim on Thursday"),
  SearchRevealNote(id: 8, title: "Movies to watch", preview: "Two documentaries and a classic"),
  SearchRevealNote(id: 9, title: "Garden", preview: "Plant tomatoes after the frost"),
  SearchRevealNote(id: 10, title: "Packing list", preview: "Charger, passport, rain jacket"),
  SearchRevealNote(id: 11, title: "Podcast notes", preview: "Episode about city design"),
  SearchRevealNote(id: 12, title: "Budget", preview: "Rent, groceries, savings"),
]

struct SearchRevealNotes: View {
  let count: Int

  @State private var query: String

  init(count: Int, query: String = "") {
    self.count = count
    _query = State(initialValue: query)
  }

  var notes: [SearchRevealNote] {
    let needle = query.trimmingCharacters(in: .whitespaces).lowercased()
    return searchRevealNotes.prefix(count).filter { note in
      needle.isEmpty || "\(note.title) \(note.preview)".lowercased().contains(needle)
    }
  }

  var body: some View {
    let notes = notes
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 0) {
      Text("Notes")
        .textVariant(.h1)
        .padding(.horizontal, 16)
        .padding(.top, 16)
        .padding(.bottom, 8)
      SearchReveal {
        SearchRevealField(text: $query)
        SearchRevealContent {
          if notes.isEmpty {
            Text(verbatim: "No results for “\(query)”")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
              .multilineTextAlignment(.center)
              .padding(.horizontal, 16)
              .padding(.vertical, 64)
              .frame(maxWidth: .infinity)
          } else {
            VStack(spacing: 0) {
              ForEach(Array(notes.enumerated()), id: \.element.id) { index, note in
                Button {} label: {
                  VStack(alignment: .leading, spacing: 0) {
                    Text(verbatim: note.title)
                      .textStyle(.base, weight: .semibold)
                      .foregroundStyle(EsuissColor.foreground)
                      .lineLimit(1)
                    Text(verbatim: note.preview)
                      .textStyle(.sm)
                      .foregroundStyle(EsuissColor.mutedForeground)
                      .lineLimit(1)
                  }
                  .padding(.horizontal, 16)
                  .padding(.vertical, 12)
                  .frame(maxWidth: .infinity, alignment: .leading)
                  .contentShape(Rectangle())
                }
                .buttonStyle(SearchRevealRowStyle())
                .overlay(alignment: .top) {
                  if index > 0 {
                    EsuissColor.border.frame(height: 1)
                  }
                }
              }
            }
            .background(EsuissColor.muted)
            .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
            .padding(.horizontal, 16)
          }
          Text(verbatim: "\(notes.count) \(notes.count == 1 ? "note" : "notes")")
            .textStyle(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
            .multilineTextAlignment(.center)
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .frame(maxWidth: .infinity)
        }
      }
    }
    .clipShape(shape)
    .overlay { shape.strokeBorder(EsuissColor.border) }
    .containerRelativeFrame(.vertical) { height, _ in height - 32 }
  }
}

struct SearchRevealRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.SearchReveal
import com.esuiss.ui.interactions.SearchRevealContent
import com.esuiss.ui.interactions.SearchRevealField
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Composable
fun SearchRevealWithQueryExample() {
    SearchRevealNotes(count = 12, defaultQuery = "porto")
}

class SearchRevealNote(val id: Int, val title: String, val preview: String)

val searchRevealAllNotes = listOf(
    SearchRevealNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    SearchRevealNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    SearchRevealNote(3, "Book list", "Three novels for the summer"),
    SearchRevealNote(4, "Meeting notes", "Ship the beta on Friday"),
    SearchRevealNote(5, "Gift ideas", "Headphones, a plant, a scarf"),
    SearchRevealNote(6, "Recipes", "Lemon pasta and a quick curry"),
    SearchRevealNote(7, "Workout plan", "Run on Monday, swim on Thursday"),
    SearchRevealNote(8, "Movies to watch", "Two documentaries and a classic"),
    SearchRevealNote(9, "Garden", "Plant tomatoes after the frost"),
    SearchRevealNote(10, "Packing list", "Charger, passport, rain jacket"),
    SearchRevealNote(11, "Podcast notes", "Episode about city design"),
    SearchRevealNote(12, "Budget", "Rent, groceries, savings"),
)

@Composable
fun SearchRevealNotes(count: Int, defaultQuery: String = "") {
    val colors = EsuissTheme.colors
    var query by rememberSaveable { mutableStateOf(defaultQuery) }
    val needle = query.trim().lowercase()
    val notes = searchRevealAllNotes.take(count).filter { "${it.title} ${it.preview}".lowercase().contains(needle) }
    val shape = RoundedCornerShape(Radius.xl2)
    Column(
        Modifier
            .fillMaxWidth()
            .height(600.dp)
            .clip(shape)
            .border(1.dp, colors.border, shape),
    ) {
        Text("Notes", modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), variant = TextVariant.H1)
        SearchReveal(Modifier.weight(1f)) {
            SearchRevealField(value = query, onValueChange = { query = it })
            SearchRevealContent {
                if (notes.isEmpty()) {
                    Text(
                        "No results for “$query”",
                        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 64.dp),
                        style = Typography.sm.copy(color = colors.mutedForeground),
                        textAlign = TextAlign.Center,
                    )
                } else {
                    Column(
                        Modifier
                            .padding(horizontal = 16.dp)
                            .fillMaxWidth()
                            .clip(RoundedCornerShape(Radius.xl))
                            .background(colors.muted),
                    ) {
                        notes.forEachIndexed { index, note ->
                            if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                            SearchRevealRow(note)
                        }
                    }
                }
                Text(
                    "${notes.size} ${if (notes.size == 1) "note" else "notes"}",
                    modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
                    style = Typography.xs.copy(color = colors.mutedForeground),
                    textAlign = TextAlign.Center,
                )
            }
        }
    }
}

@Composable
fun SearchRevealRow(note: SearchRevealNote) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        Modifier
            .fillMaxWidth()
            .alpha(if (pressed) 0.8f else 1f)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button) {}
            .padding(horizontal = 16.dp, vertical = 12.dp),
    ) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = colors.mutedForeground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}
```

## API Reference

### SearchReveal

The scroll view that hides and reveals the field.

Also accepts the props of `ScrollViewProps`.

### SearchRevealField

The search input with a clear button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | Required | The current query. |
| `onValueChange` | `(value: string) => void` | Required | Called with the new query. |

Also accepts the props of `React.ComponentProps<typeof TextInput>`.

### SearchRevealContent

The list area, at least one screen tall.

Also accepts the props of `ViewProps`.

## Accessibility

- The input has the `searchbox` role and is labelled by `placeholder`.
- The clear button is labelled `Clear search` and returns focus to the input.

## Notes

- The field uses `bg-secondary` with `muted-foreground` placeholder and icons.
- Autocorrect and autocapitalization are off, and the return key reads `search`.
- Taps on the list work while the keyboard is open.

