Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Filters

A filter bottom sheet composed from touchcn components.

A filter sheet and its trigger: a sort radio group, selectable category chips, a price-ceiling slider with a live readout, boolean option checkboxes and a sticky footer with Reset and an Apply button that shows the active-filter count. The selection is local state; apply emits the assembled filter state and reset clears it back to defaults.

Installation

npx touchcn add filters

This also adds the components the block composes:

bottom-sheet · button · checkbox · chip · radio · slider

Usage

<tcn-filters-block (apply)="applyFilters($event)" (reset)="clearFilters()" />
import { TcnFiltersBlock } from '@/components/blocks/filters';

<TcnFiltersBlock onApply={(state) => applyFilters(state)} onReset={clearFilters} />
<script setup lang="ts">
import { TcnFiltersBlock } from '@/components/blocks/filters';
</script>

<template>
  <TcnFiltersBlock @apply="(state) => applyFilters(state)" @reset="clearFilters" />
</template>

apply receives { sort, categories, maxPrice, freeShipping, inStock, onSale } and closes the sheet; reset clears every control and keeps the sheet open. The Apply button and the trigger both surface the active-filter count.

Set showBack to render a back control in the navbar’s leading slot; it emits back (Angular) / onBack (React) when tapped — wire it to your router.

Source · Angular
export * from './tcn-filters-block';
import { booleanAttribute, Component, computed, input, output, signal } from '@angular/core';
import { TcnBottomSheet } from '@/components/ui/bottom-sheet';
import { TcnButton } from '@/components/ui/button';
import { TcnCheckbox } from '@/components/ui/checkbox';
import { TcnChip } from '@/components/ui/chip';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnRadio, TcnRadioGroup } from '@/components/ui/radio';
import { TcnSlider } from '@/components/ui/slider';

export interface FilterState {
  sort: string;
  categories: string[];
  maxPrice: number;
  freeShipping: boolean;
  inStock: boolean;
  onSale: boolean;
}

const MAX_PRICE = 200;
const CATEGORIES = ['Shoes', 'Apparel', 'Accessories', 'Bags', 'Electronics', 'Home'];

/**
 * Filter sheet — a bottom-sheet composition plus its trigger. Sort (radio),
 * categories (selectable chips), a price ceiling (slider) and boolean options
 * (checkboxes) feed a live active-filter count shown on the sticky Apply button.
 * `apply` emits the assembled `FilterState`; `reset` clears it back to defaults.
 */
