diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index d6e7a0b..7484856 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -57,6 +57,7 @@ export default defineConfig({
{ label: "Borders", slug: "reference/borders" },
{ label: "Outlines", slug: "reference/outlines" },
{ label: "Shadows & Elevation", slug: "reference/shadows" },
+ { label: "Filters", slug: "reference/filters" },
{ label: "Aspect Ratio", slug: "reference/aspect-ratio" },
{ label: "Transforms", slug: "reference/transforms" },
{ label: "Sizing", slug: "reference/sizing" },
diff --git a/docs/src/content/docs/reference/filters.md b/docs/src/content/docs/reference/filters.md
new file mode 100644
index 0000000..a05c633
--- /dev/null
+++ b/docs/src/content/docs/reference/filters.md
@@ -0,0 +1,129 @@
+---
+title: Filters
+description: Apply native visual filters to views
+---
+
+Apply composable visual filters with React Native's native `filter` style property.
+
+> **Note**: Filters require a React Native version and renderer that support the `filter` style property. Applying a filter also implies `overflow: hidden`, so descendants are clipped to the view's bounds.
+
+## Platform Support
+
+| Utility | iOS | Android |
+|---------|-----|---------|
+| `brightness-*` | ✅ | ✅ |
+| `blur-*` | ❌ | ✅ |
+| `contrast-*` | ❌ | ✅ |
+| `drop-shadow-*` | ❌ | ✅ |
+| `grayscale-*` | ❌ | ✅ |
+| `hue-rotate-*` | ❌ | ✅ |
+| `invert-*` | ❌ | ✅ |
+| `saturate-*` | ❌ | ✅ |
+| `sepia-*` | ❌ | ✅ |
+
+React Native also supports filter-level opacity on iOS and Android, but Tailwind's `opacity-*` class already maps to the native `opacity` style property in this package. It remains a regular opacity utility to avoid an ambiguous class collision.
+
+## Percentage Filters
+
+Brightness, contrast, grayscale, invert, saturate, and sepia use percentage-based numeric utilities:
+
+```tsx
+ // { brightness: 1.01 }
+ // { contrast: 1.25 }
+ // { grayscale: 0.5 }
+ // { invert: 0.25 }
+ // { saturate: 1.5 }
+ // { sepia: 0.75 }
+```
+
+The full-effect forms are also supported:
+
+```tsx
+
+```
+
+Use bracket syntax for raw React Native amounts or explicit percentages:
+
+```tsx
+ // { brightness: 1.01 }
+ // { contrast: 0.8 }
+```
+
+## Blur
+
+Blur uses Tailwind's pixel scale:
+
+```tsx
+ // { blur: 4 }
+ // { blur: 8 }
+ // { blur: 12 }
+ // { blur: 16 }
+ // { blur: 24 }
+ // { blur: 40 }
+ // { blur: 64 }
+
+```
+
+Arbitrary blur values accept non-negative pixels:
+
+```tsx
+
+
+```
+
+## Hue Rotation
+
+Numeric hue rotation utilities use degrees and support negative values:
+
+```tsx
+ // { hueRotate: '45deg' }
+ // { hueRotate: '-90deg' }
+```
+
+Arbitrary values support `deg` and `rad` angles:
+
+```tsx
+
+
+```
+
+## Drop Shadow
+
+Drop shadows use Tailwind's `xs` through `2xl` presets and operate on the rendered alpha mask:
+
+```tsx
+
+
+
+
+```
+
+Arbitrary drop shadows accept X offset, Y offset, optional blur, and a preset, custom, or hex color:
+
+```tsx
+
+
+```
+
+Standalone `drop-shadow-{color}` utilities are not emitted because React Native requires a complete `dropShadow` object rather than Tailwind's separate CSS color variable. Put the color in an arbitrary drop shadow instead.
+
+## Combining Filters
+
+Different filter utilities compile into one ordered native filter array. If the same filter type appears more than once, the last value wins:
+
+```tsx
+
+// filter: [{ blur: 8 }, { brightness: 1.1 }, { contrast: 1.25 }]
+
+
+// filter: [{ brightness: 0.9 }]
+```
+
+Use `filter-none` to clear all composable filters on the same element. It wins regardless of class-string order, matching Tailwind's generated CSS:
+
+```tsx
+ // filter: []
+ // filter: []
+```
+
+Unsupported units, malformed drop shadows, and negative values where React Native requires non-negative amounts are ignored with a development warning.
diff --git a/src/babel/plugin/visitors/className.test.ts b/src/babel/plugin/visitors/className.test.ts
index 0f9a484..d297b9a 100644
--- a/src/babel/plugin/visitors/className.test.ts
+++ b/src/babel/plugin/visitors/className.test.ts
@@ -23,6 +23,38 @@ describe("className visitor - basic transformation", () => {
expect(output).toContain("style:");
});
+ it("should transform brightness filters", () => {
+ const input = `
+ import { View } from 'react-native';
+ export function Component() {
+ return ;
+ }
+ `;
+
+ const output = transform(input, undefined, true);
+
+ expect(output).not.toContain("className");
+ expect(output).toContain("_brightness_1_01");
+ expect(output).toMatch(/filter:\s*\[\s*{\s*brightness:\s*1\.01/);
+ });
+
+ it("should transform composed and structured filters", () => {
+ const input = `
+ import { View } from 'react-native';
+ export function Component() {
+ return ;
+ }
+ `;
+
+ const output = transform(input, undefined, true);
+
+ expect(output).not.toContain("className");
+ expect(output).toMatch(/blur:\s*8/);
+ expect(output).toMatch(/hueRotate:\s*["']45deg["']/);
+ expect(output).toMatch(/dropShadow:\s*{\s*offsetX:\s*0,\s*offsetY:\s*4/);
+ expect(output).toContain('color: "#00000080"');
+ });
+
it("should work with both tw and className in same file", () => {
const input = `
import { tw } from '@mgcrea/react-native-tailwind';
diff --git a/src/index.ts b/src/index.ts
index 0bd46f6..d533065 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,7 +13,7 @@ export { mergeStyles } from "./utils/mergeStyles";
export { generateStyleKey } from "./utils/styleKey";
// Re-export types
-export type { StyleObject } from "./types/core";
+export type { DropShadowStyle, FilterStyle, StyleObject } from "./types/core";
export type { NativeStyle, TwStyle } from "./types/runtime";
// Re-export colors
@@ -24,6 +24,7 @@ export {
parseAspectRatio,
parseBorder,
parseColor,
+ parseFilter,
parseLayout,
parseOutline,
parsePlaceholderClass,
@@ -37,6 +38,7 @@ export {
// Re-export constants for customization
export { ASPECT_RATIO_PRESETS } from "./parser/aspectRatio";
export { COLORS } from "./parser/colors";
+export { BLUR_SCALE, DROP_SHADOW_SCALE } from "./parser/filters";
export { INSET_SCALE, Z_INDEX_SCALE } from "./parser/layout";
export { SHADOW_SCALE } from "./parser/shadows";
export { SIZE_PERCENTAGES, SIZE_SCALE } from "./parser/sizing";
diff --git a/src/parser/filters.test.ts b/src/parser/filters.test.ts
new file mode 100644
index 0000000..704cac2
--- /dev/null
+++ b/src/parser/filters.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from "vitest";
+
+import { applyOpacity } from "../utils/colorUtils";
+import { BLUR_SCALE, DROP_SHADOW_SCALE, parseFilter } from "./filters";
+import { parseClassName } from "./index";
+
+describe("parseFilter - percentage filters", () => {
+ it("should parse numeric values as percentages", () => {
+ expect(parseFilter("brightness-101")).toEqual({ filter: [{ brightness: 1.01 }] });
+ expect(parseFilter("contrast-125")).toEqual({ filter: [{ contrast: 1.25 }] });
+ expect(parseFilter("grayscale-50")).toEqual({ filter: [{ grayscale: 0.5 }] });
+ expect(parseFilter("invert-25")).toEqual({ filter: [{ invert: 0.25 }] });
+ expect(parseFilter("saturate-150")).toEqual({ filter: [{ saturate: 1.5 }] });
+ expect(parseFilter("sepia-75")).toEqual({ filter: [{ sepia: 0.75 }] });
+ });
+
+ it("should parse bare full-effect utilities", () => {
+ expect(parseFilter("grayscale")).toEqual({ filter: [{ grayscale: 1 }] });
+ expect(parseFilter("invert")).toEqual({ filter: [{ invert: 1 }] });
+ expect(parseFilter("sepia")).toEqual({ filter: [{ sepia: 1 }] });
+ });
+
+ it("should parse arbitrary numeric values", () => {
+ expect(parseFilter("brightness-[1.01]")).toEqual({ filter: [{ brightness: 1.01 }] });
+ expect(parseFilter("contrast-[.5]")).toEqual({ filter: [{ contrast: 0.5 }] });
+ expect(parseFilter("saturate-[2]")).toEqual({ filter: [{ saturate: 2 }] });
+ });
+
+ it("should parse arbitrary percentage values", () => {
+ expect(parseFilter("brightness-[80%]")).toEqual({ filter: [{ brightness: 0.8 }] });
+ expect(parseFilter("grayscale-[25%]")).toEqual({ filter: [{ grayscale: 0.25 }] });
+ expect(parseFilter("sepia-[101%]")).toEqual({ filter: [{ sepia: 1.01 }] });
+ });
+});
+
+describe("parseFilter - blur", () => {
+ it("should expose the Tailwind blur scale", () => {
+ expect(BLUR_SCALE).toEqual({ none: 0, xs: 4, sm: 8, md: 12, lg: 16, xl: 24, "2xl": 40, "3xl": 64 });
+ });
+
+ it("should parse blur presets", () => {
+ expect(parseFilter("blur-none")).toEqual({ filter: [{ blur: 0 }] });
+ expect(parseFilter("blur-xs")).toEqual({ filter: [{ blur: 4 }] });
+ expect(parseFilter("blur-sm")).toEqual({ filter: [{ blur: 8 }] });
+ expect(parseFilter("blur-3xl")).toEqual({ filter: [{ blur: 64 }] });
+ });
+
+ it("should parse arbitrary pixel blur values", () => {
+ expect(parseFilter("blur-[2px]")).toEqual({ filter: [{ blur: 2 }] });
+ expect(parseFilter("blur-[2.5]")).toEqual({ filter: [{ blur: 2.5 }] });
+ });
+});
+
+describe("parseFilter - hue rotate", () => {
+ it("should parse degree utilities", () => {
+ expect(parseFilter("hue-rotate-0")).toEqual({ filter: [{ hueRotate: "0deg" }] });
+ expect(parseFilter("hue-rotate-45")).toEqual({ filter: [{ hueRotate: "45deg" }] });
+ expect(parseFilter("-hue-rotate-90")).toEqual({ filter: [{ hueRotate: "-90deg" }] });
+ });
+
+ it("should parse arbitrary deg and rad angles", () => {
+ expect(parseFilter("hue-rotate-[22.5deg]")).toEqual({ filter: [{ hueRotate: "22.5deg" }] });
+ expect(parseFilter("hue-rotate-[-45deg]")).toEqual({ filter: [{ hueRotate: "-45deg" }] });
+ expect(parseFilter("hue-rotate-[0.5rad]")).toEqual({ filter: [{ hueRotate: "0.5rad" }] });
+ });
+});
+
+describe("parseFilter - drop shadow", () => {
+ it("should expose and parse Tailwind drop-shadow presets", () => {
+ expect(Object.keys(DROP_SHADOW_SCALE)).toEqual(["xs", "sm", "md", "lg", "xl", "2xl", "none"]);
+ expect(parseFilter("drop-shadow-md")).toEqual({ filter: [{ dropShadow: DROP_SHADOW_SCALE.md }] });
+ expect(parseFilter("drop-shadow-none")).toEqual({ filter: [{ dropShadow: DROP_SHADOW_SCALE.none }] });
+ });
+
+ it("should apply opacity modifiers to preset drop shadows", () => {
+ expect(parseFilter("drop-shadow-xl/50")).toEqual({
+ filter: [{ dropShadow: { ...DROP_SHADOW_SCALE.xl, color: applyOpacity("#000000", 50) } }],
+ });
+ });
+
+ it("should parse arbitrary drop shadows", () => {
+ expect(parseFilter("drop-shadow-[0_4px_4px_#00000080]")).toEqual({
+ filter: [{ dropShadow: { offsetX: 0, offsetY: 4, standardDeviation: 4, color: "#00000080" } }],
+ });
+ expect(parseFilter("drop-shadow-[-2px_3px_#ff0000]")).toEqual({
+ filter: [{ dropShadow: { offsetX: -2, offsetY: 3, color: "#ff0000" } }],
+ });
+ });
+
+ it("should resolve custom colors in arbitrary drop shadows", () => {
+ expect(parseFilter("drop-shadow-[0_2px_3px_brand]", { brand: "#123456" })).toEqual({
+ filter: [{ dropShadow: { offsetX: 0, offsetY: 2, standardDeviation: 3, color: "#123456" } }],
+ });
+ });
+});
+
+describe("parseFilter - composition and validation", () => {
+ it("should compose different filter functions", () => {
+ expect(parseClassName("blur-sm brightness-110 contrast-125 saturate-150")).toEqual({
+ filter: [{ blur: 8 }, { brightness: 1.1 }, { contrast: 1.25 }, { saturate: 1.5 }],
+ });
+ });
+
+ it("should use the last utility for duplicate filter functions", () => {
+ expect(parseClassName("brightness-110 blur-sm brightness-90")).toEqual({
+ filter: [{ blur: 8 }, { brightness: 0.9 }],
+ });
+ });
+
+ it("should clear preceding filters with filter-none", () => {
+ expect(parseClassName("brightness-110 blur-sm filter-none")).toEqual({ filter: [] });
+ });
+
+ it("should let filter-none win regardless of class order", () => {
+ expect(parseClassName("filter-none brightness-110 blur-sm")).toEqual({ filter: [] });
+ });
+
+ it("should compose filters in Tailwind's canonical order", () => {
+ expect(parseClassName("drop-shadow-md sepia hue-rotate-45 brightness-110 blur-sm")).toEqual({
+ filter: [
+ { blur: 8 },
+ { brightness: 1.1 },
+ { hueRotate: "45deg" },
+ { sepia: 1 },
+ { dropShadow: DROP_SHADOW_SCALE.md },
+ ],
+ });
+ });
+
+ it("should compose native opacity with filters without a class collision", () => {
+ expect(parseClassName("brightness-[1.01] opacity-80")).toEqual({
+ filter: [{ brightness: 1.01 }],
+ opacity: 0.8,
+ });
+ });
+
+ it("should reject unsupported or malformed values", () => {
+ expect(parseFilter("brightness-[-1]")).toBeNull();
+ expect(parseFilter("contrast-[1.01px]")).toBeNull();
+ expect(parseFilter("blur-[-1px]")).toBeNull();
+ expect(parseFilter("blur-[50%]")).toBeNull();
+ expect(parseFilter("hue-rotate-[45turn]")).toBeNull();
+ expect(parseFilter("drop-shadow-[0_4px_-2px_#000000]")).toBeNull();
+ expect(parseFilter("drop-shadow-[invalid]")).toBeNull();
+ expect(parseFilter("brightness-auto")).toBeNull();
+ });
+
+ it("should return null for unrelated classes", () => {
+ expect(parseFilter("opacity-50")).toBeNull();
+ expect(parseFilter("bg-white")).toBeNull();
+ expect(parseFilter("")).toBeNull();
+ });
+});
diff --git a/src/parser/filters.ts b/src/parser/filters.ts
new file mode 100644
index 0000000..0b24f14
--- /dev/null
+++ b/src/parser/filters.ts
@@ -0,0 +1,213 @@
+/**
+ * Filter utilities supported by React Native's filter style property.
+ */
+
+import type { StyleObject } from "../types";
+import type { DropShadowStyle, FilterStyle } from "../types/core";
+import { COLORS, applyOpacity, parseColorValue } from "../utils/colorUtils";
+
+const NUMBER_PATTERN = String.raw`(?:\d+(?:\.\d*)?|\.\d+)`;
+
+export const BLUR_SCALE: Record = {
+ none: 0,
+ xs: 4,
+ sm: 8,
+ md: 12,
+ lg: 16,
+ xl: 24,
+ "2xl": 40,
+ "3xl": 64,
+};
+
+export const DROP_SHADOW_SCALE: Record = {
+ xs: { offsetX: 0, offsetY: 1, standardDeviation: 1, color: applyOpacity(COLORS.black, 5) },
+ sm: { offsetX: 0, offsetY: 1, standardDeviation: 2, color: applyOpacity(COLORS.black, 15) },
+ md: { offsetX: 0, offsetY: 3, standardDeviation: 3, color: applyOpacity(COLORS.black, 12) },
+ lg: { offsetX: 0, offsetY: 4, standardDeviation: 4, color: applyOpacity(COLORS.black, 15) },
+ xl: { offsetX: 0, offsetY: 9, standardDeviation: 7, color: applyOpacity(COLORS.black, 10) },
+ "2xl": { offsetX: 0, offsetY: 25, standardDeviation: 25, color: applyOpacity(COLORS.black, 15) },
+ none: { offsetX: 0, offsetY: 0, standardDeviation: 0, color: "transparent" },
+};
+
+type PercentageFilterName = "brightness" | "contrast" | "grayscale" | "invert" | "saturate" | "sepia";
+
+/**
+ * Parse a Tailwind percentage-based filter amount.
+ * Named numeric utilities are percentages (contrast-125 -> 1.25), while
+ * bracketed numbers are raw React Native amounts (contrast-[1.25] -> 1.25).
+ */
+function parsePercentageAmount(value: string, filterName: PercentageFilterName): number | null {
+ const arbitraryMatch = value.match(new RegExp(`^\\[(${NUMBER_PATTERN})(%)?\\]$`));
+ if (arbitraryMatch) {
+ const amount = Number.parseFloat(arbitraryMatch[1]);
+ return arbitraryMatch[2] === "%" ? amount / 100 : amount;
+ }
+
+ if (value.startsWith("[") && value.endsWith("]")) {
+ /* v8 ignore next 5 */
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ `[react-native-tailwind] Invalid arbitrary ${filterName} value: ${value}. Only non-negative numbers and percentages are supported (e.g., [1.01], [80%]).`,
+ );
+ }
+ return null;
+ }
+
+ if (new RegExp(`^${NUMBER_PATTERN}$`).test(value)) {
+ return Number.parseFloat(value) / 100;
+ }
+
+ return null;
+}
+
+function parseBlurAmount(value: string): number | null {
+ const scaleValue = BLUR_SCALE[value];
+ if (scaleValue !== undefined) {
+ return scaleValue;
+ }
+
+ const arbitraryMatch = value.match(new RegExp(`^\\[(${NUMBER_PATTERN})(?:px)?\\]$`));
+ if (arbitraryMatch) {
+ return Number.parseFloat(arbitraryMatch[1]);
+ }
+
+ if (value.startsWith("[") && value.endsWith("]")) {
+ /* v8 ignore next 5 */
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ `[react-native-tailwind] Invalid arbitrary blur value: ${value}. Only non-negative pixel values are supported (e.g., [2px], [2.5]).`,
+ );
+ }
+ }
+
+ return null;
+}
+
+function parseHueRotateAmount(value: string, isNegative: boolean): string | null {
+ const arbitraryMatch = value.match(new RegExp(`^\\[(-?${NUMBER_PATTERN})(deg|rad)\\]$`));
+ if (arbitraryMatch) {
+ const amount = Number.parseFloat(arbitraryMatch[1]);
+ return `${isNegative ? -amount : amount}${arbitraryMatch[2]}`;
+ }
+
+ if (value.startsWith("[") && value.endsWith("]")) {
+ /* v8 ignore next 5 */
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ `[react-native-tailwind] Invalid arbitrary hue-rotate value: ${value}. Only deg and rad angles are supported (e.g., [45deg], [0.5rad]).`,
+ );
+ }
+ return null;
+ }
+
+ if (new RegExp(`^${NUMBER_PATTERN}$`).test(value)) {
+ const amount = Number.parseFloat(value);
+ return `${isNegative ? -amount : amount}deg`;
+ }
+
+ return null;
+}
+
+function parseDropShadowLength(value: string, allowNegative: boolean): number | null {
+ const match = value.match(new RegExp(`^(${allowNegative ? "-?" : ""}${NUMBER_PATTERN})(?:px)?$`));
+ return match ? Number.parseFloat(match[1]) : null;
+}
+
+function parseDropShadowAmount(value: string, customColors?: Record): DropShadowStyle | null {
+ const opacityMatch = value.match(/^(.+)\/(\d+)$/);
+ if (opacityMatch) {
+ const preset = DROP_SHADOW_SCALE[opacityMatch[1]];
+ const opacity = Number.parseInt(opacityMatch[2], 10);
+ if (preset && opacity >= 0 && opacity <= 100) {
+ return { ...preset, color: applyOpacity(COLORS.black, opacity) };
+ }
+ return null;
+ }
+
+ const preset = DROP_SHADOW_SCALE[value];
+ if (preset) {
+ return preset;
+ }
+
+ if (value.startsWith("[") && value.endsWith("]")) {
+ const parts = value.slice(1, -1).split("_");
+ if (parts.length === 3 || parts.length === 4) {
+ const offsetX = parseDropShadowLength(parts[0], true);
+ const offsetY = parseDropShadowLength(parts[1], true);
+ const hasDeviation = parts.length === 4;
+ const standardDeviation = hasDeviation ? parseDropShadowLength(parts[2], false) : undefined;
+ const colorToken = parts[parts.length - 1];
+ const colorKey = colorToken.startsWith("#") ? `[${colorToken}]` : colorToken;
+ const color = parseColorValue(colorKey, customColors);
+
+ if (
+ offsetX !== null &&
+ offsetY !== null &&
+ (!hasDeviation || standardDeviation !== null) &&
+ color !== null
+ ) {
+ return { offsetX, offsetY, standardDeviation: standardDeviation ?? undefined, color };
+ }
+ }
+
+ /* v8 ignore next 5 */
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ `[react-native-tailwind] Invalid arbitrary drop-shadow value: ${value}. Use [offsetX_offsetY_blur_color] with pixel lengths and a supported color (e.g., [0_4px_4px_#00000080]).`,
+ );
+ }
+ }
+
+ return null;
+}
+
+function percentageFilterStyle(filterName: PercentageFilterName, amount: number): StyleObject {
+ return { filter: [{ [filterName]: amount } as FilterStyle] };
+}
+
+/**
+ * Parse filter classes.
+ * @param cls - The class name to parse
+ */
+export function parseFilter(cls: string, customColors?: Record): StyleObject | null {
+ if (cls === "filter-none") {
+ return { filter: [] };
+ }
+
+ if (cls.startsWith("blur-")) {
+ const amount = parseBlurAmount(cls.substring(5));
+ if (amount !== null) {
+ return { filter: [{ blur: amount }] };
+ }
+ }
+
+ if (cls.startsWith("drop-shadow-")) {
+ const dropShadow = parseDropShadowAmount(cls.substring(12), customColors);
+ if (dropShadow !== null) {
+ return { filter: [{ dropShadow }] };
+ }
+ }
+
+ const hueRotateMatch = cls.match(/^(-?)hue-rotate-(.+)$/);
+ if (hueRotateMatch) {
+ const amount = parseHueRotateAmount(hueRotateMatch[2], hueRotateMatch[1] === "-");
+ if (amount !== null) {
+ return { filter: [{ hueRotate: amount }] };
+ }
+ }
+
+ if (cls === "grayscale" || cls === "invert" || cls === "sepia") {
+ return percentageFilterStyle(cls, 1);
+ }
+
+ const percentageFilterMatch = cls.match(/^(brightness|contrast|grayscale|invert|saturate|sepia)-(.+)$/);
+ if (percentageFilterMatch) {
+ const filterName = percentageFilterMatch[1] as PercentageFilterName;
+ const amount = parsePercentageAmount(percentageFilterMatch[2], filterName);
+ if (amount !== null) {
+ return percentageFilterStyle(filterName, amount);
+ }
+ }
+
+ return null;
+}
diff --git a/src/parser/index.ts b/src/parser/index.ts
index 0e89fcc..3b99b92 100644
--- a/src/parser/index.ts
+++ b/src/parser/index.ts
@@ -8,6 +8,7 @@ import { mergeStyles } from "../utils/mergeStyles";
import { parseAspectRatio } from "./aspectRatio";
import { parseBorder } from "./borders";
import { parseColor } from "./colors";
+import { parseFilter } from "./filters";
import { parseLayout } from "./layout";
import { parseOutline } from "./outline";
import { parseShadow } from "./shadows";
@@ -41,6 +42,12 @@ export function parseClassName(className: string, customTheme?: CustomTheme): St
mergeStyles(style, parsedStyle);
}
+ // Tailwind emits filter-none after composable filter utilities, so it wins
+ // regardless of the order of classes in markup.
+ if (classes.includes("filter-none")) {
+ style.filter = [];
+ }
+
return style;
}
@@ -63,6 +70,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject
(cls: string) => parseTypography(cls, customTheme?.fontFamily, customTheme?.fontSize),
(cls: string) => parseSizing(cls, customTheme?.spacing),
(cls: string) => parseShadow(cls, customTheme?.colors),
+ (cls: string) => parseFilter(cls, customTheme?.colors),
parseAspectRatio,
(cls: string) => parseTransform(cls, customTheme?.spacing),
];
@@ -87,6 +95,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject
export { parseAspectRatio } from "./aspectRatio";
export { parseBorder } from "./borders";
export { parseColor } from "./colors";
+export { parseFilter } from "./filters";
export { parseLayout } from "./layout";
export { parseOutline } from "./outline";
export { parsePlaceholderClass, parsePlaceholderClasses } from "./placeholder";
diff --git a/src/types/core.ts b/src/types/core.ts
index 1ea60c5..0c592f3 100644
--- a/src/types/core.ts
+++ b/src/types/core.ts
@@ -18,9 +18,26 @@ export type TransformStyle =
| { perspective?: number };
export type ShadowOffsetStyle = { width: number; height: number };
+export type DropShadowStyle = {
+ offsetX: number;
+ offsetY: number;
+ standardDeviation?: number;
+ color?: string;
+};
+export type FilterStyle =
+ | { brightness: number }
+ | { blur: number }
+ | { contrast: number }
+ | { grayscale: number }
+ | { hueRotate: string }
+ | { invert: number }
+ | { saturate: number }
+ | { sepia: number }
+ | { dropShadow: DropShadowStyle };
export type StyleObject = {
- [key: string]: string | number | ShadowOffsetStyle | TransformStyle[] | undefined;
+ [key: string]: string | number | ShadowOffsetStyle | TransformStyle[] | FilterStyle[] | undefined;
+ filter?: FilterStyle[];
shadowOffset?: ShadowOffsetStyle;
transform?: TransformStyle[];
};
diff --git a/src/utils/mergeStyles.test.ts b/src/utils/mergeStyles.test.ts
index 6a1a30b..c725487 100644
--- a/src/utils/mergeStyles.test.ts
+++ b/src/utils/mergeStyles.test.ts
@@ -102,6 +102,42 @@ describe("mergeStyles", () => {
});
});
+ describe("filter array merging", () => {
+ it("should compose different filter functions", () => {
+ const target = { filter: [{ brightness: 1.1 }] };
+ const source = { filter: [{ blur: 8 }] };
+ expect(mergeStyles(target, source)).toEqual({
+ filter: [{ blur: 8 }, { brightness: 1.1 }],
+ });
+ });
+
+ it("should replace the same filter function with the last value", () => {
+ const target = { filter: [{ brightness: 1.1 }, { blur: 8 }] };
+ const source = { filter: [{ brightness: 0.9 }] };
+ expect(mergeStyles(target, source)).toEqual({
+ filter: [{ blur: 8 }, { brightness: 0.9 }],
+ });
+ });
+
+ it("should clear filters when the source array is empty", () => {
+ const target = { filter: [{ brightness: 1.1 }, { blur: 8 }] };
+ expect(mergeStyles(target, { filter: [] })).toEqual({ filter: [] });
+ });
+
+ it("should use Tailwind's canonical order for composed filters", () => {
+ const target = { filter: [{ dropShadow: { offsetX: 0, offsetY: 1 } }, { sepia: 1 }] };
+ const source = { filter: [{ hueRotate: "45deg" }, { contrast: 1.25 }] };
+ expect(mergeStyles(target, source)).toEqual({
+ filter: [
+ { contrast: 1.25 },
+ { hueRotate: "45deg" },
+ { sepia: 1 },
+ { dropShadow: { offsetX: 0, offsetY: 1 } },
+ ],
+ });
+ });
+ });
+
describe("mixed properties", () => {
it("should handle mix of standard and transform properties", () => {
const target = { margin: 4, transform: [{ rotate: "45deg" }] };
diff --git a/src/utils/mergeStyles.ts b/src/utils/mergeStyles.ts
index 043a6a3..f66c44f 100644
--- a/src/utils/mergeStyles.ts
+++ b/src/utils/mergeStyles.ts
@@ -1,9 +1,9 @@
/**
* Smart merge utility for StyleObject values
- * Handles transform arrays with "last wins" semantics for same transform types
+ * Handles transform and filter arrays with "last wins" semantics for duplicate function types
*/
-import type { StyleObject, TransformStyle } from "../types/core";
+import type { FilterStyle, StyleObject, TransformStyle } from "../types/core";
/**
* Get the transform type key from a transform object
@@ -49,8 +49,60 @@ function mergeTransforms(target: TransformStyle[], source: TransformStyle[]): Tr
return result;
}
+function getFilterType(filter: FilterStyle): string {
+ return Object.keys(filter)[0];
+}
+
+// Tailwind composes independent filter utilities into one declaration in this
+// order. Native filter arrays are order-sensitive, so preserve the same order
+// regardless of class ordering.
+const FILTER_ORDER = [
+ "blur",
+ "brightness",
+ "contrast",
+ "grayscale",
+ "hueRotate",
+ "invert",
+ "saturate",
+ "sepia",
+ "dropShadow",
+] as const;
+
+function sortFilters(filters: FilterStyle[]): FilterStyle[] {
+ return filters.sort((left, right) => {
+ const leftIndex = FILTER_ORDER.indexOf(getFilterType(left) as (typeof FILTER_ORDER)[number]);
+ const rightIndex = FILTER_ORDER.indexOf(getFilterType(right) as (typeof FILTER_ORDER)[number]);
+ return leftIndex - rightIndex;
+ });
+}
+
/**
- * Merge two StyleObject instances, handling transform arrays specially
+ * Merge native filter arrays, composing different filter functions while the
+ * last utility wins for duplicate filter types.
+ */
+function mergeFilters(target: FilterStyle[], source: FilterStyle[]): FilterStyle[] {
+ if (source.length === 0) {
+ return [];
+ }
+
+ const result: FilterStyle[] = [...target];
+
+ for (const sourceFilter of source) {
+ const sourceType = getFilterType(sourceFilter);
+ const existingIndex = result.findIndex((filter) => getFilterType(filter) === sourceType);
+
+ if (existingIndex !== -1) {
+ result[existingIndex] = sourceFilter;
+ } else {
+ result.push(sourceFilter);
+ }
+ }
+
+ return sortFilters(result);
+}
+
+/**
+ * Merge two StyleObject instances, handling transform and filter arrays specially
*
* @param target - The target object to merge into (mutated)
* @param source - The source object to merge from
@@ -84,16 +136,25 @@ export function mergeStyles(target: StyleObject, source: StyleObject): StyleObje
// Handle transform arrays specially
if (key === "transform" && Array.isArray(sourceValue)) {
- const targetValue = target[key];
+ const sourceTransforms = sourceValue as TransformStyle[];
+ const targetValue = target.transform;
if (Array.isArray(targetValue)) {
// Merge transforms with "last wins" for same types
- target.transform = mergeTransforms(targetValue, sourceValue);
+ target.transform = mergeTransforms(targetValue, sourceTransforms);
} else {
// No existing array, just assign
- target[key] = sourceValue;
+ target.transform = sourceTransforms;
+ }
+ } else if (key === "filter" && Array.isArray(sourceValue)) {
+ const sourceFilters = sourceValue as FilterStyle[];
+ const targetValue = target.filter;
+ if (Array.isArray(targetValue)) {
+ target.filter = mergeFilters(targetValue, sourceFilters);
+ } else {
+ target.filter = sourceFilters;
}
} else {
- // Standard Object.assign behavior for non-transform properties
+ // Standard Object.assign behavior for scalar and object properties
target[key] = sourceValue;
}
}