# Empty Search

A search page showing live suggestions, a no-results state, and suggested queries.

Empty Search is an open-source block 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/blocks/empty-search

**React**

**Example**

```tsx title="components/blocks/empty-search.tsx"
"use client"

import { MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"

const suggestions = ["Book Air", "Book Pro", "Book mini", "Compare Book models"]

export function EmptySearch() {
  const [query, setQuery] = React.useState("book neo")
  const inputRef = React.useRef<HTMLInputElement>(null)
  const trimmed = query.trim().toLowerCase()
  const matches =
    trimmed.length > 0 && trimmed.startsWith("book")
      ? suggestions.filter((item) => item.toLowerCase().startsWith(trimmed))
      : []

  return (
    <section className="px-5 py-10 md:px-12">
      <div className="mx-auto flex max-w-170 flex-col gap-6.5">
        <div className="flex h-13 items-center gap-2.5 border-b border-input text-muted-foreground focus-within:border-primary">
          <MagnifyingGlassIcon className="size-5 shrink-0" />
          <input
            ref={inputRef}
            type="search"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            aria-label="Search suiss"
            placeholder="Search suiss"
            className="min-w-0 flex-1 bg-transparent text-2xl font-semibold text-foreground outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:hidden"
          />
          {query && (
            <Button
              size="icon-xs"
              aria-label="Clear search"
              onClick={() => {
                setQuery("")
                inputRef.current?.focus()
              }}
              className="relative size-5.5 bg-muted-foreground text-background after:absolute after:-inset-2.5 after:content-[''] hover:bg-muted-foreground"
            >
              <XIcon className="size-2.75" />
            </Button>
          )}
        </div>
        {matches.length > 0 ? (
          <nav aria-label="Suggested" className="flex flex-col gap-1">
            <p className="mb-1.5 text-xs text-muted-foreground">Suggested</p>
            {matches.map((item) => (
              <button
                key={item}
                type="button"
                onClick={() => setQuery(item)}
                className="flex items-center gap-2.5 rounded-md py-1.5 text-start text-lg outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
              >
                <MagnifyingGlassIcon
                  className="size-3.5 text-muted-foreground"
                />
                {item}
              </button>
            ))}
          </nav>
        ) : (
          <Empty
            key={query ? "none" : "idle"}
            className="items-start gap-3 rounded-none p-0 text-start transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
          >
            <EmptyHeader
              className="max-w-none items-start gap-3"
              aria-live="polite"
            >
              <EmptyTitle className="text-3xl tracking-tight">
                {query
                  ? `No results for “${query}”.`
                  : "What are you looking for?"}
              </EmptyTitle>
              <EmptyDescription className="text-lg/normal">
                {query
                  ? "Check the spelling or try one of these:"
                  : "Search for products, support articles or stores."}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent className="mt-1.5 max-w-none flex-row flex-wrap gap-2">
              {suggestions.map((item) => (
                <Button
                  key={item}
                  variant="secondary"
                  onClick={() => setQuery(item)}
                  className="h-8.5 px-3.5"
                >
                  {item}
                </Button>
              ))}
            </EmptyContent>
          </Empty>
        )}
      </div>
    </section>
  )
}
```

A search page that lists matching suggestions as you type, and shows a no-results message with suggested queries when nothing matches. An empty field shows an idle prompt instead. Use it for site search, command search or any search screen that needs a helpful empty state.

## Installation

### CLI

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

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 blocks empty-search
```

### Manual

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty).

3. Copy the source into `components/blocks/empty-search.tsx`:

```tsx title="components/blocks/empty-search.tsx"
"use client"

import { MagnifyingGlassIcon, XIcon } from "@phosphor-icons/react"
import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"

const suggestions = ["Book Air", "Book Pro", "Book mini", "Compare Book models"]