@Component({
  selector: 'tcn-filters-block',
  imports: [TcnPage, TcnNavbar, TcnBottomSheet, TcnButton, TcnCheckbox, TcnChip, TcnRadioGroup, TcnRadio, TcnSlider],
  template: `
    <tcn-page>
      <tcn-navbar title="Filters">
        @if (showBack()) {
          <button navbar-leading type="button" class="tcn-navbar-back" aria-label="Back" (click)="back.emit()">
            <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
              <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
            <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
          </button>
        }
      </tcn-navbar>
      <div class="flex min-h-full flex-col items-center justify-center gap-4 p-6 pt-20">
        <div class="text-center">
          <h2 class="text-lg font-semibold">Filters</h2>
          <p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">Open the sheet to refine the results.</p>
        </div>
        <button tcnButton type="button" (click)="open.set(true)">
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
          </svg>
          Filters{{ activeCount() ? ' (' + activeCount() + ')' : '' }}
        </button>

        <tcn-bottom-sheet [(open)]="open">
          <div class="flex max-h-[75vh] flex-col">
            <h2 class="shrink-0 pb-3 text-lg font-semibold">Filters</h2>

            <div class="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto pb-2">
              <section>
                <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
                  Sort by
                </h3>
                <tcn-radio-group [(value)]="sort">
                  <tcn-radio value="relevance">Relevance</tcn-radio>
                  <tcn-radio value="newest">Newest</tcn-radio>
                  <tcn-radio value="price-low">Price: Low to High</tcn-radio>
                  <tcn-radio value="price-high">Price: High to Low</tcn-radio>
                </tcn-radio-group>
              </section>

              <section>
                <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
                  Categories
                </h3>
                <div class="flex flex-wrap gap-2">
                  @for (category of categories; track category) {
                    <tcn-chip
                      variant="selectable"
                      [selected]="isCategorySelected(category)"
                      (selectedChange)="toggleCategory(category, $event)"
                    >
                      {{ category }}
                    </tcn-chip>
                  }
                </div>
              </section>

              <section>
                <div class="mb-2 flex items-center justify-between">
                  <h3 class="text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
                    Max price
                  </h3>
                  <span class="text-sm font-medium">
                    {{ maxPrice() >= max ? 'Any' : '$' + maxPrice() }}
                  </span>
                </div>
                <tcn-slider [min]="0" [max]="max" [step]="5" [(value)]="maxPrice" />
              </section>

              <section>
                <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
                  Options
                </h3>
                <div class="flex flex-col gap-3">
                  <tcn-checkbox [(checked)]="freeShipping">Free shipping</tcn-checkbox>
                  <tcn-checkbox [(checked)]="inStock">In stock only</tcn-checkbox>
                  <tcn-checkbox [(checked)]="onSale">On sale</tcn-checkbox>
                </div>
              </section>
            </div>

            <div
              class="mt-1 flex shrink-0 gap-3 border-t border-[color-mix(in_srgb,var(--color-on-surface)_12%,transparent)] pt-3 pb-1"
            >
              <button tcnButton variant="ghost" type="button" class="flex-1" (click)="onReset()">Reset</button>
              <button tcnButton type="button" class="flex-1" (click)="onApply()">
                Apply{{ activeCount() ? ' (' + activeCount() + ')' : '' }}
              </button>
            </div>
          </div>
        </tcn-bottom-sheet>
      </div>
    </tcn-page>
  `,
})
export class TcnFiltersBlock {
  /** Renders a back control in the navbar's leading slot. */
  readonly showBack = input(false, { transform: booleanAttribute });
  /** Fires with the assembled filter state when Apply is tapped. */
  readonly apply = output<FilterState>();
  readonly reset = output<void>();
  readonly back = output<void>();

  protected readonly max = MAX_PRICE;
  protected readonly categories = CATEGORIES;

  protected readonly open = signal(false);
  protected readonly sort = signal('relevance');
  protected readonly selectedCategories = signal<string[]>([]);
  protected readonly maxPrice = signal(MAX_PRICE);
  protected readonly freeShipping = signal(false);
  protected readonly inStock = signal(false);
  protected readonly onSale = signal(false);

  protected readonly activeCount = computed(() => {
    let count = 0;
    if (this.sort() !== 'relevance') {
      count += 1;
    }
    count += this.selectedCategories().length;
    if (this.maxPrice() < MAX_PRICE) {
      count += 1;
    }
    count += [this.freeShipping(), this.inStock(), this.onSale()].filter(Boolean).length;
    return count;
  });

  protected isCategorySelected(category: string): boolean {
    return this.selectedCategories().includes(category);
  }

  protected toggleCategory(category: string, selected: boolean): void {
    this.selectedCategories.update((list) =>
      selected ? [...list, category] : list.filter((item) => item !== category),
    );
  }

  protected onReset(): void {
    this.sort.set('relevance');
    this.selectedCategories.set([]);
    this.maxPrice.set(MAX_PRICE);
    this.freeShipping.set(false);
    this.inStock.set(false);
    this.onSale.set(false);
    this.reset.emit();
  }

  protected onApply(): void {
    this.apply.emit({
      sort: this.sort(),
      categories: this.selectedCategories(),
      maxPrice: this.maxPrice(),
      freeShipping: this.freeShipping(),
      inStock: this.inStock(),
      onSale: this.onSale(),
    });
    this.open.set(false);
  }
}
Source · React
export * from './tcn-filters-block';
import { useState } from 'react';
import { TcnBottomSheet } from '@/components/ui/bottom-sheet';
import { TcnButton } from '@/components/ui/button';
import { TcnCheckbox } from '@/components/ui/checkbox';
import { TcnChip } from '@/components/ui/chip';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnRadio, TcnRadioGroup } from '@/components/ui/radio';
import { TcnSlider } from '@/components/ui/slider';

export interface FilterState {
  sort: string;
  categories: string[];
  maxPrice: number;
  freeShipping: boolean;
  inStock: boolean;
  onSale: boolean;
}

export interface TcnFiltersBlockProps {
  /** Renders a back control in the navbar's leading slot. */
  showBack?: boolean;
  /** Fires with the assembled filter state when Apply is tapped. */
  onApply?(state: FilterState): void;
  onReset?(): void;
  /** Fires when the navbar back control is activated. */
  onBack?(): void;
}

const MAX_PRICE = 200;
const CATEGORIES = ['Shoes', 'Apparel', 'Accessories', 'Bags', 'Electronics', 'Home'];

const sectionHeading = 'mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]';

/**
 * Filter sheet — a bottom-sheet composition plus its trigger. Sort (radio),
 * categories (selectable chips), a price ceiling (slider) and boolean options
 * (checkboxes) feed a live active-filter count shown on the sticky Apply button.
 * `onApply` emits the assembled `FilterState`; `onReset` clears it to defaults.
 */
export function TcnFiltersBlock({ showBack = false, onApply, onReset, onBack }: TcnFiltersBlockProps) {
  const [open, setOpen] = useState(false);
  const [sort, setSort] = useState('relevance');
  const [categories, setCategories] = useState<string[]>([]);
  const [maxPrice, setMaxPrice] = useState(MAX_PRICE);
  const [freeShipping, setFreeShipping] = useState(false);
  const [inStock, setInStock] = useState(false);
  const [onSale, setOnSale] = useState(false);

  const activeCount =
    (sort !== 'relevance' ? 1 : 0) +
    categories.length +
    (maxPrice < MAX_PRICE ? 1 : 0) +
    [freeShipping, inStock, onSale].filter(Boolean).length;

  const toggleCategory = (category: string, selected: boolean) => {
    setCategories((list) => (selected ? [...list, category] : list.filter((item) => item !== category)));
  };

  const reset = () => {
    setSort('relevance');
    setCategories([]);
    setMaxPrice(MAX_PRICE);
    setFreeShipping(false);
    setInStock(false);
    setOnSale(false);
    onReset?.();
  };

  const apply = () => {
    onApply?.({ sort, categories, maxPrice, freeShipping, inStock, onSale });
    setOpen(false);
  };

  return (
    <TcnPage>
      <TcnNavbar
        title="Filters"
        leading={
          showBack ? (
            <button type="button" className="tcn-navbar-back" aria-label="Back" onClick={() => onBack?.()}>
              <svg className="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
                <path d="M10 2 2 10l8 8" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <svg className="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
          ) : undefined
        }
      />
      <div className="flex min-h-full flex-col items-center justify-center gap-4 p-6 pt-20">
        <div className="text-center">
          <h2 className="text-lg font-semibold">Filters</h2>
          <p className="mt-1 text-sm text-[var(--color-on-surface-variant)]">Open the sheet to refine the results.</p>
        </div>
        <TcnButton onClick={() => setOpen(true)}>
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
          </svg>
          Filters{activeCount ? ` (${activeCount})` : ''}
        </TcnButton>

        <TcnBottomSheet open={open} onOpenChange={setOpen} title="Filters">
          <div className="flex max-h-[75vh] flex-col">
            <h2 className="shrink-0 pb-3 text-lg font-semibold">Filters</h2>

            <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto pb-2">
              <section>
                <h3 className={sectionHeading}>Sort by</h3>
                <TcnRadioGroup value={sort} onValueChange={setSort}>
                  <TcnRadio value="relevance">Relevance</TcnRadio>
                  <TcnRadio value="newest">Newest</TcnRadio>
                  <TcnRadio value="price-low">Price: Low to High</TcnRadio>
                  <TcnRadio value="price-high">Price: High to Low</TcnRadio>
                </TcnRadioGroup>
              </section>

              <section>
                <h3 className={sectionHeading}>Categories</h3>
                <div className="flex flex-wrap gap-2">
                  {CATEGORIES.map((category) => (
                    <TcnChip
                      key={category}
                      variant="selectable"
                      selected={categories.includes(category)}
                      onSelectedChange={(selected) => toggleCategory(category, selected)}
                    >
                      {category}
                    </TcnChip>
                  ))}
                </div>
              </section>

              <section>
                <div className="mb-2 flex items-center justify-between">
                  <h3 className={sectionHeading + ' mb-0'}>Max price</h3>
                  <span className="text-sm font-medium">{maxPrice >= MAX_PRICE ? 'Any' : `$${maxPrice}`}</span>
                </div>
                <TcnSlider min={0} max={MAX_PRICE} step={5} value={maxPrice} onValueChange={setMaxPrice} />
              </section>

              <section>
                <h3 className={sectionHeading}>Options</h3>
                <div className="flex flex-col gap-3">
                  <TcnCheckbox checked={freeShipping} onCheckedChange={setFreeShipping}>
                    Free shipping
                  </TcnCheckbox>
                  <TcnCheckbox checked={inStock} onCheckedChange={setInStock}>
                    In stock only
                  </TcnCheckbox>
                  <TcnCheckbox checked={onSale} onCheckedChange={setOnSale}>
                    On sale
                  </TcnCheckbox>
                </div>
              </section>
            </div>

            <div className="mt-1 flex shrink-0 gap-3 border-t border-[color-mix(in_srgb,var(--color-on-surface)_12%,transparent)] pt-3 pb-1">
              <TcnButton variant="ghost" className="flex-1" onClick={reset}>
                Reset
              </TcnButton>
              <TcnButton className="flex-1" onClick={apply}>
                Apply{activeCount ? ` (${activeCount})` : ''}
              </TcnButton>
            </div>
          </div>
        </TcnBottomSheet>
      </div>
    </TcnPage>
  );
}
Source · Vue
<script lang="ts">
export interface FilterState {
  sort: string;
  categories: string[];
  maxPrice: number;
  freeShipping: boolean;
  inStock: boolean;
  onSale: boolean;
}
</script>

