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",