diff --git a/example/app/testing-grounds/gradient-playground.tsx b/example/app/testing-grounds/gradient-playground.tsx
new file mode 100644
index 0000000..6800ab3
--- /dev/null
+++ b/example/app/testing-grounds/gradient-playground.tsx
@@ -0,0 +1,5 @@
+import GradientPlaygroundScreen from '~/screens/testing-grounds/gradient-playground/GradientPlaygroundScreen'
+
+export default function GradientPlaygroundIndex() {
+ return
+}
diff --git a/example/screens/testing-grounds/TestingGroundsScreen.tsx b/example/screens/testing-grounds/TestingGroundsScreen.tsx
index 9e9ecba..4e4b369 100644
--- a/example/screens/testing-grounds/TestingGroundsScreen.tsx
+++ b/example/screens/testing-grounds/TestingGroundsScreen.tsx
@@ -55,6 +55,13 @@ const TESTING_GROUNDS_SECTIONS = [
'Interactive playground for experimenting with flex layout properties. Test alignItems, justifyContent, flexDirection, spacing, and padding with live visual feedback.',
route: '/testing-grounds/flex-playground',
},
+ {
+ id: 'gradient-playground',
+ title: 'Gradient Playground',
+ description:
+ 'Test CSS gradient strings as backgroundColor. Experiment with linear, radial, and conic gradients, direction/angle controls, color presets, stop positions, and borderRadius clipping.',
+ route: '/testing-grounds/gradient-playground',
+ },
{
id: 'image-preloading',
title: 'Image Preloading',
diff --git a/example/screens/testing-grounds/gradient-playground/GradientPlaygroundScreen.tsx b/example/screens/testing-grounds/gradient-playground/GradientPlaygroundScreen.tsx
new file mode 100644
index 0000000..7702c6f
--- /dev/null
+++ b/example/screens/testing-grounds/gradient-playground/GradientPlaygroundScreen.tsx
@@ -0,0 +1,291 @@
+import { Link } from 'expo-router'
+import React, { useState } from 'react'
+import { ScrollView, StyleSheet, Text, View } from 'react-native'
+import { Voltra } from 'voltra'
+import { VoltraView } from 'voltra/client'
+
+import { Button } from '~/components/Button'
+import { Card } from '~/components/Card'
+
+type GradientType = 'linear' | 'radial' | 'conic'
+type Direction = 'to right' | 'to bottom' | 'to bottom right' | 'to top right'
+
+const GRADIENT_TYPES: GradientType[] = ['linear', 'radial', 'conic']
+const DIRECTIONS: Direction[] = ['to right', 'to bottom', 'to bottom right', 'to top right']
+const DIRECTION_LABELS: Record = {
+ 'to right': 'to right',
+ 'to bottom': 'to bottom',
+ 'to bottom right': 'to bottom right',
+ 'to top right': 'to top right',
+}
+
+const PRESETS: Array<{ label: string; colors: [string, string, ...string[]] }> = [
+ { label: 'Sunset', colors: ['#FF6B6B', '#FFD93D'] },
+ { label: 'Ocean', colors: ['#0093E9', '#80D0C7'] },
+ { label: 'Purple', colors: ['#8B5CF6', '#EC4899'] },
+ { label: 'Tri-color', colors: ['#EF4444', '#10B981', '#3B82F6'] },
+]
+
+const ANGLE_OPTIONS = [0, 45, 90, 135, 180]
+
+export default function GradientPlaygroundScreen() {
+ const [gradientType, setGradientType] = useState('linear')
+ const [direction, setDirection] = useState('to right')
+ const [angle, setAngle] = useState(90)
+ const [useAngle, setUseAngle] = useState(false)
+ const [preset, setPreset] = useState(0)
+ const [borderRadius, setBorderRadius] = useState(12)
+
+ const colors = PRESETS[preset].colors
+ const positionedStops = colors
+ .map((color, idx) => {
+ const pct = colors.length === 1 ? 0 : Math.round((idx / (colors.length - 1)) * 100)
+ return `${color} ${pct}%`
+ })
+ .join(', ')
+
+ const buildGradient = (): string => {
+ if (gradientType === 'radial') {
+ return `radial-gradient(circle at center, ${positionedStops})`
+ }
+ if (gradientType === 'conic') {
+ return `conic-gradient(from ${angle}deg at center, ${positionedStops})`
+ }
+ // linear
+ const dir = useAngle ? `${angle}deg` : direction
+ return `linear-gradient(${dir}, ${positionedStops})`
+ }
+
+ const gradient = buildGradient()
+
+ const cycleGradientType = () => {
+ const i = GRADIENT_TYPES.indexOf(gradientType)
+ setGradientType(GRADIENT_TYPES[(i + 1) % GRADIENT_TYPES.length])
+ }
+
+ const cycleDirection = () => {
+ const i = DIRECTIONS.indexOf(direction)
+ setDirection(DIRECTIONS[(i + 1) % DIRECTIONS.length])
+ }
+
+ const cycleAngle = () => {
+ const i = ANGLE_OPTIONS.indexOf(angle)
+ setAngle(ANGLE_OPTIONS[(i + 1) % ANGLE_OPTIONS.length])
+ }
+
+ const cyclePreset = () => {
+ setPreset((prev) => (prev + 1) % PRESETS.length)
+ }
+
+ const increaseBorderRadius = () => setBorderRadius((prev) => Math.min(prev + 8, 80))
+ const decreaseBorderRadius = () => setBorderRadius((prev) => Math.max(prev - 8, 0))
+
+ return (
+
+
+ Gradient Playground
+ Test CSS gradient strings as backgroundColor on Voltra views.
+
+ Playground uses parser-compatible CSS syntax only. If a preview is blank, this indicates a gradient parser bug
+ in iOS rendering.
+
+
+ {/* Controls */}
+
+ Controls
+
+ {/* Gradient Type */}
+
+ Type:
+
+
+
+ {/* Direction (linear only) */}
+ {gradientType === 'linear' && (
+ <>
+
+ Mode:
+
+ {useAngle ? (
+
+ Angle: {angle}deg
+
+
+ ) : (
+
+ Direction:
+
+
+ )}
+ >
+ )}
+
+ {/* Angle for conic */}
+ {gradientType === 'conic' && (
+
+ Start angle: {angle}deg
+
+
+ )}
+
+ {/* Color Preset */}
+
+ Colors: {PRESETS[preset].label}
+
+
+
+ {/* Border Radius */}
+
+ borderRadius: {borderRadius}px
+
+
+
+
+
+
+
+ {/* Live Preview */}
+
+ Live Preview
+
+
+
+ Gradient View
+ {gradient}
+
+
+
+
+ {/* Explicit stop positions */}
+
+ Color Stop Positions
+ Explicit percentage stops: red 10%, yellow 50%, blue 90%
+
+
+
+
+
+
+ {/* rgba inside gradient */}
+
+ RGBA Inside Gradient
+ linear-gradient(to right, rgba(255,0,0,0.8) 0%, rgba(0,0,255,0.3) 100%)
+
+
+
+
+
+
+ {/* Solid color still works */}
+
+ Solid Color (Unchanged)
+ backgroundColor: "#3B82F6" — plain colors still work
+
+
+
+ Solid #3B82F6
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ content: {
+ paddingHorizontal: 20,
+ paddingVertical: 24,
+ },
+ heading: {
+ fontSize: 24,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ marginBottom: 8,
+ },
+ subheading: {
+ fontSize: 14,
+ lineHeight: 20,
+ color: '#CBD5F5',
+ marginBottom: 8,
+ },
+ warningText: {
+ fontSize: 12,
+ lineHeight: 18,
+ color: '#FCA5A5',
+ marginBottom: 20,
+ },
+ controlRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ controlLabel: {
+ fontSize: 14,
+ color: '#CBD5F5',
+ flex: 1,
+ },
+ buttonGroup: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ previewSubtext: {
+ fontSize: 12,
+ color: '#94A3B8',
+ marginTop: 4,
+ },
+ footer: {
+ marginTop: 24,
+ alignItems: 'center',
+ },
+})
diff --git a/ios/Package.swift b/ios/Package.swift
index 58f057a..4262726 100644
--- a/ios/Package.swift
+++ b/ios/Package.swift
@@ -12,6 +12,10 @@ let package = Package(
name: "VoltraSharedCore",
targets: ["VoltraSharedCore"]
),
+ .library(
+ name: "VoltraStyleCore",
+ targets: ["VoltraStyleCore"]
+ ),
],
targets: [
.target(
@@ -45,5 +49,20 @@ let package = Package(
dependencies: ["VoltraSharedCore"],
path: "Tests/VoltraSharedTests"
),
+ .target(
+ name: "VoltraStyleCore",
+ path: "ui",
+ sources: [
+ "Style/BackgroundValue.swift",
+ "Style/JSColorParser.swift",
+ "Style/JSGradientParser.swift",
+ ]
+ ),
+ .testTarget(
+ name: "VoltraStyleTests",
+ dependencies: ["VoltraStyleCore"],
+ path: "tests",
+ sources: ["JSGradientParserTests.swift"]
+ ),
]
)
diff --git a/ios/tests/JSGradientParserTests.swift b/ios/tests/JSGradientParserTests.swift
new file mode 100644
index 0000000..e78d78b
--- /dev/null
+++ b/ios/tests/JSGradientParserTests.swift
@@ -0,0 +1,189 @@
+import XCTest
+import SwiftUI
+@testable import VoltraStyleCore
+
+final class JSGradientParserTests: XCTestCase {
+ private func assertLinearGradient(
+ _ value: String,
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ assertions: (_ stops: [Gradient.Stop], _ start: UnitPoint, _ end: UnitPoint) -> Void
+ ) {
+ let parsed = JSGradientParser.parse(value)
+ guard case let .linearGradient(gradient, startPoint, endPoint)? = parsed else {
+ XCTFail("Expected linear gradient for: \(value), got: \(String(describing: parsed))", file: file, line: line)
+ return
+ }
+ assertions(gradient.stops, startPoint, endPoint)
+ }
+
+ private func assertRadialGradient(
+ _ value: String,
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ assertions: (_ spec: RadialGradientSpec) -> Void
+ ) {
+ let parsed = JSGradientParser.parse(value)
+ guard case let .radialGradient(spec)? = parsed else {
+ XCTFail("Expected radial gradient for: \(value), got: \(String(describing: parsed))", file: file, line: line)
+ return
+ }
+ assertions(spec)
+ }
+
+ private func assertConicGradient(
+ _ value: String,
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ assertions: (_ stops: [Gradient.Stop], _ center: UnitPoint, _ angle: Angle) -> Void
+ ) {
+ guard case let .angularGradient(gradient, center, angle)? = JSGradientParser.parse(value) else {
+ XCTFail("Expected conic gradient for: \(value)", file: file, line: line)
+ return
+ }
+ assertions(gradient.stops, center, angle)
+ }
+
+ func testLinearGradientWithRGBAStopsParses() {
+ let value = "linear-gradient(to right, rgba(255,0,0,0.8) 0%, rgba(0,0,255,0.3) 100%)"
+ XCTAssertNotNil(JSGradientParser.parse(value))
+ }
+
+ func testLinearGradientWithSpaceSlashRGBAParses() {
+ let value = "linear-gradient(90deg, rgba(255 0 0 / 80%) 0%, rgba(0 0 255 / 30%) 100%)"
+ XCTAssertNotNil(JSGradientParser.parse(value))
+ }
+
+ func testRadialGradientWithRGBAAndTransparentParses() {
+ let value = "radial-gradient(circle at center, rgba(255,0,0,.8) 10%, transparent 90%)"
+ XCTAssertNotNil(JSGradientParser.parse(value))
+ }
+
+ func testConicGradientWithDegreeStopsParses() {
+ let value = "conic-gradient(from 90deg at center, rgba(255,0,0,.8) 0deg, rgba(0,0,255,.3) 360deg)"
+ XCTAssertNotNil(JSGradientParser.parse(value))
+ }
+
+ func testInvalidColorTokenWithTrailingGarbageFails() {
+ let value = "linear-gradient(to right, rgba(255,0,0,0.8)garbage 0%, blue 100%)"
+ XCTAssertNil(JSGradientParser.parse(value))
+ }
+
+ func testMalformedParenthesesFails() {
+ let value = "linear-gradient(to right, rgba(255,0,0,0.8 0%, blue 100%)"
+ XCTAssertNil(JSGradientParser.parse(value))
+ }
+
+ func testBadStopTokenFails() {
+ let value = "linear-gradient(to right, red badstop, blue 100%)"
+ XCTAssertNil(JSGradientParser.parse(value))
+ }
+
+ func testLinearGradientDirectionParsesExpectedStartAndEndPoints() {
+ assertLinearGradient("linear-gradient(to bottom right, rgba(255,0,0,1) 0%, rgba(0,0,255,1) 100%)") { _, start, end in
+ XCTAssertEqual(start.x, 0, accuracy: 0.0001)
+ XCTAssertEqual(start.y, 0, accuracy: 0.0001)
+ XCTAssertEqual(end.x, 1, accuracy: 0.0001)
+ XCTAssertEqual(end.y, 1, accuracy: 0.0001)
+ }
+ }
+
+ func testLinearGradientAngleParsesExpectedStartAndEndPoints() {
+ assertLinearGradient("linear-gradient(90deg, rgba(255,0,0,1) 0%, rgba(0,0,255,1) 100%)") { _, start, end in
+ XCTAssertEqual(start.x, 0, accuracy: 0.0001)
+ XCTAssertEqual(start.y, 0.5, accuracy: 0.0001)
+ XCTAssertEqual(end.x, 1, accuracy: 0.0001)
+ XCTAssertEqual(end.y, 0.5, accuracy: 0.0001)
+ }
+ }
+
+ func testDoublePositionStopExpandsIntoTwoStops() {
+ assertLinearGradient("linear-gradient(to right, red 10% 30%, blue 100%)") { stops, _, _ in
+ XCTAssertEqual(stops.count, 3)
+ XCTAssertEqual(stops[0].location, 0.1, accuracy: 0.0001)
+ XCTAssertEqual(stops[1].location, 0.3, accuracy: 0.0001)
+ XCTAssertEqual(stops[2].location, 1, accuracy: 0.0001)
+ }
+ }
+
+ func testStopPositionsClampToNonDecreasingOrder() {
+ assertLinearGradient("linear-gradient(to right, red 70%, green 30%, blue 100%)") { stops, _, _ in
+ XCTAssertEqual(stops.count, 3)
+ XCTAssertEqual(stops[0].location, 0.7, accuracy: 0.0001)
+ XCTAssertEqual(stops[1].location, 0.7, accuracy: 0.0001)
+ XCTAssertEqual(stops[2].location, 1, accuracy: 0.0001)
+ }
+ }
+
+ func testRadialGradientPreludeParsesShapeExtentAndCenter() {
+ assertRadialGradient("radial-gradient(circle closest-side at top right, red 0%, blue 100%)") { spec in
+ guard case .circle = spec.shape else {
+ XCTFail("Expected circle radial shape")
+ return
+ }
+ guard case .closestSide = spec.extent else {
+ XCTFail("Expected closest-side radial extent")
+ return
+ }
+ XCTAssertEqual(spec.center.x, 1, accuracy: 0.0001)
+ XCTAssertEqual(spec.center.y, 0, accuracy: 0.0001)
+ XCTAssertEqual(spec.gradient.stops.count, 2)
+ }
+ }
+
+ func testConicGradientPreludeParsesAngleAndCenter() {
+ assertConicGradient("conic-gradient(from 0.25turn at left bottom, red 0%, blue 100%)") { stops, center, angle in
+ XCTAssertEqual(stops.count, 2)
+ XCTAssertEqual(center.x, 0, accuracy: 0.0001)
+ XCTAssertEqual(center.y, 1, accuracy: 0.0001)
+ XCTAssertEqual(angle.degrees, 90, accuracy: 0.0001)
+ }
+ }
+
+ func testConicGradientDegreeStopsMapToUnitInterval() {
+ assertConicGradient("conic-gradient(from 0deg at center, red 90deg, blue 270deg)") { stops, _, _ in
+ XCTAssertEqual(stops.count, 2)
+ XCTAssertEqual(stops[0].location, 0.25, accuracy: 0.0001)
+ XCTAssertEqual(stops[1].location, 0.75, accuracy: 0.0001)
+ }
+ }
+
+ func testRepeatingGradientsAreRejected() {
+ XCTAssertNil(JSGradientParser.parse("repeating-linear-gradient(to right, red, blue)"))
+ XCTAssertNil(JSGradientParser.parse("repeating-radial-gradient(circle, red, blue)"))
+ XCTAssertNil(JSGradientParser.parse("repeating-conic-gradient(from 45deg, red, blue)"))
+ }
+
+ func testInvalidRadialPreludeFails() {
+ XCTAssertNil(JSGradientParser.parse("radial-gradient(circle foo at center, red, blue)"))
+ }
+
+ func testConicStopsWithPercentAndAngleProduceExpandedStops() {
+ assertConicGradient("conic-gradient(from 0deg at center, red 25% 90deg, blue 100%)") { stops, _, _ in
+ XCTAssertEqual(stops.count, 3)
+ XCTAssertEqual(stops[0].location, 0.25, accuracy: 0.0001)
+ XCTAssertEqual(stops[1].location, 0.25, accuracy: 0.0001)
+ XCTAssertEqual(stops[2].location, 1, accuracy: 0.0001)
+ }
+ }
+}
+
+final class JSColorParserTests: XCTestCase {
+ func testNamedColorsParse() {
+ XCTAssertNotNil(JSColorParser.parse("red"))
+ XCTAssertNotNil(JSColorParser.parse("green"))
+ XCTAssertNotNil(JSColorParser.parse("blue"))
+ }
+
+ func testRGBSlashSyntaxParses() {
+ XCTAssertNotNil(JSColorParser.parse("rgb(255 0 0 / 80%)"))
+ XCTAssertNotNil(JSColorParser.parse("rgba(255 0 0 / 0.8)"))
+ XCTAssertNotNil(JSColorParser.parse("hsl(240 100% 50% / 30%)"))
+ }
+
+ func testTrailingGarbageRejected() {
+ XCTAssertNil(JSColorParser.parse("rgba(255,0,0,0.8)garbage"))
+ XCTAssertNil(JSColorParser.parse("rgb(255,0)"))
+ XCTAssertNil(JSColorParser.parse("hsl(120, 100, 50%)"))
+ }
+}
diff --git a/ios/ui/Style/BackgroundValue.swift b/ios/ui/Style/BackgroundValue.swift
new file mode 100644
index 0000000..84440a2
--- /dev/null
+++ b/ios/ui/Style/BackgroundValue.swift
@@ -0,0 +1,27 @@
+import SwiftUI
+
+enum RadialGradientShape {
+ case circle
+ case ellipse
+}
+
+enum RadialGradientExtent {
+ case closestSide
+ case farthestSide
+ case closestCorner
+ case farthestCorner
+}
+
+struct RadialGradientSpec {
+ var gradient: Gradient
+ var center: UnitPoint
+ var shape: RadialGradientShape
+ var extent: RadialGradientExtent
+}
+
+enum BackgroundValue {
+ case color(Color)
+ case linearGradient(gradient: Gradient, startPoint: UnitPoint, endPoint: UnitPoint)
+ case radialGradient(spec: RadialGradientSpec)
+ case angularGradient(gradient: Gradient, center: UnitPoint, angle: Angle)
+}
diff --git a/ios/ui/Style/DecorationStyle.swift b/ios/ui/Style/DecorationStyle.swift
index 6467e94..c18156a 100644
--- a/ios/ui/Style/DecorationStyle.swift
+++ b/ios/ui/Style/DecorationStyle.swift
@@ -1,7 +1,7 @@
import SwiftUI
struct DecorationStyle {
- var backgroundColor: Color?
+ var backgroundColor: BackgroundValue?
var cornerRadius: CGFloat?
var border: (width: CGFloat, color: Color)?
var shadow: (radius: CGFloat, color: Color, opacity: Double, offset: CGSize)?
@@ -12,10 +12,115 @@ struct DecorationStyle {
struct DecorationModifier: ViewModifier {
let style: DecorationStyle
+ private func point(from unitPoint: UnitPoint, in size: CGSize) -> CGPoint {
+ CGPoint(x: unitPoint.x * size.width, y: unitPoint.y * size.height)
+ }
+
+ private func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
+ hypot(a.x - b.x, a.y - b.y)
+ }
+
+ private func radialRadii(spec: RadialGradientSpec, in size: CGSize) -> (x: CGFloat, y: CGFloat) {
+ let center = point(from: spec.center, in: size)
+ let left = center.x
+ let right = size.width - center.x
+ let top = center.y
+ let bottom = size.height - center.y
+
+ let horizontalClosest = min(left, right)
+ let horizontalFarthest = max(left, right)
+ let verticalClosest = min(top, bottom)
+ let verticalFarthest = max(top, bottom)
+
+ let corners = [
+ CGPoint(x: 0, y: 0),
+ CGPoint(x: size.width, y: 0),
+ CGPoint(x: 0, y: size.height),
+ CGPoint(x: size.width, y: size.height),
+ ]
+ let cornerDistances = corners.map { distance(center, $0) }
+ let closestCorner = cornerDistances.min() ?? 0
+ let farthestCorner = cornerDistances.max() ?? 0
+
+ switch spec.shape {
+ case .circle:
+ let radius: CGFloat
+ switch spec.extent {
+ case .closestSide: radius = min(horizontalClosest, verticalClosest)
+ case .farthestSide: radius = max(horizontalFarthest, verticalFarthest)
+ case .closestCorner: radius = closestCorner
+ case .farthestCorner: radius = farthestCorner
+ }
+ return (max(0, radius), max(0, radius))
+ case .ellipse:
+ switch spec.extent {
+ case .closestSide:
+ return (max(0, horizontalClosest), max(0, verticalClosest))
+ case .farthestSide:
+ return (max(0, horizontalFarthest), max(0, verticalFarthest))
+ case .closestCorner, .farthestCorner:
+ let target = spec.extent == .closestCorner ? closestCorner : farthestCorner
+ let referenceCorner = corners.max {
+ let lhs = distance(center, $0)
+ let rhs = distance(center, $1)
+ if spec.extent == .closestCorner {
+ return lhs > rhs
+ }
+ return lhs < rhs
+ } ?? CGPoint(x: size.width, y: size.height)
+
+ let dx = abs(referenceCorner.x - center.x)
+ let dy = abs(referenceCorner.y - center.y)
+ if dy == 0 {
+ return (max(0, target), max(0, target))
+ }
+ let aspect = size.width / max(size.height, 1)
+ let ry = sqrt((dx * dx) / max(aspect * aspect, 0.0001) + dy * dy)
+ if ry == 0 {
+ return (max(0, target), max(0, target))
+ }
+ let scale = target / ry
+ return (max(0, aspect * ry * scale), max(0, ry * scale))
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func radialGradientBackground(_ spec: RadialGradientSpec) -> some View {
+ GeometryReader { proxy in
+ let size = proxy.size
+ let radii = radialRadii(spec: spec, in: size)
+ let baseRadius = max(max(radii.x, radii.y), 0.0001)
+ let circle = RadialGradient(gradient: spec.gradient, center: spec.center, startRadius: 0, endRadius: baseRadius)
+ if spec.shape == .ellipse {
+ circle
+ .scaleEffect(
+ x: radii.x / baseRadius,
+ y: radii.y / baseRadius,
+ anchor: spec.center
+ )
+ } else {
+ circle
+ }
+ }
+ .allowsHitTesting(false)
+ }
+
func body(content: Content) -> some View {
content
- .voltraIfLet(style.backgroundColor) { content, color in
- content.background(color)
+ .voltraIfLet(style.backgroundColor) { content, bg in
+ switch bg {
+ case let .color(color):
+ content.background(color)
+ case let .linearGradient(gradient, start, end):
+ content.background(LinearGradient(gradient: gradient, startPoint: start, endPoint: end))
+ case let .radialGradient(spec):
+ content.background {
+ radialGradientBackground(spec)
+ }
+ case let .angularGradient(gradient, center, angle):
+ content.background(AngularGradient(gradient: gradient, center: center, angle: angle))
+ }
}
// If we have a corner radius, we must handle the border specifically here
.voltraIfLet(style.cornerRadius) { content, radius in
diff --git a/ios/ui/Style/JSColorParser.swift b/ios/ui/Style/JSColorParser.swift
index c1c426a..d88f631 100644
--- a/ios/ui/Style/JSColorParser.swift
+++ b/ios/ui/Style/JSColorParser.swift
@@ -147,51 +147,248 @@ enum JSColorParser {
// MARK: - RGB Parser
- // rgb(255, 0, 0) / rgba(255, 0, 0, 0.5)
+ // Supports:
+ // - rgb(255, 0, 0), rgba(255, 0, 0, 0.5)
+ // - rgb(255 0 0 / 80%), rgba(255 0 0 / 0.8)
private static func parseRGB(_ string: String) -> Color? {
- let cleaned = string
- .replacingOccurrences(of: "rgba", with: "")
- .replacingOccurrences(of: "rgb", with: "")
- .replacingOccurrences(of: "(", with: "")
- .replacingOccurrences(of: ")", with: "")
- .replacingOccurrences(of: " ", with: "")
-
- let components = cleaned.split(separator: ",")
- guard components.count >= 3 else { return nil }
-
- let r = Double(components[0]) ?? 0
- let g = Double(components[1]) ?? 0
- let b = Double(components[2]) ?? 0
- let a = components.count >= 4 ? (Double(components[3]) ?? 1.0) : 1.0
+ guard let function = parseFunctionCall(string, allowedNames: ["rgb", "rgba"]) else { return nil }
+
+ let isRgba = function.name == "rgba"
+ let parsed: (r: Double, g: Double, b: Double, a: Double)?
+ if function.arguments.contains(",") {
+ parsed = parseRGBCommaSyntax(arguments: function.arguments, expectsAlpha: isRgba)
+ } else {
+ parsed = parseRGBSpaceSyntax(arguments: function.arguments, expectsAlpha: isRgba)
+ }
+ guard let parsed else { return nil }
- return Color(.sRGB, red: r / 255.0, green: g / 255.0, blue: b / 255.0, opacity: a)
+ return Color(.sRGB, red: parsed.r, green: parsed.g, blue: parsed.b, opacity: parsed.a)
}
// MARK: - HSL Parser
- // hsl(120, 100%, 50%) / hsla(...)
+ // Supports:
+ // - hsl(120, 100%, 50%), hsla(120, 100%, 50%, 0.5)
+ // - hsl(120 100% 50% / 30%), hsla(120 100% 50% / 0.3)
private static func parseHSL(_ string: String) -> Color? {
- let cleaned = string
- .replacingOccurrences(of: "hsla", with: "")
- .replacingOccurrences(of: "hsl", with: "")
- .replacingOccurrences(of: "(", with: "")
- .replacingOccurrences(of: ")", with: "")
- .replacingOccurrences(of: " ", with: "")
- .replacingOccurrences(of: "%", with: "")
-
- let components = cleaned.split(separator: ",")
- guard components.count >= 3 else { return nil }
-
- let h = (Double(components[0]) ?? 0) / 360.0
- let s = (Double(components[1]) ?? 0) / 100.0
- let l = (Double(components[2]) ?? 0) / 100.0
- let a = components.count >= 4 ? (Double(components[3]) ?? 1.0) : 1.0
+ guard let function = parseFunctionCall(string, allowedNames: ["hsl", "hsla"]) else { return nil }
+
+ let isHsla = function.name == "hsla"
+ let parsed: (h: Double, s: Double, l: Double, a: Double)?
+ if function.arguments.contains(",") {
+ parsed = parseHSLCommaSyntax(arguments: function.arguments, expectsAlpha: isHsla)
+ } else {
+ parsed = parseHSLSpaceSyntax(arguments: function.arguments, expectsAlpha: isHsla)
+ }
+ guard let parsed else { return nil }
+
+ let h = parsed.h
+ let s = parsed.s
+ let l = parsed.l
+ let a = parsed.a
// Convert HSL to RGB (HSL != HSB/HSV)
let (r, g, b) = hslToRgb(h: h, s: s, l: l)
return Color(.sRGB, red: r, green: g, blue: b, opacity: a)
}
+ private struct FunctionCall {
+ var name: String
+ var arguments: String
+ }
+
+ private static func parseFunctionCall(_ input: String, allowedNames: Set) -> FunctionCall? {
+ let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard let open = trimmed.firstIndex(of: "("), trimmed.hasSuffix(")") else { return nil }
+
+ let name = String(trimmed[.. (r: Double, g: Double, b: Double, a: Double)? {
+ let parts = splitCommaSeparated(arguments)
+ let expected = expectsAlpha ? 4 : 3
+ guard parts.count == expected else { return nil }
+
+ guard
+ let r = parseRGBChannel(parts[0]),
+ let g = parseRGBChannel(parts[1]),
+ let b = parseRGBChannel(parts[2])
+ else { return nil }
+
+ let alpha: Double
+ if expectsAlpha {
+ guard let parsedAlpha = parseAlpha(parts[3]) else { return nil }
+ alpha = parsedAlpha
+ } else {
+ alpha = 1.0
+ }
+
+ return (r, g, b, alpha)
+ }
+
+ private static func parseRGBSpaceSyntax(arguments: String, expectsAlpha: Bool) -> (r: Double, g: Double, b: Double, a: Double)? {
+ let split = splitLeftAndOptionalAlpha(arguments)
+ guard let split else { return nil }
+ let channels = splitWhitespaceSeparated(split.left)
+ guard channels.count == 3 else { return nil }
+
+ guard
+ let r = parseRGBChannel(channels[0]),
+ let g = parseRGBChannel(channels[1]),
+ let b = parseRGBChannel(channels[2])
+ else { return nil }
+
+ let alpha: Double
+ if let alphaToken = split.alpha {
+ guard let parsedAlpha = parseAlpha(alphaToken) else { return nil }
+ alpha = parsedAlpha
+ } else {
+ guard !expectsAlpha else { return nil }
+ alpha = 1.0
+ }
+
+ return (r, g, b, alpha)
+ }
+
+ private static func parseHSLCommaSyntax(arguments: String, expectsAlpha: Bool) -> (h: Double, s: Double, l: Double, a: Double)? {
+ let parts = splitCommaSeparated(arguments)
+ let expected = expectsAlpha ? 4 : 3
+ guard parts.count == expected else { return nil }
+
+ guard
+ let h = parseHue(parts[0]),
+ let s = parsePercentage(parts[1]),
+ let l = parsePercentage(parts[2])
+ else { return nil }
+
+ let alpha: Double
+ if expectsAlpha {
+ guard let parsedAlpha = parseAlpha(parts[3]) else { return nil }
+ alpha = parsedAlpha
+ } else {
+ alpha = 1.0
+ }
+
+ return (h, s, l, alpha)
+ }
+
+ private static func parseHSLSpaceSyntax(arguments: String, expectsAlpha: Bool) -> (h: Double, s: Double, l: Double, a: Double)? {
+ let split = splitLeftAndOptionalAlpha(arguments)
+ guard let split else { return nil }
+ let channels = splitWhitespaceSeparated(split.left)
+ guard channels.count == 3 else { return nil }
+
+ guard
+ let h = parseHue(channels[0]),
+ let s = parsePercentage(channels[1]),
+ let l = parsePercentage(channels[2])
+ else { return nil }
+
+ let alpha: Double
+ if let alphaToken = split.alpha {
+ guard let parsedAlpha = parseAlpha(alphaToken) else { return nil }
+ alpha = parsedAlpha
+ } else {
+ guard !expectsAlpha else { return nil }
+ alpha = 1.0
+ }
+
+ return (h, s, l, alpha)
+ }
+
+ private static func splitCommaSeparated(_ arguments: String) -> [String] {
+ let parts = arguments
+ .split(separator: ",", omittingEmptySubsequences: false)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ if parts.contains(where: \.isEmpty) {
+ return []
+ }
+ return parts
+ }
+
+ private static func splitWhitespaceSeparated(_ input: String) -> [String] {
+ input
+ .split { $0.isWhitespace }
+ .map(String.init)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
+ private static func splitLeftAndOptionalAlpha(_ arguments: String) -> (left: String, alpha: String?)? {
+ let parts = arguments.split(separator: "/", omittingEmptySubsequences: false).map {
+ $0.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ guard (1...2).contains(parts.count) else { return nil }
+ guard let left = parts.first, !left.isEmpty else { return nil }
+
+ if parts.count == 1 {
+ return (left, nil)
+ }
+
+ let alpha = parts[1]
+ guard !alpha.isEmpty else { return nil }
+ return (left, alpha)
+ }
+
+ private static func parseRGBChannel(_ token: String) -> Double? {
+ if token.hasSuffix("%") {
+ guard let pct = Double(token.dropLast()) else { return nil }
+ return clamp01(pct / 100.0)
+ }
+ guard let value = Double(token) else { return nil }
+ return clamp01(value / 255.0)
+ }
+
+ private static func parsePercentage(_ token: String) -> Double? {
+ guard token.hasSuffix("%"), let value = Double(token.dropLast()) else { return nil }
+ return clamp01(value / 100.0)
+ }
+
+ private static func parseAlpha(_ token: String) -> Double? {
+ if token.hasSuffix("%") {
+ guard let pct = Double(token.dropLast()) else { return nil }
+ return clamp01(pct / 100.0)
+ }
+ guard let value = Double(token) else { return nil }
+ return clamp01(value)
+ }
+
+ private static func parseHue(_ token: String) -> Double? {
+ let normalized: Double?
+ if token.hasSuffix("deg"), let value = Double(token.dropLast(3)) {
+ normalized = value / 360.0
+ } else if token.hasSuffix("rad"), let value = Double(token.dropLast(3)) {
+ normalized = value / (2 * .pi)
+ } else if token.hasSuffix("turn"), let value = Double(token.dropLast(4)) {
+ normalized = value
+ } else {
+ normalized = Double(token).map { $0 / 360.0 }
+ }
+
+ guard let value = normalized else { return nil }
+ return normalizeUnit(value)
+ }
+
+ private static func clamp01(_ value: Double) -> Double {
+ Swift.max(0, Swift.min(1, value))
+ }
+
+ private static func normalizeUnit(_ value: Double) -> Double {
+ var result = value.truncatingRemainder(dividingBy: 1)
+ if result < 0 { result += 1 }
+ return result
+ }
+
/// Convert HSL to RGB
/// - Parameters:
/// - h: Hue (0.0 to 1.0)
diff --git a/ios/ui/Style/JSGradientParser.swift b/ios/ui/Style/JSGradientParser.swift
new file mode 100644
index 0000000..dfbb8db
--- /dev/null
+++ b/ios/ui/Style/JSGradientParser.swift
@@ -0,0 +1,674 @@
+import Foundation
+import SwiftUI
+
+enum JSGradientParser {
+ private enum CacheEntry {
+ case success(BackgroundValue)
+ case failure
+ }
+
+ private enum GradientKind {
+ case linear
+ case radial
+ case conic
+ }
+
+ private struct ColorStopToken {
+ var color: Color
+ var firstPosition: Double?
+ var secondPosition: Double?
+ }
+
+ private struct LinearSpec {
+ var startPoint: UnitPoint
+ var endPoint: UnitPoint
+ var stops: [Gradient.Stop]
+ }
+
+ private struct RadialSpec {
+ var center: UnitPoint
+ var shape: RadialGradientShape
+ var extent: RadialGradientExtent
+ var stops: [Gradient.Stop]
+ }
+
+ private struct ConicSpec {
+ var center: UnitPoint
+ var angle: Angle
+ var stops: [Gradient.Stop]
+ }
+
+ private static let cacheLimit = 256
+ private static var cache: [String: CacheEntry] = [:]
+ private static var cacheOrder: [String] = []
+ private static let cacheLock = NSLock()
+
+ static func parse(_ input: String) -> BackgroundValue? {
+ let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !raw.isEmpty else { return nil }
+ let key = raw.lowercased()
+
+ if let cached = cacheValue(for: key) {
+ switch cached {
+ case let .success(value):
+ return value
+ case .failure:
+ return nil
+ }
+ }
+
+ let result = parseUncached(raw)
+ setCacheValue(result.map(CacheEntry.success) ?? .failure, for: key)
+ return result
+ }
+
+ private static func parseUncached(_ raw: String) -> BackgroundValue? {
+ let lower = raw.lowercased()
+ if lower.hasPrefix("repeating-linear-gradient(") || lower.hasPrefix("repeating-radial-gradient(") || lower.hasPrefix("repeating-conic-gradient(") {
+ return nil
+ }
+ if lower.hasPrefix("linear-gradient(") {
+ return parseLinear(raw)
+ }
+ if lower.hasPrefix("radial-gradient(") {
+ return parseRadial(raw)
+ }
+ if lower.hasPrefix("conic-gradient(") {
+ return parseConic(raw)
+ }
+ return nil
+ }
+
+ // MARK: - Top level parsers
+
+ private static func parseLinear(_ value: String) -> BackgroundValue? {
+ guard let content = extractContent(value, prefix: "linear-gradient(") else { return nil }
+ let args = splitGradientArgs(content)
+ guard args.count >= 2 else { return nil }
+
+ var startPoint = UnitPoint(x: 0.5, y: 0)
+ var endPoint = UnitPoint(x: 0.5, y: 1)
+ var stopArgs = args
+
+ if let first = args.first {
+ let trimmed = first.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.lowercased().hasPrefix("to ") {
+ guard let points = parseLinearDirection(trimmed) else { return nil }
+ startPoint = points.start
+ endPoint = points.end
+ stopArgs = Array(args.dropFirst())
+ } else if let angle = parseAngle(trimmed) {
+ let points = angleToPoints(angle)
+ startPoint = points.start
+ endPoint = points.end
+ stopArgs = Array(args.dropFirst())
+ }
+ }
+
+ guard let stops = parseStops(stopArgs, kind: .linear), stops.count >= 2 else { return nil }
+ let spec = LinearSpec(startPoint: startPoint, endPoint: endPoint, stops: stops)
+ return .linearGradient(gradient: Gradient(stops: spec.stops), startPoint: spec.startPoint, endPoint: spec.endPoint)
+ }
+
+ private static func parseRadial(_ value: String) -> BackgroundValue? {
+ guard let content = extractContent(value, prefix: "radial-gradient(") else { return nil }
+ let args = splitGradientArgs(content)
+ guard args.count >= 2 else { return nil }
+
+ var shape: RadialGradientShape = .ellipse
+ var extent: RadialGradientExtent = .farthestCorner
+ var center = UnitPoint.center
+ var stopArgs = args
+
+ if let first = args.first, parseColorStop(first, kind: .radial) == nil {
+ guard let radialPrelude = parseRadialPrelude(first) else { return nil }
+ shape = radialPrelude.shape
+ extent = radialPrelude.extent
+ center = radialPrelude.center
+ stopArgs = Array(args.dropFirst())
+ }
+
+ guard let stops = parseStops(stopArgs, kind: .radial), stops.count >= 2 else { return nil }
+ let spec = RadialSpec(center: center, shape: shape, extent: extent, stops: stops)
+ return .radialGradient(spec: RadialGradientSpec(
+ gradient: Gradient(stops: spec.stops),
+ center: spec.center,
+ shape: spec.shape,
+ extent: spec.extent
+ ))
+ }
+
+ private static func parseConic(_ value: String) -> BackgroundValue? {
+ guard let content = extractContent(value, prefix: "conic-gradient(") else { return nil }
+ let args = splitGradientArgs(content)
+ guard args.count >= 2 else { return nil }
+
+ var center = UnitPoint.center
+ var angle = Angle.zero
+ var stopArgs = args
+
+ if let first = args.first, parseColorStop(first, kind: .conic) == nil {
+ guard let prelude = parseConicPrelude(first) else { return nil }
+ center = prelude.center
+ angle = prelude.angle
+ stopArgs = Array(args.dropFirst())
+ }
+
+ guard let stops = parseStops(stopArgs, kind: .conic), stops.count >= 2 else { return nil }
+ let spec = ConicSpec(center: center, angle: angle, stops: stops)
+ return .angularGradient(gradient: Gradient(stops: spec.stops), center: spec.center, angle: spec.angle)
+ }
+
+ // MARK: - Cache
+
+ private static func cacheValue(for key: String) -> CacheEntry? {
+ cacheLock.lock()
+ defer { cacheLock.unlock() }
+ return cache[key]
+ }
+
+ private static func setCacheValue(_ value: CacheEntry, for key: String) {
+ cacheLock.lock()
+ defer { cacheLock.unlock() }
+
+ if cache[key] == nil {
+ cacheOrder.append(key)
+ if cacheOrder.count > cacheLimit {
+ let oldest = cacheOrder.removeFirst()
+ cache.removeValue(forKey: oldest)
+ }
+ }
+ cache[key] = value
+ }
+
+ // MARK: - Grammar helpers
+
+ static func extractContent(_ value: String, prefix: String) -> String? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ let lower = trimmed.lowercased()
+ guard lower.hasPrefix(prefix), trimmed.hasSuffix(")") else { return nil }
+ return String(trimmed.dropFirst(prefix.count).dropLast())
+ }
+
+ static func splitGradientArgs(_ content: String) -> [String] {
+ var args: [String] = []
+ var current = ""
+ var depth = 0
+
+ for char in content {
+ if char == "(" {
+ depth += 1
+ } else if char == ")" {
+ depth -= 1
+ if depth < 0 { return [] }
+ }
+
+ if char == ",", depth == 0 {
+ let token = current.trimmingCharacters(in: .whitespacesAndNewlines)
+ if token.isEmpty { return [] }
+ args.append(token)
+ current = ""
+ } else {
+ current.append(char)
+ }
+ }
+
+ guard depth == 0 else { return [] }
+
+ let token = current.trimmingCharacters(in: .whitespacesAndNewlines)
+ if token.isEmpty { return [] }
+ args.append(token)
+ return args
+ }
+
+ private static func splitByWhitespaceOutsideParentheses(_ value: String) -> [String] {
+ var result: [String] = []
+ var current = ""
+ var depth = 0
+
+ for char in value {
+ if char == "(" {
+ depth += 1
+ } else if char == ")" {
+ depth -= 1
+ if depth < 0 { return [] }
+ }
+
+ if char.isWhitespace, depth == 0 {
+ let token = current.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !token.isEmpty {
+ result.append(token)
+ }
+ current = ""
+ } else {
+ current.append(char)
+ }
+ }
+
+ guard depth == 0 else { return [] }
+
+ let tail = current.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !tail.isEmpty {
+ result.append(tail)
+ }
+ return result
+ }
+
+ // MARK: - Linear helpers
+
+ private static func parseLinearDirection(_ value: String) -> (start: UnitPoint, end: UnitPoint)? {
+ let lower = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
+ guard lower.hasPrefix("to ") else { return nil }
+ let suffix = lower.dropFirst(3)
+ let words = suffix.split(separator: " ").map(String.init)
+ guard (1 ... 2).contains(words.count) else { return nil }
+
+ var horizontal: String?
+ var vertical: String?
+
+ for word in words {
+ switch word {
+ case "left", "right":
+ if horizontal != nil { return nil }
+ horizontal = word
+ case "top", "bottom":
+ if vertical != nil { return nil }
+ vertical = word
+ default:
+ return nil
+ }
+ }
+
+ let endX: Double = switch horizontal {
+ case "left": 0
+ case "right": 1
+ default: 0.5
+ }
+ let endY: Double = switch vertical {
+ case "top": 0
+ case "bottom": 1
+ default: 0.5
+ }
+ let start = UnitPoint(x: 1 - endX, y: 1 - endY)
+ let end = UnitPoint(x: endX, y: endY)
+ return (start, end)
+ }
+
+ private static func angleToPoints(_ angle: Angle) -> (start: UnitPoint, end: UnitPoint) {
+ let radians = (angle.degrees - 90) * .pi / 180
+ let x = cos(radians)
+ let y = sin(radians)
+
+ let startX = 0.5 - x / 2
+ let startY = 0.5 + y / 2
+ let endX = 0.5 + x / 2
+ let endY = 0.5 - y / 2
+ return (UnitPoint(x: startX, y: startY), UnitPoint(x: endX, y: endY))
+ }
+
+ // MARK: - Radial helpers
+
+ private static func parseRadialPrelude(_ value: String) -> (shape: RadialGradientShape, extent: RadialGradientExtent, center: UnitPoint)? {
+ let tokens = splitByWhitespaceOutsideParentheses(value.lowercased())
+ guard !tokens.isEmpty else { return nil }
+
+ var shape: RadialGradientShape = .ellipse
+ var extent: RadialGradientExtent = .farthestCorner
+ var center = UnitPoint.center
+ var idx = 0
+
+ while idx < tokens.count {
+ let token = tokens[idx]
+ if token == "at" {
+ let positionTokens = Array(tokens[(idx + 1)...])
+ guard let parsedCenter = parsePosition(positionTokens), !positionTokens.isEmpty else { return nil }
+ center = parsedCenter
+ idx = tokens.count
+ } else if token == "circle" {
+ shape = .circle
+ idx += 1
+ } else if token == "ellipse" {
+ shape = .ellipse
+ idx += 1
+ } else if let parsedExtent = parseRadialExtent(token) {
+ extent = parsedExtent
+ idx += 1
+ } else {
+ return nil
+ }
+ }
+
+ return (shape, extent, center)
+ }
+
+ private static func parseRadialExtent(_ token: String) -> RadialGradientExtent? {
+ switch token {
+ case "closest-side":
+ return .closestSide
+ case "farthest-side":
+ return .farthestSide
+ case "closest-corner":
+ return .closestCorner
+ case "farthest-corner":
+ return .farthestCorner
+ default:
+ return nil
+ }
+ }
+
+ // MARK: - Conic helpers
+
+ private static func parseConicPrelude(_ value: String) -> (angle: Angle, center: UnitPoint)? {
+ let tokens = splitByWhitespaceOutsideParentheses(value.lowercased())
+ guard !tokens.isEmpty else { return nil }
+
+ var angle = Angle.zero
+ var center = UnitPoint.center
+ var hasFrom = false
+ var hasAt = false
+ var idx = 0
+
+ while idx < tokens.count {
+ let token = tokens[idx]
+ if token == "from" {
+ guard !hasFrom, idx + 1 < tokens.count, let parsedAngle = parseAngle(tokens[idx + 1]) else { return nil }
+ angle = parsedAngle
+ hasFrom = true
+ idx += 2
+ } else if token == "at" {
+ guard !hasAt else { return nil }
+ let positionTokens = Array(tokens[(idx + 1)...])
+ guard let parsedCenter = parsePosition(positionTokens), !positionTokens.isEmpty else { return nil }
+ center = parsedCenter
+ hasAt = true
+ idx = tokens.count
+ } else {
+ return nil
+ }
+ }
+
+ return (angle, center)
+ }
+
+ // MARK: - Position helpers
+
+ private static func parsePosition(_ tokens: [String]) -> UnitPoint? {
+ guard !tokens.isEmpty, tokens.count <= 2 else { return nil }
+
+ if tokens.count == 1 {
+ switch tokens[0] {
+ case "center":
+ return .center
+ case "left":
+ return UnitPoint(x: 0, y: 0.5)
+ case "right":
+ return UnitPoint(x: 1, y: 0.5)
+ case "top":
+ return UnitPoint(x: 0.5, y: 0)
+ case "bottom":
+ return UnitPoint(x: 0.5, y: 1)
+ default:
+ return nil
+ }
+ }
+
+ var x: Double?
+ var y: Double?
+
+ for token in tokens {
+ switch token {
+ case "left":
+ if x != nil { return nil }
+ x = 0
+ case "right":
+ if x != nil { return nil }
+ x = 1
+ case "center":
+ if x == nil {
+ x = 0.5
+ } else if y == nil {
+ y = 0.5
+ } else {
+ return nil
+ }
+ case "top":
+ if y != nil { return nil }
+ y = 0
+ case "bottom":
+ if y != nil { return nil }
+ y = 1
+ default:
+ return nil
+ }
+ }
+
+ return UnitPoint(x: x ?? 0.5, y: y ?? 0.5)
+ }
+
+ // MARK: - Stop helpers
+
+ private static func parseStops(_ args: [String], kind: GradientKind) -> [Gradient.Stop]? {
+ var tokens: [ColorStopToken] = []
+
+ for raw in args {
+ guard let parsed = parseColorStop(raw, kind: kind) else { return nil }
+ tokens.append(parsed)
+ }
+
+ guard tokens.count >= 2 else { return nil }
+
+ let expanded = expandColorStops(tokens)
+ let resolved = resolveStopPositions(expanded)
+ guard resolved.count >= 2 else { return nil }
+
+ return resolved.map { stop in
+ Gradient.Stop(color: stop.color, location: CGFloat(stop.position))
+ }
+ }
+
+ private static func parseColorStop(_ token: String, kind: GradientKind) -> ColorStopToken? {
+ let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ // Function color with optional trailing stop positions, e.g. "rgba(...) 0%".
+ if let functionSplit = splitFunctionColorAndPositions(trimmed),
+ let color = JSColorParser.parse(functionSplit.colorToken)
+ {
+ var parsedPositions: [Double] = []
+ for positionToken in functionSplit.positionTokens {
+ guard let position = parseStopPosition(positionToken, kind: kind) else { return nil }
+ parsedPositions.append(position)
+ }
+ return ColorStopToken(
+ color: color,
+ firstPosition: parsedPositions.first,
+ secondPosition: parsedPositions.count == 2 ? parsedPositions[1] : nil
+ )
+ }
+
+ // Generic "color + optional trailing positions" split from right side.
+ if let split = splitColorAndPositions(trimmed, kind: kind),
+ let color = JSColorParser.parse(split.colorToken)
+ {
+ var parsed: [Double] = []
+ for positionToken in split.positionTokens {
+ guard let position = parseStopPosition(positionToken, kind: kind) else { return nil }
+ parsed.append(position)
+ }
+
+ return ColorStopToken(
+ color: color,
+ firstPosition: parsed.first,
+ secondPosition: parsed.count == 2 ? parsed[1] : nil
+ )
+ }
+
+ if let color = JSColorParser.parse(trimmed) {
+ return ColorStopToken(color: color, firstPosition: nil, secondPosition: nil)
+ }
+
+ return nil
+ }
+
+ private static func splitFunctionColorAndPositions(_ token: String) -> (colorToken: String, positionTokens: [String])? {
+ let lower = token.lowercased()
+ let fnPrefixes = ["rgba(", "rgb(", "hsla(", "hsl("]
+ guard fnPrefixes.contains(where: { lower.hasPrefix($0) }) else { return nil }
+
+ var depth = 0
+ var closeIndex: String.Index?
+ for idx in token.indices {
+ let ch = token[idx]
+ if ch == "(" {
+ depth += 1
+ } else if ch == ")" {
+ depth -= 1
+ if depth == 0 {
+ closeIndex = idx
+ break
+ }
+ if depth < 0 {
+ return nil
+ }
+ }
+ }
+ guard let closeIndex else { return nil }
+
+ let colorToken = String(token[...closeIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
+ let restStart = token.index(after: closeIndex)
+ let rest = token[restStart...].trimmingCharacters(in: .whitespacesAndNewlines)
+ if rest.isEmpty {
+ return (colorToken, [])
+ }
+ let positionTokens = splitByWhitespaceOutsideParentheses(String(rest))
+ guard !positionTokens.isEmpty, positionTokens.count <= 2 else { return nil }
+ return (colorToken, positionTokens)
+ }
+
+ private static func splitColorAndPositions(_ token: String, kind: GradientKind) -> (colorToken: String, positionTokens: [String])? {
+ let parts = splitByWhitespaceOutsideParentheses(token)
+ guard parts.count >= 2 else { return nil }
+
+ // Try two trailing position tokens first (to support "red 10% 30%").
+ if parts.count >= 3,
+ let colorToken = join(parts[0 ..< parts.count - 2])
+ {
+ let two = [parts[parts.count - 2], parts[parts.count - 1]]
+ if two.allSatisfy({ parseStopPosition($0, kind: kind) != nil }) {
+ return (colorToken, two)
+ }
+ }
+
+ // Try one trailing position token.
+ if let colorToken = join(parts[0 ..< parts.count - 1]) {
+ let one = [parts[parts.count - 1]]
+ if one.allSatisfy({ parseStopPosition($0, kind: kind) != nil }) {
+ return (colorToken, one)
+ }
+ }
+
+ return nil
+ }
+
+ private static func join(_ parts: ArraySlice) -> String? {
+ let value = parts.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+
+ private static func parseStopPosition(_ token: String, kind: GradientKind) -> Double? {
+ let lower = token.lowercased()
+ if lower.hasSuffix("%"), let value = Double(lower.dropLast()) {
+ return value / 100
+ }
+
+ if kind == .conic, let angle = parseAngle(lower) {
+ return angle.degrees / 360
+ }
+
+ return nil
+ }
+
+ private struct ResolvedStop {
+ var color: Color
+ var position: Double
+ }
+
+ private struct IntermediateStop {
+ var color: Color
+ var position: Double?
+ }
+
+ private static func expandColorStops(_ stops: [ColorStopToken]) -> [IntermediateStop] {
+ var expanded: [IntermediateStop] = []
+ for stop in stops {
+ expanded.append(.init(color: stop.color, position: stop.firstPosition))
+ if let second = stop.secondPosition {
+ expanded.append(.init(color: stop.color, position: second))
+ }
+ }
+ return expanded
+ }
+
+ private static func resolveStopPositions(_ stops: [IntermediateStop]) -> [ResolvedStop] {
+ guard !stops.isEmpty else { return [] }
+
+ var positions = stops.map(\.position)
+ if positions.first == nil { positions[0] = 0 }
+ if positions.last == nil { positions[positions.count - 1] = 1 }
+
+ var lastDefined: Double?
+ for idx in positions.indices {
+ if var current = positions[idx] {
+ if let previous = lastDefined, current < previous {
+ current = previous
+ positions[idx] = current
+ }
+ lastDefined = current
+ }
+ }
+
+ var index = 0
+ while index < positions.count {
+ if positions[index] != nil {
+ index += 1
+ continue
+ }
+
+ let start = index - 1
+ var end = index
+ while end < positions.count, positions[end] == nil {
+ end += 1
+ }
+ guard start >= 0 else { return [] }
+ guard end < positions.count, let startPos = positions[start], let endPos = positions[end] else { return [] }
+
+ let gaps = end - start
+ for step in 1 ..< gaps {
+ let t = Double(step) / Double(gaps)
+ positions[start + step] = startPos + (endPos - startPos) * t
+ }
+ index = end + 1
+ }
+
+ return zip(stops, positions).compactMap { stop, position in
+ guard let position else { return nil }
+ return ResolvedStop(color: stop.color, position: position)
+ }
+ }
+
+ // MARK: - Numeric helpers
+
+ private static func parseAngle(_ token: String) -> Angle? {
+ let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ if trimmed.hasSuffix("deg"), let value = Double(trimmed.dropLast(3)) {
+ return Angle(degrees: value)
+ }
+ if trimmed.hasSuffix("rad"), let value = Double(trimmed.dropLast(3)) {
+ return Angle(radians: value)
+ }
+ if trimmed.hasSuffix("turn"), let value = Double(trimmed.dropLast(4)) {
+ return Angle(degrees: value * 360)
+ }
+ return nil
+ }
+}
diff --git a/ios/ui/Style/JSStyleParser.swift b/ios/ui/Style/JSStyleParser.swift
index 6557599..5a5563b 100644
--- a/ios/ui/Style/JSStyleParser.swift
+++ b/ios/ui/Style/JSStyleParser.swift
@@ -35,6 +35,18 @@ enum JSStyleParser {
JSColorParser.parse(value)
}
+ static func background(_ value: Any?) -> BackgroundValue? {
+ guard let string = value as? String else { return nil }
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ if let gradient = JSGradientParser.parse(trimmed) {
+ return gradient
+ }
+ if let color = JSColorParser.parse(string) {
+ return .color(color)
+ }
+ return nil
+ }
+
static func boolean(_ value: Any?) -> Bool {
guard let value = value else { return false }
diff --git a/ios/ui/Style/StyleConverter.swift b/ios/ui/Style/StyleConverter.swift
index b7bdd1c..9cd5538 100644
--- a/ios/ui/Style/StyleConverter.swift
+++ b/ios/ui/Style/StyleConverter.swift
@@ -134,7 +134,7 @@ enum StyleConverter {
let overflow = JSStyleParser.overflow(js["overflow"])
return DecorationStyle(
- backgroundColor: JSStyleParser.color(js["backgroundColor"]),
+ backgroundColor: JSStyleParser.background(js["backgroundColor"]),
cornerRadius: JSStyleParser.number(js["borderRadius"]),
border: border,
shadow: shadow,
diff --git a/website/docs/ios/development/styling.md b/website/docs/ios/development/styling.md
index 188a4ea..4cea5b8 100644
--- a/website/docs/ios/development/styling.md
+++ b/website/docs/ios/development/styling.md
@@ -31,7 +31,7 @@ The following React Native style properties are supported:
**Style:**
-- `backgroundColor` - Background color (hex strings or color names)
+- `backgroundColor` - Background color (hex strings, color names, or CSS gradient strings — see [Gradients](#gradients))
- `opacity` - Opacity value between 0 and 1
- `borderRadius` - Corner radius value
- `borderWidth` - Border width
@@ -133,6 +133,125 @@ const element = (
)
```
+## Gradients
+
+The `backgroundColor` style property accepts CSS gradient strings in addition to solid colors. Gradients are rendered natively using SwiftUI gradient modifiers and are automatically clipped by `borderRadius`.
+
+Invalid or unsupported gradient syntax is parsed in **strict mode** and results in **no gradient background** (instead of silent best-effort fallback).
+
+### Linear gradients
+
+```tsx
+// Named direction
+
+
+// Diagonal
+
+
+// Angle in degrees/radians/turns
+
+
+```
+
+Supported directions: `to right`, `to left`, `to top`, `to bottom`, `to top right`, `to top left`, `to bottom right`, `to bottom left`.
+
+### Color stops
+
+Explicit percentage positions are supported:
+
+```tsx
+
+```
+
+When positions are omitted, Voltra applies CSS-like stop fix-up:
+- First and last unspecified stops default to `0%` and `100%`.
+- Unspecified stops between explicit anchors are linearly interpolated.
+- Non-monotonic explicit positions are clamped forward.
+
+### RGBA colors inside gradients
+
+```tsx
+
+```
+
+### Radial gradients
+
+```tsx
+
+
+
+```
+
+:::note
+Radial gradients are rendered with geometry-aware radii computed from the view size (`closest-side`, `farthest-side`, `closest-corner`, `farthest-corner`).
+
+For `ellipse`, SwiftUI does not provide native elliptical radial gradients. Voltra approximates ellipse behavior by scaling a circular radial gradient.
+:::
+
+### Conic gradients
+
+```tsx
+
+
+```
+
+### With border radius
+
+Gradients are clipped by `borderRadius` automatically — no extra configuration needed:
+
+```tsx
+
+ Rounded gradient card
+
+```
+
+### Solid colors still work unchanged
+
+Passing a plain color string to `backgroundColor` continues to work exactly as before:
+
+```tsx
+
+
+
+```
+
+### Comparison with `` component
+
+| Feature | `backgroundColor` gradient | `` component |
+|---|---|---|
+| CSS string syntax | ✓ | — |
+| Named directions (`to right`) | ✓ (physical direction) | ✓ |
+| Angle units | `deg`, `rad`, `turn` | ✓ via `{x,y}` |
+| `{x, y}` coordinate control | — | ✓ |
+| Stop positions | `linear/radial`: `%`, `conic`: `%` + angle units | ✓ via `locations` prop |
+| Multi-position stops (`red 20% 40%`) | ✓ | — |
+| `radial-gradient` | ✓ (`circle` / approximated `ellipse`) | — |
+| `conic-gradient` | ✓ (`from` + `at`) | — |
+| Strict invalid syntax handling | ✓ (fails closed) | — |
+| Dithering | — | ✓ |
+| Children layered on top | ✓ (as background) | ✓ (as container) |
+
+Use `backgroundColor` gradient strings for convenience and web-style syntax. Use `` when you need precise `{x, y}` coordinate control or dithering.
+
+### Scope and exclusions
+
+- Supported core syntax: `linear-gradient(...)`, `radial-gradient(...)`, `conic-gradient(...)`.
+- Not supported in this parser: `repeating-linear-gradient(...)`, `repeating-radial-gradient(...)`, `repeating-conic-gradient(...)`.
+
## Custom Fonts
Voltra supports custom fonts through the `fontFamily` style property.
@@ -251,4 +370,3 @@ If you're using Google Fonts via `@expo-google-fonts`, they work seamlessly with
:::note System Font Fallback
If `fontFamily` is not specified or the font cannot be found, Voltra will fall back to the system font with the specified `fontWeight`.
:::
-