<script setup lang="ts">
import { computed, ref } from 'vue';
import { TcnBottomSheet } from '@/components/ui/bottom-sheet';
import { TcnButton } from '@/components/ui/button';
import { TcnCheckbox } from '@/components/ui/checkbox';
import { TcnChip } from '@/components/ui/chip';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnRadio, TcnRadioGroup } from '@/components/ui/radio';
import { TcnSlider } from '@/components/ui/slider';

/**
 * Filter sheet — a bottom-sheet composition plus its trigger. Sort (radio),
 * categories (selectable chips), a price ceiling (slider) and boolean options
 * (checkboxes) feed a live active-filter count shown on the sticky Apply button.
 * `apply` emits the assembled `FilterState`; `reset` clears it to defaults.
 */
withDefaults(
  defineProps<{
    /** Renders a back control in the navbar's leading slot. */
    showBack?: boolean;
  }>(),
  { showBack: false },
);

const emit = defineEmits<{
  /** Fires with the assembled filter state when Apply is tapped. */
  apply: [state: FilterState];
  reset: [];
  /** Fires when the navbar back control is activated. */
  back: [];
}>();

const MAX_PRICE = 200;
const CATEGORIES = ['Shoes', 'Apparel', 'Accessories', 'Bags', 'Electronics', 'Home'];

const sectionHeading = 'mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]';

const open = ref(false);
const sort = ref('relevance');
const categories = ref<string[]>([]);
const maxPrice = ref(MAX_PRICE);
const freeShipping = ref(false);
const inStock = ref(false);
const onSale = ref(false);

const activeCount = computed(
  () =>
    (sort.value !== 'relevance' ? 1 : 0) +
    categories.value.length +
    (maxPrice.value < MAX_PRICE ? 1 : 0) +
    [freeShipping.value, inStock.value, onSale.value].filter(Boolean).length,
);

