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';