Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
129 changes: 129 additions & 0 deletions docs/src/content/docs/reference/filters.md
Original file line number Diff line number Diff line change
@@ -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
<View className="brightness-101" /> // { brightness: 1.01 }
<View className="contrast-125" /> // { contrast: 1.25 }
<View className="grayscale-50" /> // { grayscale: 0.5 }
<View className="invert-25" /> // { invert: 0.25 }
<View className="saturate-150" /> // { saturate: 1.5 }
<View className="sepia-75" /> // { sepia: 0.75 }
```

The full-effect forms are also supported:

```tsx
<View className="grayscale invert sepia" />
```

Use bracket syntax for raw React Native amounts or explicit percentages:

```tsx
<View className="brightness-[1.01]" /> // { brightness: 1.01 }
<View className="contrast-[80%]" /> // { contrast: 0.8 }
```

## Blur

Blur uses Tailwind's pixel scale:

```tsx
<View className="blur-xs" /> // { blur: 4 }
<View className="blur-sm" /> // { blur: 8 }
<View className="blur-md" /> // { blur: 12 }
<View className="blur-lg" /> // { blur: 16 }
<View className="blur-xl" /> // { blur: 24 }
<View className="blur-2xl" /> // { blur: 40 }
<View className="blur-3xl" /> // { blur: 64 }
<View className="blur-none" />
```

Arbitrary blur values accept non-negative pixels:

```tsx
<View className="blur-[2px]" />
<View className="blur-[2.5]" />
```

## Hue Rotation

Numeric hue rotation utilities use degrees and support negative values:

```tsx
<View className="hue-rotate-45" /> // { hueRotate: '45deg' }
<View className="-hue-rotate-90" /> // { hueRotate: '-90deg' }
```

Arbitrary values support `deg` and `rad` angles:

```tsx
<View className="hue-rotate-[22.5deg]" />
<View className="hue-rotate-[0.5rad]" />
```

## Drop Shadow

Drop shadows use Tailwind's `xs` through `2xl` presets and operate on the rendered alpha mask:

```tsx
<View className="drop-shadow-xs" />
<View className="drop-shadow-md" />
<View className="drop-shadow-xl/50" />
<View className="drop-shadow-none" />
```

Arbitrary drop shadows accept X offset, Y offset, optional blur, and a preset, custom, or hex color:

```tsx
<View className="drop-shadow-[0_4px_4px_#00000080]" />
<View className="drop-shadow-[-2px_3px_#ff0000]" />
```

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
<View className="blur-sm brightness-110 contrast-125" />
// filter: [{ blur: 8 }, { brightness: 1.1 }, { contrast: 1.25 }]

<View className="brightness-110 brightness-90" />
// 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
<View className="blur-sm brightness-110 filter-none" /> // filter: []
<View className="filter-none blur-sm brightness-110" /> // filter: []
```

Unsupported units, malformed drop shadows, and negative values where React Native requires non-negative amounts are ignored with a development warning.
32 changes: 32 additions & 0 deletions src/babel/plugin/visitors/className.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <View className="brightness-[1.01]" />;
}
`;

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 <View className="blur-sm hue-rotate-45 drop-shadow-[0_4px_4px_#00000080]" />;
}
`;

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';
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +24,7 @@ export {
parseAspectRatio,
parseBorder,
parseColor,
parseFilter,
parseLayout,
parseOutline,
parsePlaceholderClass,
Expand All @@ -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";
Expand Down
153 changes: 153 additions & 0 deletions src/parser/filters.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading