From 46fce79017def670319bb49a39ff17075e27a6ca Mon Sep 17 00:00:00 2001
From: Evan Simpson <25159851+e-simpson@users.noreply.github.com>
Date: Mon, 27 Jul 2026 21:42:20 -0400
Subject: [PATCH 1/2] feat: add native ring utilities
---
docs/astro.config.mjs | 1 +
docs/src/content/docs/reference/rings.md | 60 ++++++++++++++
src/babel/plugin/visitors/className.test.ts | 17 ++++
src/index.ts | 2 +
src/parser/index.ts | 3 +
src/parser/rings.test.ts | 86 +++++++++++++++++++++
src/parser/rings.ts | 65 ++++++++++++++++
7 files changed, 234 insertions(+)
create mode 100644 docs/src/content/docs/reference/rings.md
create mode 100644 src/parser/rings.test.ts
create mode 100644 src/parser/rings.ts
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index d6e7a0b..f403842 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -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" },
diff --git a/docs/src/content/docs/reference/rings.md b/docs/src/content/docs/reference/rings.md
new file mode 100644
index 0000000..6176584
--- /dev/null
+++ b/docs/src/content/docs/reference/rings.md
@@ -0,0 +1,60 @@
+---
+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
+
+// {
+// 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
+
+
+
+
+```
+
+Width utilities set the native outline width and solid style. Combine them with a ring color; use the bare `ring` utility when you want the default blue treatment.
+
+## Ring Color
+
+Use preset, custom, arbitrary, and opacity-modified colors:
+
+```tsx
+
+
+
+
+```
+
+Custom ring colors are read from `theme.extend.colors` like other color utilities.
+
+## Ring Offset
+
+Ring offsets map to React Native's `outlineOffset`:
+
+```tsx
+
+
+```
+
+Inset rings and ring-offset colors do not have direct React Native outline equivalents and are not supported.
diff --git a/src/babel/plugin/visitors/className.test.ts b/src/babel/plugin/visitors/className.test.ts
index 0f9a484..df93923 100644
--- a/src/babel/plugin/visitors/className.test.ts
+++ b/src/babel/plugin/visitors/className.test.ts
@@ -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 ;
+ }
+ `;
+
+ 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';
diff --git a/src/index.ts b/src/index.ts
index 0bd46f6..ec5fc07 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -26,6 +26,7 @@ export {
parseColor,
parseLayout,
parseOutline,
+ parseRing,
parsePlaceholderClass,
parsePlaceholderClasses,
parseShadow,
@@ -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";
diff --git a/src/parser/index.ts b/src/parser/index.ts
index 0e89fcc..ebab0e6 100644
--- a/src/parser/index.ts
+++ b/src/parser/index.ts
@@ -10,6 +10,7 @@ import { parseBorder } from "./borders";
import { parseColor } from "./colors";
import { parseLayout } from "./layout";
import { parseOutline } from "./outline";
+import { parseRing } from "./rings";
import { parseShadow } from "./shadows";
import { parseSizing } from "./sizing";
import { parseSpacing } from "./spacing";
@@ -58,6 +59,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),
@@ -89,6 +91,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";
diff --git a/src/parser/rings.test.ts b/src/parser/rings.test.ts
new file mode 100644
index 0000000..0efcb66
--- /dev/null
+++ b/src/parser/rings.test.ts
@@ -0,0 +1,86 @@
+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 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();
+ });
+});
diff --git a/src/parser/rings.ts b/src/parser/rings.ts
new file mode 100644
index 0000000..d436fbf
--- /dev/null
+++ b/src/parser/rings.ts
@@ -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): 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 };
+}
From f91324baebaf95a80888150b181586b6ac28a0c4 Mon Sep 17 00:00:00 2001
From: Evan Simpson <25159851+e-simpson@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:36:59 -0400
Subject: [PATCH 2/2] fix: inherit the default browser ring color
---
docs/src/content/docs/reference/rings.md | 6 +++++-
src/parser/index.ts | 9 ++++++++-
src/parser/rings.test.ts | 8 ++++++++
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/docs/src/content/docs/reference/rings.md b/docs/src/content/docs/reference/rings.md
index 6176584..c1341d2 100644
--- a/docs/src/content/docs/reference/rings.md
+++ b/docs/src/content/docs/reference/rings.md
@@ -33,7 +33,7 @@ Numeric and arbitrary widths are supported:
```
-Width utilities set the native outline width and solid style. Combine them with a ring color; use the bare `ring` utility when you want the default blue treatment.
+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
@@ -48,6 +48,8 @@ Use preset, custom, arbitrary, and opacity-modified colors:
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`:
@@ -58,3 +60,5 @@ Ring offsets map to React Native's `outlineOffset`:
```
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.
diff --git a/src/parser/index.ts b/src/parser/index.ts
index ebab0e6..a32b28f 100644
--- a/src/parser/index.ts
+++ b/src/parser/index.ts
@@ -10,7 +10,7 @@ import { parseBorder } from "./borders";
import { parseColor } from "./colors";
import { parseLayout } from "./layout";
import { parseOutline } from "./outline";
-import { parseRing } from "./rings";
+import { DEFAULT_RING_COLOR, parseRing } from "./rings";
import { parseShadow } from "./shadows";
import { parseSizing } from "./sizing";
import { parseSpacing } from "./spacing";
@@ -42,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;
}
diff --git a/src/parser/rings.test.ts b/src/parser/rings.test.ts
index 0efcb66..6718592 100644
--- a/src/parser/rings.test.ts
+++ b/src/parser/rings.test.ts
@@ -67,6 +67,14 @@ describe("parseRing", () => {
});
});
+ 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",