export function EmptySearch() {
  const [query, setQuery] = React.useState("book neo")
  const inputRef = React.useRef<HTMLInputElement>(null)
  const trimmed = query.trim().toLowerCase()
  const matches =
    trimmed.length > 0 && trimmed.startsWith("book")
      ? suggestions.filter((item) => item.toLowerCase().startsWith(trimmed))
      : []

  return (
    <section className="px-5 py-10 md:px-12">
      <div className="mx-auto flex max-w-170 flex-col gap-6.5">
        <div className="flex h-13 items-center gap-2.5 border-b border-input text-muted-foreground focus-within:border-primary">
          <MagnifyingGlassIcon className="size-5 shrink-0" />
          <input
            ref={inputRef}
            type="search"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            aria-label="Search suiss"
            placeholder="Search suiss"
            className="min-w-0 flex-1 bg-transparent text-2xl font-semibold text-foreground outline-none placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:hidden"
          />
          {query && (
            <Button
              size="icon-xs"
              aria-label="Clear search"
              onClick={() => {
                setQuery("")
                inputRef.current?.focus()
              }}
              className="relative size-5.5 bg-muted-foreground text-background after:absolute after:-inset-2.5 after:content-[''] hover:bg-muted-foreground"
            >
              <XIcon className="size-2.75" />
            </Button>
          )}
        </div>
        {matches.length > 0 ? (
          <nav aria-label="Suggested" className="flex flex-col gap-1">
            <p className="mb-1.5 text-xs text-muted-foreground">Suggested</p>
            {matches.map((item) => (
              <button
                key={item}
                type="button"
                onClick={() => setQuery(item)}
                className="flex items-center gap-2.5 rounded-md py-1.5 text-start text-lg outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
              >
                <MagnifyingGlassIcon
                  className="size-3.5 text-muted-foreground"
                />
                {item}
              </button>
            ))}
          </nav>
        ) : (
          <Empty
            key={query ? "none" : "idle"}
            className="items-start gap-3 rounded-none p-0 text-start transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
          >
            <EmptyHeader
              className="max-w-none items-start gap-3"
              aria-live="polite"
            >
              <EmptyTitle className="text-3xl tracking-tight">
                {query
                  ? `No results for “${query}”.`
                  : "What are you looking for?"}
              </EmptyTitle>
              <EmptyDescription className="text-lg/normal">
                {query
                  ? "Check the spelling or try one of these:"
                  : "Search for products, support articles or stores."}
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent className="mt-1.5 max-w-none flex-row flex-wrap gap-2">
              {suggestions.map((item) => (
                <Button
                  key={item}
                  variant="secondary"
                  onClick={() => setQuery(item)}
                  className="h-8.5 px-3.5"
                >
                  {item}
                </Button>
              ))}
            </EmptyContent>
          </Empty>
        )}
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { EmptySearch } from "@/components/blocks/empty-search"
```

```tsx
<EmptySearch />
```

Replace the `suggestions` array and the `matches` computation with results from your search API; the demo only matches queries that start with book. The query starts as book neo so the no-results state shows on load, so start it empty in your app.

## Notes

The clear button returns focus to the field, and the empty-state message is announced when it changes.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/empty-search.tsx"
import { cn } from "cn"
import { MagnifyingGlassIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, type TextInput, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const SUGGESTIONS = ["Book Air", "Book Pro", "Book mini", "Compare Book models"]

function EmptySearch() {
  const [query, setQuery] = React.useState("book neo")
  const [focused, setFocused] = React.useState(false)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const trimmed = query.trim().toLowerCase()
  const matches =
    trimmed.length > 0 && trimmed.startsWith("book")
      ? SUGGESTIONS.filter((item) => item.toLowerCase().startsWith(trimmed))
      : []

  return (
    <Screen>
      <ScrollView
        keyboardShouldPersistTaps="handled"
        keyboardDismissMode="on-drag"
        contentContainerClassName="gap-6.5 px-5 py-10"
      >
        <View
          className={cn(
            "h-13 flex-row items-center gap-2.5 border-b",
            focused ? "border-primary" : "border-input"
          )}
        >
          <Icon
            as={MagnifyingGlassIcon}
            className="size-5 shrink-0 text-muted-foreground"
          />
          <Input
            ref={inputRef}
            value={query}
            onChangeText={setQuery}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            placeholder="Search suiss"
            accessibilityLabel="Search suiss"
            returnKeyType="search"
            autoCorrect={false}
            className="h-12 min-w-0 flex-1 rounded-none border-transparent bg-transparent px-0 text-[24px] font-semibold"
          />
          {query ? (
            <Button
              size="icon-xs"
              hitSlop={10}
              accessibilityLabel="Clear search"
              onPress={() => {
                setQuery("")
                inputRef.current?.focus()
              }}
              className="size-5.5 bg-muted-foreground active:opacity-80"
            >
              <Icon
                as={XIcon}
                weight="bold"
                className="size-2.75 text-background"
              />
            </Button>
          ) : null}
        </View>
        {matches.length > 0 ? (
          <View accessibilityLabel="Suggested" className="gap-1">
            <Text className="mb-1.5 text-xs text-muted-foreground">
              Suggested
            </Text>
            {matches.map((item) => (
              <Pressable
                key={item}
                role="button"
                onPress={() => setQuery(item)}
                className="flex-row items-center gap-2.5 rounded-md py-1.5 active:opacity-60"
              >
                <Icon
                  as={MagnifyingGlassIcon}
                  className="size-3.5 text-muted-foreground"
                />
                <Text className="text-lg">{item}</Text>
              </Pressable>
            ))}
          </View>
        ) : (
          <AnimatedView
            key={query ? "none" : "idle"}
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
          >
            <Empty className="flex-none items-start gap-3 rounded-none p-0">
              <EmptyHeader
                accessibilityLiveRegion="polite"
                className="max-w-none items-start gap-3"
              >
                <EmptyTitle className="text-start text-3xl font-semibold">
                  {query
                    ? `No results for “${query}”.`
                    : "What are you looking for?"}
                </EmptyTitle>
                <EmptyDescription className="text-start text-lg">
                  {query
                    ? "Check the spelling or try one of these:"
                    : "Search for products, support articles or stores."}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent className="mt-1.5 max-w-none flex-row flex-wrap items-start gap-2">
                {SUGGESTIONS.map((item) => (
                  <Button
                    key={item}
                    variant="secondary"
                    onPress={() => setQuery(item)}
                    className="h-8.5 px-3.5"
                  >
                    <Text>{item}</Text>
                  </Button>
                ))}
              </EmptyContent>
            </Empty>
          </AnimatedView>
        )}
      </ScrollView>
    </Screen>
  )
}

export { EmptySearch }
```

```swift title="swift/Blocks/EmptySearch.swift"
import PhosphorSwift
import SwiftUI

public struct EmptySearch: View {
  static var suggestions: [String] { ["Book Air", "Book Pro", "Book mini", "Compare Book models"] }

  @State private var query: String
  @FocusState private var focused: Bool
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init() {
    self.init(query: "book neo")
  }

  init(query: String) {
    _query = State(initialValue: query)
  }

  private var matches: [String] {
    let trimmed = query.trimmingCharacters(in: .whitespaces).lowercased()
    guard !trimmed.isEmpty, trimmed.hasPrefix("book") else { return [] }
    return Self.suggestions.filter { $0.lowercased().hasPrefix(trimmed) }
  }

  public var body: some View {
    Screen {
      KeyboardScrollView {
        VStack(alignment: .leading, spacing: 26) {
          field
          if matches.isEmpty {
            empty
              .id(query.isEmpty)
              .transition(.opacity.combined(with: .offset(y: 8)))
          } else {
            suggested
          }
        }
        .padding(.horizontal, 20)
        .padding(.vertical, 24)
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.5), value: query.isEmpty)
  }

  private var field: some View {
    HStack(spacing: 10) {
      Ph.magnifyingGlass.regular
        .frame(width: 20, height: 20)
        .foregroundStyle(EsuissColor.mutedForeground)
        .accessibilityHidden(true)
      TextField("Search suiss", text: $query)
        .focused($focused)
        .submitLabel(.search)
        .textInputAutocapitalization(.never)
        .autocorrectionDisabled()
        .scaledFont(.xl, weight: .semibold)
        .foregroundStyle(EsuissColor.foreground)
        .tint(EsuissColor.primary)
        .accessibilityLabel(Text("Search suiss"))
      if !query.isEmpty {
        Button {
          query = ""
          focused = true
        } label: {
          Ph.x.bold
            .frame(width: 11, height: 11)
            .foregroundStyle(EsuissColor.background)
            .frame(width: 22, height: 22)
            .background(EsuissColor.mutedForeground, in: Circle())
            .frame(width: 44, height: 44)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .padding(.trailing, -11)
        .accessibilityLabel(Text("Clear search"))
      }
    }
    .frame(height: 52)
    .overlay(alignment: .bottom) {
      (focused ? EsuissColor.primary : EsuissColor.input).frame(height: 1)
    }
  }

  private var suggested: some View {
    VStack(alignment: .leading, spacing: 4) {
      Text("Suggested")
        .scaledFont(.xs)
        .foregroundStyle(EsuissColor.mutedForeground)
        .padding(.bottom, 6)
        .accessibilityAddTraits(.isHeader)
      ForEach(matches, id: \.self) { item in
        Button {
          query = item
        } label: {
          HStack(spacing: 10) {
            Ph.magnifyingGlass.regular
              .frame(width: 14, height: 14)
              .foregroundStyle(EsuissColor.mutedForeground)
            Text(verbatim: item)
              .scaledFont(.lg)
              .foregroundStyle(EsuissColor.foreground)
          }
          .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading)
          .contentShape(Rectangle())
        }
        .buttonStyle(EmptySearchRowStyle())
      }
    }
  }

  private var empty: some View {
    VStack(alignment: .leading, spacing: 12) {
      Group {
        if query.isEmpty {
          Text("What are you looking for?")
        } else {
          Text("No results for “\(query)”.")
        }
      }
      .scaledTextStyle(.xl2, weight: .bold)
      .fixedSize(horizontal: false, vertical: true)
      .accessibilityAddTraits(.isHeader)
      Text(query.isEmpty ? "Search for products, support articles or stores." : "Check the spelling or try one of these:")
        .scaledTextStyle(.lg)
        .foregroundStyle(EsuissColor.mutedForeground)
        .fixedSize(horizontal: false, vertical: true)
      ComboboxFlowLayout(spacing: 8) {
        ForEach(Self.suggestions, id: \.self) { item in
          Button(item) { query = item }
            .buttonStyle(.variant(.secondary))
        }
      }
      .padding(.top, 6)
    }
    .accessibilityElement(children: .contain)
  }
}

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

```kt title="kotlin/blocks/EmptySearch.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
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.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.X
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Empty
import com.esuiss.ui.components.EmptyContent
import com.esuiss.ui.components.EmptyHeader
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Screen
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

private val RevealEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private val Suggestions = listOf("Book Air", "Book Pro", "Book mini", "Compare Book models")

@Composable
fun EmptySearch(
    modifier: Modifier = Modifier,
) {
    val colors = EsuissTheme.colors
    var query by rememberSaveable { mutableStateOf("book neo") }
    val focus = remember { FocusRequester() }
    val trimmed = query.trim().lowercase()
    val matches = if (trimmed.isNotEmpty() && trimmed.startsWith("book")) {
        Suggestions.filter { it.lowercase().startsWith(trimmed) }
    } else {
        emptyList()
    }

    Screen(modifier) {
        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 20.dp, vertical = 24.dp),
            verticalArrangement = Arrangement.spacedBy(26.dp),
        ) {
            SearchField(
                value = query,
                onValueChange = { query = it },
                onSearch = {},
                onClear = {
                    query = ""
                    runCatching { focus.requestFocus() }
                },
                modifier = Modifier.focusRequester(focus),
            )
            if (matches.isNotEmpty()) {
                Column(
                    modifier = Modifier.semantics { contentDescription = "Suggested" },
                    verticalArrangement = Arrangement.spacedBy(4.dp),
                ) {
                    BasicText(
                        "Suggested",
                        modifier = Modifier.padding(bottom = 6.dp),
                        style = Typography.xs.copy(color = colors.mutedForeground),
                    )
                    matches.forEach { item ->
                        SuggestionRow(item) { query = item }
                    }
                }
            } else {
                AnimatedContent(
                    targetState = query.isEmpty(),
                    transitionSpec = {
                        (fadeIn(tween(800)) + slideInVertically(tween(800, easing = RevealEasing)) { it / 20 }) togetherWith fadeOut(tween(150))
                    },
                    label = "empty",
                ) { idle ->
                    Empty(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
                        EmptyHeader {
                            BasicText(
                                if (idle) "What are you looking for?" else "No results for “$query”.",
                                modifier = Modifier.semantics { heading() },
                                style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
                            )
                            BasicText(
                                if (idle) "Search for products, support articles or stores." else "Check the spelling or try one of these:",
                                style = Typography.lg.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                            )
                        }
                        EmptyContent {
                            FlowRow(
                                horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
                                verticalArrangement = Arrangement.spacedBy(8.dp),
                            ) {
                                Suggestions.forEach { item ->
                                    Button(item, { query = item }, variant = ButtonVariant.Secondary)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun SearchField(
    value: String,
    onValueChange: (String) -> Unit,
    onSearch: () -> Unit,
    onClear: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val focused by interaction.collectIsFocusedAsState()
    val line by animateColorAsState(if (focused) colors.primary else colors.input, tween(200), label = "line")
    val style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold)
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .height(52.dp)
            .drawBehind {
                val stroke = 1.dp.toPx()
                drawRect(line, Offset(0f, size.height - stroke), Size(size.width, stroke))
            },
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = null, size = 20.dp, tint = colors.mutedForeground)
        BasicTextField(
            value = value,
            onValueChange = onValueChange,
            modifier = modifier
                .weight(1f)
                .semantics { contentDescription = "Search suiss" },
            textStyle = style,
            singleLine = true,
            cursorBrush = SolidColor(colors.primary),
            interactionSource = interaction,
            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
            keyboardActions = KeyboardActions(onSearch = { onSearch() }),
            decorationBox = { inner ->
                Box(contentAlignment = Alignment.CenterStart) {
                    if (value.isEmpty()) BasicText("Search suiss", style = style.copy(color = colors.mutedForeground), maxLines = 1)
                    inner()
                }
            },
        )
        if (value.isNotEmpty()) {
            val press = remember { MutableInteractionSource() }
            val pressed by press.collectIsPressedAsState()
            Box(
                Modifier
                    .size(44.dp)
                    .clickable(interactionSource = press, indication = null, role = Role.Button, onClick = onClear)
                    .semantics { contentDescription = "Clear search" },
                contentAlignment = Alignment.Center,
            ) {
                Box(
                    Modifier
                        .size(22.dp)
                        .alpha(if (pressed) 0.8f else 1f)
                        .background(colors.mutedForeground, CircleShape),
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(PhosphorIcons.Bold.X, contentDescription = null, size = 11.dp, tint = colors.background)
                }
            }
        }
    }
}

@Composable
private fun SuggestionRow(text: String, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(min = 44.dp)
            .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick),
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = null, size = 14.dp, tint = colors.mutedForeground)
        BasicText(text, style = Typography.lg.copy(color = colors.foreground))
    }
}
```

Composes `Screen`, `Input`, `Empty`, `Button`, `Icon` and `Text`. Typing filters the suggestions list, the clear button empties the field and refocuses it, and when nothing matches the screen shows a no-results message with suggestion buttons that fill in the query.

## Installation

**React Native**

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

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 empty-search
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### 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: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/empty-search.tsx`:

- `uniwind/components/blocks/empty-search.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

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: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/empty-search.tsx`:

```tsx title="nativewind/components/blocks/empty-search.tsx"
import { cn } from "cn"
import { MagnifyingGlassIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView, type TextInput, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const SUGGESTIONS = ["Book Air", "Book Pro", "Book mini", "Compare Book models"]

function EmptySearch() {
  const [query, setQuery] = React.useState("book neo")
  const [focused, setFocused] = React.useState(false)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const trimmed = query.trim().toLowerCase()
  const matches =
    trimmed.length > 0 && trimmed.startsWith("book")
      ? SUGGESTIONS.filter((item) => item.toLowerCase().startsWith(trimmed))
      : []

  return (
    <Screen>
      <ScrollView
        keyboardShouldPersistTaps="handled"
        keyboardDismissMode="on-drag"
        contentContainerClassName="gap-[1.625rem] px-5 py-10"
      >
        <View
          className={cn(
            "h-[3.25rem] flex-row items-center gap-2.5 border-b",
            focused ? "border-primary" : "border-input"
          )}
        >
          <Icon
            as={MagnifyingGlassIcon}
            className="size-5 shrink-0 text-muted-foreground"
          />
          <Input
            ref={inputRef}
            value={query}
            onChangeText={setQuery}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            placeholder="Search suiss"
            accessibilityLabel="Search suiss"
            returnKeyType="search"
            autoCorrect={false}
            className="h-12 min-w-0 flex-1 rounded-none border-transparent bg-transparent px-0 text-[24px] font-semibold"
          />
          {query ? (
            <Button
              size="icon-xs"
              hitSlop={10}
              accessibilityLabel="Clear search"
              onPress={() => {
                setQuery("")
                inputRef.current?.focus()
              }}
              className="size-[1.375rem] bg-muted-foreground active:opacity-80"
            >
              <Icon
                as={XIcon}
                weight="bold"
                className="size-[0.6875rem] text-background"
              />
            </Button>
          ) : null}
        </View>
        {matches.length > 0 ? (
          <View accessibilityLabel="Suggested" className="gap-1">
            <Text className="mb-1.5 text-xs text-muted-foreground">
              Suggested
            </Text>
            {matches.map((item) => (
              <Pressable
                key={item}
                role="button"
                onPress={() => setQuery(item)}
                className="flex-row items-center gap-2.5 rounded-md py-1.5 active:opacity-60"
              >
                <Icon
                  as={MagnifyingGlassIcon}
                  className="size-3.5 text-muted-foreground"
                />
                <Text className="text-lg">{item}</Text>
              </Pressable>
            ))}
          </View>
        ) : (
          <AnimatedView
            key={query ? "none" : "idle"}
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
          >
            <Empty className="flex-none items-start gap-3 rounded-none p-0">
              <EmptyHeader
                accessibilityLiveRegion="polite"
                className="max-w-none items-start gap-3"
              >
                <EmptyTitle className="text-start text-3xl font-semibold">
                  {query
                    ? `No results for “${query}”.`
                    : "What are you looking for?"}
                </EmptyTitle>
                <EmptyDescription className="text-start text-lg">
                  {query
                    ? "Check the spelling or try one of these:"
                    : "Search for products, support articles or stores."}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent className="mt-1.5 max-w-none flex-row flex-wrap items-start gap-2">
                {SUGGESTIONS.map((item) => (
                  <Button
                    key={item}
                    variant="secondary"
                    onPress={() => setQuery(item)}
                    className="h-[2.125rem] px-3.5"
                  >
                    <Text>{item}</Text>
                  </Button>
                ))}
              </EmptyContent>
            </Empty>
          </AnimatedView>
        )}
      </ScrollView>
    </Screen>
  )
}

export { EmptySearch }
```

5. 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: [Button](https://ui.suiss.app/docs/components/button), [Empty](https://ui.suiss.app/docs/components/empty), [Icon](https://ui.suiss.app/docs/components/icon), [Input](https://ui.suiss.app/docs/components/input), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/empty-search.tsx`:

```tsx title="stylesheet/components/blocks/empty-search.tsx"
import { MagnifyingGlassIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Pressable,
  ScrollView,
  StyleSheet,
  type TextInput,
  View,
} from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"
import { Icon } from "@/components/ui/icon"
import { Input } from "@/components/ui/input"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

const SUGGESTIONS = ["Book Air", "Book Pro", "Book mini", "Compare Book models"]

const styles = StyleSheet.create({
  content: { gap: 26, paddingHorizontal: 20, paddingVertical: 40 },
  field: {
    height: 52,
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderBottomWidth: 1,
  },
  input: {
    fontSize: fontSize["2xl"].fontSize,
    height: 48,
    minWidth: 0,
    flex: 1,
    borderRadius: 0,
    borderColor: "transparent",
    backgroundColor: "transparent",
    paddingHorizontal: 0,
    fontWeight: "600",
  },
  clear: { width: 22, height: 22 },
  suggested: { gap: 4 },
  suggestedLabel: { ...fontSize.xs, marginBottom: 6 },
  suggestion: {
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderRadius: radius.md,
    paddingVertical: 6,
  },
  suggestionText: { ...fontSize.lg },
  empty: {
    flex: 0,
    alignItems: "flex-start",
    gap: 12,
    borderRadius: 0,
    padding: 0,
  },
  header: { maxWidth: "100%", alignItems: "flex-start", gap: 12 },
  title: { ...fontSize["3xl"], fontWeight: "600", textAlign: "left" },
  description: { ...fontSize.lg, textAlign: "left" },
  chips: {
    marginTop: 6,
    maxWidth: "100%",
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "flex-start",
    gap: 8,
  },
  chip: { height: 34, paddingHorizontal: 14 },
})

function EmptySearch() {
  const { colors } = useTheme()
  const [query, setQuery] = React.useState("book neo")
  const [focused, setFocused] = React.useState(false)
  const inputRef = React.useRef<React.ComponentRef<typeof TextInput>>(null)
  const trimmed = query.trim().toLowerCase()
  const matches =
    trimmed.length > 0 && trimmed.startsWith("book")
      ? SUGGESTIONS.filter((item) => item.toLowerCase().startsWith(trimmed))
      : []

  return (
    <Screen>
      <ScrollView
        keyboardShouldPersistTaps="handled"
        keyboardDismissMode="on-drag"
        contentContainerStyle={styles.content}
      >
        <View
          style={[
            styles.field,
            {
              borderBottomColor: focused ? colors.primary : colors.input,
            },
          ]}
        >
          <Icon
            as={MagnifyingGlassIcon}
            size={20}
            color={colors.mutedForeground}
          />
          <Input
            ref={inputRef}
            value={query}
            onChangeText={setQuery}
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            placeholder="Search suiss"
            accessibilityLabel="Search suiss"
            returnKeyType="search"
            autoCorrect={false}
            style={styles.input}
          />
          {query ? (
            <Button
              size="icon-xs"
              hitSlop={10}
              accessibilityLabel="Clear search"
              onPress={() => {
                setQuery("")
                inputRef.current?.focus()
              }}
              style={({ pressed }) => [
                styles.clear,
                {
                  backgroundColor: pressed
                    ? colors.mutedForeground
                    : colors.mutedForeground,
                },
              ]}
            >
              <Icon
                as={XIcon}
                weight="bold"
                size={11}
                color={colors.background}
              />
            </Button>
          ) : null}
        </View>
        {matches.length > 0 ? (
          <View accessibilityLabel="Suggested" style={styles.suggested}>
            <Text
              style={[styles.suggestedLabel, { color: colors.mutedForeground }]}
            >
              Suggested
            </Text>
            {matches.map((item) => (
              <Pressable
                key={item}
                role="button"
                onPress={() => setQuery(item)}
                style={({ pressed }) => [
                  styles.suggestion,
                  pressed && { opacity: 0.6 },
                ]}
              >
                <Icon
                  as={MagnifyingGlassIcon}
                  size={14}
                  color={colors.mutedForeground}
                />
                <Text style={styles.suggestionText}>{item}</Text>
              </Pressable>
            ))}
          </View>
        ) : (
          <AnimatedView
            key={query ? "none" : "idle"}
            entering={FadeInDown.duration(600).reduceMotion(
              ReduceMotion.System
            )}
          >
            <Empty style={styles.empty}>
              <EmptyHeader
                accessibilityLiveRegion="polite"
                style={styles.header}
              >
                <EmptyTitle style={styles.title}>
                  {query
                    ? `No results for “${query}”.`
                    : "What are you looking for?"}
                </EmptyTitle>
                <EmptyDescription style={styles.description}>
                  {query
                    ? "Check the spelling or try one of these:"
                    : "Search for products, support articles or stores."}
                </EmptyDescription>
              </EmptyHeader>
              <EmptyContent style={styles.chips}>
                {SUGGESTIONS.map((item) => (
                  <Button
                    key={item}
                    variant="secondary"
                    onPress={() => setQuery(item)}
                    style={styles.chip}
                  >
                    <Text>{item}</Text>
                  </Button>
                ))}
              </EmptyContent>
            </Empty>
          </AnimatedView>
        )}
      </ScrollView>
    </Screen>
  )
}

export { EmptySearch }
```

5. 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/Blocks/EmptySearch.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Combobox.swift`: see [Combobox](https://ui.suiss.app/docs/components/combobox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

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

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `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:

- `kotlin/blocks/EmptySearch.kt`: shown above

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

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `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/Empty.kt`: see [Empty](https://ui.suiss.app/docs/components/empty.md)

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

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

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

## Usage

```tsx
import { EmptySearch } from "@/components/blocks/empty-search"
```

```tsx
<EmptySearch />
```

Replace `SUGGESTIONS` and the prefix matching with results from your search service. The component takes no props.

