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 @@ -56,6 +56,7 @@ export default defineConfig({
{ label: "Typography", slug: "reference/typography" },
{ label: "Borders", slug: "reference/borders" },
{ label: "Outlines", slug: "reference/outlines" },
{ label: "Rings", slug: "reference/rings" },
{ label: "Shadows & Elevation", slug: "reference/shadows" },
{ label: "Aspect Ratio", slug: "reference/aspect-ratio" },
{ label: "Transforms", slug: "reference/transforms" },
Expand Down
64 changes: 64 additions & 0 deletions docs/src/content/docs/reference/rings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
title: Rings
description: Add browser-style focus rings to views
---

Add non-layout-shifting rings with React Native's native outline styles.

> **Note**: Rings require a React Native version and renderer that support `outlineWidth`, `outlineStyle`, `outlineColor`, and `outlineOffset`.

## Default Ring

The bare `ring` class adds a 3-unit, solid blue-500 ring at 50% opacity:

```tsx
<View className="ring" />
// {
// outlineWidth: 3,
// outlineStyle: 'solid',
// outlineColor: '#2B7FFF80'
// }
```

This default intentionally mirrors the familiar blue browser-style focus treatment. Use color utilities when a different treatment is needed.

## Ring Width

Numeric and arbitrary widths are supported:

```tsx
<View className="ring-0" />
<View className="ring-1" />
<View className="ring-2.5" />
<View className="ring-[3px]" />
```

Width utilities set the native outline width and solid style and inherit the default browser-blue color. Combine them with a ring color to override that default; explicit colors win regardless of class order.

## Ring Color

Use preset, custom, arbitrary, and opacity-modified colors:

```tsx
<View className="ring-2 ring-red-500" />
<View className="ring-2 ring-blue-500/25" />
<View className="ring-2 ring-[#123456]" />
<View className="ring-2 ring-brand" />
```

Custom ring colors are read from `theme.extend.colors` like other color utilities.

Color-scheme modifiers such as `dark:ring-white` and `light:ring-black` work like other modified classes. The package-specific `scheme:ring-*` shorthand depends on the scheme color-expansion support and is documented with that modifier.

## Ring Offset

Ring offsets map to React Native's `outlineOffset`:

```tsx
<View className="ring ring-offset-2" />
<View className="ring-2 ring-offset-[3px]" />
```

Inset rings and ring-offset colors do not have direct React Native outline equivalents and are not supported.

Unlike Tailwind CSS's box-shadow rings, these browser-style rings intentionally use React Native outlines. This keeps them non-layout-shifting and close to native browser focus treatment, but a ring and an outline share the same native properties and cannot render as two independent effects.
17 changes: 17 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,23 @@ describe("className visitor - basic transformation", () => {
expect(output).toContain("style:");
});

it("should transform composed ring utilities", () => {
const input = `
import { View } from 'react-native';
export function Component() {
return <View className="ring-2 ring-red-500 ring-offset-1" />;
}
`;

const output = transform(input, undefined, true);

expect(output).not.toContain("className");
expect(output).toMatch(/outlineWidth:\s*2/);
expect(output).toMatch(/outlineStyle:\s*["']solid["']/);
expect(output).toMatch(/outlineColor:\s*["']#fb2c36["']/);
expect(output).toMatch(/outlineOffset:\s*1/);
});

it("should work with both tw and className in same file", () => {
const input = `
import { tw } from '@mgcrea/react-native-tailwind';
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
parseColor,
parseLayout,
parseOutline,
parseRing,
parsePlaceholderClass,
parsePlaceholderClasses,
parseShadow,
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 { DEFAULT_RING_COLOR, DEFAULT_RING_WIDTH } from "./parser/rings";
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
10 changes: 10 additions & 0 deletions src/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { parseBorder } from "./borders";
import { parseColor } from "./colors";
import { parseLayout } from "./layout";
import { parseOutline } from "./outline";
import { DEFAULT_RING_COLOR, parseRing } from "./rings";
import { parseShadow } from "./shadows";
import { parseSizing } from "./sizing";
import { parseSpacing } from "./spacing";
Expand Down Expand Up @@ -41,6 +42,13 @@ export function parseClassName(className: string, customTheme?: CustomTheme): St
mergeStyles(style, parsedStyle);
}

// Width-only browser rings inherit the default focus color. Apply this
// after parsing so an explicit ring color wins regardless of class order.
const hasRingWidth = classes.some((cls) => parseRing(cls, customTheme?.colors)?.outlineWidth !== undefined);
if (hasRingWidth && style.outlineColor === undefined) {
style.outlineColor = DEFAULT_RING_COLOR;
}

return style;
}

Expand All @@ -58,6 +66,7 @@ export function parseClass(cls: string, customTheme?: CustomTheme): StyleObject
(cls: string) => parseSpacing(cls, customTheme?.spacing),
(cls: string) => parseBorder(cls, customTheme?.colors),
parseOutline,
(cls: string) => parseRing(cls, customTheme?.colors),
(cls: string) => parseColor(cls, customTheme?.colors),
(cls: string) => parseLayout(cls, customTheme?.spacing),
(cls: string) => parseTypography(cls, customTheme?.fontFamily, customTheme?.fontSize),
Expand Down Expand Up @@ -89,6 +98,7 @@ export { parseBorder } from "./borders";
export { parseColor } from "./colors";
export { parseLayout } from "./layout";
export { parseOutline } from "./outline";
export { parseRing } from "./rings";
export { parsePlaceholderClass, parsePlaceholderClasses } from "./placeholder";
export { parseShadow } from "./shadows";
export { parseSizing } from "./sizing";
Expand Down
94 changes: 94 additions & 0 deletions src/parser/rings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";

import { applyOpacity } from "../utils/colorUtils";
import { parseClassName } from "./index";
import { DEFAULT_RING_COLOR, DEFAULT_RING_WIDTH, parseRing } from "./rings";

describe("parseRing", () => {
it("should parse the browser-style default ring", () => {
expect(parseRing("ring")).toEqual({
outlineWidth: DEFAULT_RING_WIDTH,
outlineStyle: "solid",
outlineColor: DEFAULT_RING_COLOR,
});
});

it("should parse dynamic ring widths", () => {
expect(parseRing("ring-0")).toEqual({
outlineWidth: 0,
outlineStyle: "solid",
});
expect(parseRing("ring-1")).toEqual({
outlineWidth: 1,
outlineStyle: "solid",
});
expect(parseRing("ring-2.5")).toEqual({
outlineWidth: 2.5,
outlineStyle: "solid",
});
});

it("should parse arbitrary ring widths", () => {
expect(parseRing("ring-[3px]")).toEqual({
outlineWidth: 3,
outlineStyle: "solid",
});
expect(parseRing("ring-[1.5]")).toEqual({
outlineWidth: 1.5,
outlineStyle: "solid",
});
});

it("should parse ring colors and opacity", () => {
expect(parseRing("ring-red-500")).toEqual({ outlineColor: "#fb2c36" });
expect(parseRing("ring-blue-500/25")).toEqual({
outlineColor: applyOpacity("#2b7fff", 25),
});
expect(parseRing("ring-[#123456]")).toEqual({ outlineColor: "#123456" });
});

it("should parse custom ring colors", () => {
expect(parseRing("ring-brand", { brand: "#123456" })).toEqual({ outlineColor: "#123456" });
});

it("should parse dynamic and arbitrary offsets", () => {
expect(parseRing("ring-offset-0")).toEqual({ outlineOffset: 0 });
expect(parseRing("ring-offset-2")).toEqual({ outlineOffset: 2 });
expect(parseRing("ring-offset-2.5")).toEqual({ outlineOffset: 2.5 });
expect(parseRing("ring-offset-[3px]")).toEqual({ outlineOffset: 3 });
});

it("should compose width, color, and offset through the class parser", () => {
expect(parseClassName("ring-2 ring-red-500 ring-offset-1")).toEqual({
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: "#fb2c36",
outlineOffset: 1,
});
});

it("should inherit the browser-blue color for width-only rings", () => {
expect(parseClassName("ring-2")).toEqual({
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: DEFAULT_RING_COLOR,
});
});

it("should preserve a ring color declared before its width", () => {
expect(parseClassName("ring-red-500 ring-2")).toEqual({
outlineColor: "#fb2c36",
outlineWidth: 2,
outlineStyle: "solid",
});
});

it("should reject unsupported ring classes", () => {
expect(parseRing("ring-inset")).toBeNull();
expect(parseRing("ring-offset-red-500")).toBeNull();
expect(parseRing("ring-[-1px]")).toBeNull();
expect(parseRing("ring-[2rem]")).toBeNull();
expect(parseRing("rings")).toBeNull();
expect(parseRing("")).toBeNull();
});
});
65 changes: 65 additions & 0 deletions src/parser/rings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Ring utilities implemented with React Native outline styles.
*/

import type { StyleObject } from "../types";
import { COLORS, applyOpacity, parseColorValue } from "../utils/colorUtils";

export const DEFAULT_RING_WIDTH = 3;
export const DEFAULT_RING_COLOR = applyOpacity(COLORS["blue-500"], 50);

const NUMBER_PATTERN = String.raw`(?:\d+(?:\.\d*)?|\.\d+)`;

function parseRingWidth(value: string): number | null {
const arbitraryMatch = value.match(new RegExp(`^\\[(${NUMBER_PATTERN})(?:px)?\\]$`));
if (arbitraryMatch) {
return Number.parseFloat(arbitraryMatch[1]);
}

if (new RegExp(`^${NUMBER_PATTERN}$`).test(value)) {
return Number.parseFloat(value);
}

return null;
}

function defaultRingStyle(): StyleObject {
return {
outlineWidth: DEFAULT_RING_WIDTH,
outlineStyle: "solid",
outlineColor: DEFAULT_RING_COLOR,
};
}

function ringWidthStyle(outlineWidth: number): StyleObject {
return { outlineWidth, outlineStyle: "solid" };
}

/**
* Parse ring width, color, and offset classes.
* @param cls - The class name to parse
* @param customColors - Optional custom colors from tailwind.config
*/
export function parseRing(cls: string, customColors?: Record<string, string>): StyleObject | null {
if (cls === "ring") {
return defaultRingStyle();
}

if (cls.startsWith("ring-offset-")) {
const offset = parseRingWidth(cls.substring(12));
return offset === null ? null : { outlineOffset: offset };
}

if (!cls.startsWith("ring-")) {
return null;
}

const value = cls.substring(5);
const width = parseRingWidth(value);
if (width !== null) {
return ringWidthStyle(width);
}

const color = parseColorValue(value, customColors);
return color === null ? null : { outlineColor: color };
}
Loading