const toggleCategory = (category: string, selected: boolean): void => {
  categories.value = selected
    ? [...categories.value, category]
    : categories.value.filter((item) => item !== category);
};

const reset = (): void => {
  sort.value = 'relevance';
  categories.value = [];
  maxPrice.value = MAX_PRICE;
  freeShipping.value = false;
  inStock.value = false;
  onSale.value = false;
  emit('reset');
};

const apply = (): void => {
  emit('apply', {
    sort: sort.value,
    categories: categories.value,
    maxPrice: maxPrice.value,
    freeShipping: freeShipping.value,
    inStock: inStock.value,
    onSale: onSale.value,
  });
  open.value = false;
};
</script>

<template>
  <TcnPage>
    <TcnNavbar title="Filters">
      <template v-if="showBack" #leading>
        <button type="button" class="tcn-navbar-back" aria-label="Back" @click="emit('back')">
          <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
            <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        </button>
      </template>
    </TcnNavbar>
    <div class="flex min-h-full flex-col items-center justify-center gap-4 p-6 pt-20">
      <div class="text-center">
        <h2 class="text-lg font-semibold">Filters</h2>
        <p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">Open the sheet to refine the results.</p>
      </div>
      <TcnButton @click="open = true">
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
        </svg>
        Filters{{ activeCount ? ` (${activeCount})` : '' }}
      </TcnButton>

      <TcnBottomSheet v-model:open="open" title="Filters">
        <div class="flex max-h-[75vh] flex-col">
          <h2 class="shrink-0 pb-3 text-lg font-semibold">Filters</h2>

          <div class="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto pb-2">
            <section>
              <h3 :class="sectionHeading">Sort by</h3>
              <TcnRadioGroup v-model="sort">
                <TcnRadio value="relevance">Relevance</TcnRadio>
                <TcnRadio value="newest">Newest</TcnRadio>
                <TcnRadio value="price-low">Price: Low to High</TcnRadio>
                <TcnRadio value="price-high">Price: High to Low</TcnRadio>
              </TcnRadioGroup>
            </section>

            <section>
              <h3 :class="sectionHeading">Categories</h3>
              <div class="flex flex-wrap gap-2">
                <TcnChip
                  v-for="category in CATEGORIES"
                  :key="category"
                  variant="selectable"
                  :selected="categories.includes(category)"
                  @update:selected="(selected) => toggleCategory(category, selected)"
                >
                  {{ category }}
                </TcnChip>
              </div>
            </section>

            <section>
              <div class="mb-2 flex items-center justify-between">
                <h3 :class="sectionHeading + ' mb-0'">Max price</h3>
                <span class="text-sm font-medium">{{ maxPrice >= MAX_PRICE ? 'Any' : `$${maxPrice}` }}</span>
              </div>
              <TcnSlider v-model="maxPrice" :min="0" :max="MAX_PRICE" :step="5" />
            </section>

            <section>
              <h3 :class="sectionHeading">Options</h3>
              <div class="flex flex-col gap-3">
                <TcnCheckbox v-model="freeShipping">Free shipping</TcnCheckbox>
                <TcnCheckbox v-model="inStock">In stock only</TcnCheckbox>
                <TcnCheckbox v-model="onSale">On sale</TcnCheckbox>
              </div>
            </section>
          </div>

          <div class="mt-1 flex shrink-0 gap-3 border-t border-[color-mix(in_srgb,var(--color-on-surface)_12%,transparent)] pt-3 pb-1">
            <TcnButton variant="ghost" class="flex-1" @click="reset">Reset</TcnButton>
            <TcnButton class="flex-1" @click="apply">Apply{{ activeCount ? ` (${activeCount})` : '' }}</TcnButton>
          </div>
        </div>
      </TcnBottomSheet>
    </div>
  </TcnPage>
</template>
export { default as TcnFiltersBlock } from './TcnFiltersBlock.vue';
export type { FilterState } from './TcnFiltersBlock.vue';

Last updated on July 24, 2026

Was this page helpful?