Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
EaseMotion CSS - Filter Chip Group
Provides semantic layout styling and the Smooth Select Transitions.
*/

.ease-chip-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

/* Base Chip Styling */
.ease-filter-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 16px;
border-radius: 9999px; /* Pill shape */
border: 1px solid #e2e8f0;
background-color: #f8fafc;
color: #475569;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
outline: none;

/* Smooth hardware-accelerated transitions for colors and box-shadow */
transition:
background-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s ease,
transform 0.1s ease; /* For active click state */
}

/* Hover and Focus States */
.ease-filter-chip:hover {
background-color: #f1f5f9;
border-color: #cbd5e1;
color: #0f172a;
}

.ease-filter-chip:focus-visible {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
border-color: #3b82f6;
}

/* Active Click state for tactile feedback */
.ease-filter-chip:active {
transform: scale(0.96);
}

/* Icon Wrapper for width transitions */
.ease-chip-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;

/* Initial state: Hidden (width 0, margin 0, opacity 0) */
width: 0;
margin-right: 0;
opacity: 0;
transform: scale(0.5);

/* Smoothly animate width and opacity */
transition:
width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
margin-right 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 0.2s ease-in-out,
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.ease-chip-check-icon {
width: 16px;
height: 16px;
color: #ffffff;
}

/*
========================================
SELECTED STATE
========================================
*/
.ease-chip-selected {
background-color: #3b82f6;
border-color: #3b82f6;
color: #ffffff;
}

.ease-chip-selected:hover {
background-color: #2563eb;
border-color: #2563eb;
color: #ffffff;
}

/* When selected, expand the wrapper and fade/scale in the check icon */
.ease-chip-selected .ease-chip-icon-wrapper {
width: 16px;
margin-right: 6px; /* Space between icon and label */
opacity: 1;
transform: scale(1);
}

/* Accessibility: Respect Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.ease-filter-chip {
transition: none;
}
.ease-filter-chip:active {
transform: none;
}
.ease-chip-icon-wrapper {
transition: none;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import './FilterChipGroup.css';

/**
* Filter Chip Group with Smooth Select Transitions
*
* A reusable React component that renders a group of interactive
* filter chips. It handles multi-selection state and utilizes
* pure CSS transitions to smoothly animate background colors,
* text colors, and the entrance/exit of a checkmark icon.
*
* @param {Array<Object>} filters - Array of filter objects { id: string, label: string }
* @param {Array<string>} initialSelected - Array of filter IDs that are selected by default
* @param {function} onChange - Callback returning the array of currently selected IDs
*/
const FilterChipGroup = ({
filters = [
{ id: 'f1', label: 'All' },
{ id: 'f2', label: 'Design' },
{ id: 'f3', label: 'Development' },
{ id: 'f4', label: 'Marketing' },
{ id: 'f5', label: 'Product' }
],
initialSelected = ['f1'],
onChange = () => {}
}) => {
const [selectedIds, setSelectedIds] = useState(initialSelected);

const toggleFilter = (id) => {
let newSelection;
if (selectedIds.includes(id)) {
// Deselect
newSelection = selectedIds.filter((selectedId) => selectedId !== id);
} else {
// Select
newSelection = [...selectedIds, id];
}
setSelectedIds(newSelection);
if (onChange) {
onChange(newSelection);
}
};

return (
<div
className="ease-chip-group"
role="group"
aria-label="Filter Options"
>
{filters.map((filter) => {
const isSelected = selectedIds.includes(filter.id);

return (
<button
key={filter.id}
type="button"
className={`ease-filter-chip ${isSelected ? 'ease-chip-selected' : ''}`}
aria-pressed={isSelected}
onClick={() => toggleFilter(filter.id)}
>
<span className="ease-chip-icon-wrapper">
<svg
className="ease-chip-check-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
<span className="ease-chip-label">{filter.label}</span>
</button>
);
})}
</div>
);
};

export default FilterChipGroup;
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# React Filter Chip Group with Smooth Select Transitions

A highly interactive, accessible React component that renders a group of multi-selectable filter chips (pill tags). When a user toggles a chip, it utilizes smooth CSS transitions to gracefully animate background colors and seamlessly expand a checkmark icon using a bouncy cubic-bezier curve.

## Files
- `FilterChipGroup.jsx` – The core React component that handles the multi-selection state array and renders the SVG checkmarks.
- `FilterChipGroup.css` – The stylesheet that powers the pill layout, hover states, and the 60fps width/opacity transitions.

## How it works
1. **State Management**: The component tracks an array of `selectedIds`. Clicking a chip toggles its ID in and out of the array.
2. **Dynamic Class Switching**: Selected chips receive the `.ease-chip-selected` class, which triggers the visual change.
3. **The Entrance Animation**: The checkmark icon is wrapped in `.ease-chip-icon-wrapper` which defaults to `width: 0` and `opacity: 0`. When `.ease-chip-selected` is applied to the parent, CSS immediately transitions the wrapper's `width` to `16px` and `opacity` to `1` using a spring-like `cubic-bezier` timing function. This creates the illusion that the text smoothly pushes aside to make room for the checkmark.

## Installation & Usage

1. Copy both `FilterChipGroup.jsx` and `FilterChipGroup.css` into your React project.
2. Import the component and pass it an array of filter options.

```jsx
import React, { useState } from 'react';
import FilterChipGroup from './components/FilterChipGroup';

const App = () => {
const [activeFilters, setActiveFilters] = useState(['tag-react']);

const blogTags = [
{ id: 'tag-react', label: 'React' },
{ id: 'tag-css', label: 'CSS Animations' },
{ id: 'tag-ui', label: 'UI Design' },
{ id: 'tag-a11y', label: 'Accessibility' }
];

return (
<div style={{ padding: '2rem' }}>
<FilterChipGroup
filters={blogTags}
initialSelected={['tag-react']}
onChange={(newSelection) => setActiveFilters(newSelection)}
/>

<p style={{ marginTop: '2rem', color: '#64748b' }}>
Active Filters: {activeFilters.join(', ')}
</p>
</div>
);
};

export default App;
```

## Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `filters` | `Array<Object>` | *(Demo items)* | Array of options to render. Shape: `{ id: string, label: string }`. |
| `initialSelected` | `Array<string>` | `['f1']` | Array of `id` strings that should be active on initial mount. |
| `onChange` | `function` | `() => {}` | Callback fired whenever the selection changes. Receives the updated array of selected IDs. |

## Accessibility (prefers-reduced-motion)
The component uses native `<button type="button">` elements ensuring full keyboard tab-navigation support. It properly toggles the `aria-pressed={true/false}` attribute so screen readers announce the selection state correctly. It implements a strict `@media (prefers-reduced-motion: reduce)` block in the CSS that strips away the width-expanding animation and background transitions to ensure compatibility with vestibular sensitivity settings.

## Why it fits EaseMotion CSS
EaseMotion champions high-fidelity micro-interactions that don't rely on heavy JavaScript animation libraries. Checkmark entrance animations are frequently built using bloated JS tools (like Framer Motion) which negatively impact bundle size. By cleverly transitioning the `width` and `margin` properties of the icon wrapper via native CSS, this component delivers a premium, highly tactile "push-aside" interaction that performs perfectly with zero external dependencies.
Loading