Bringing CSS Clipping to React Native

Authors
Kamil Paradowski
Software Engineer
@
Callstack

If you've ever tried to create angled headers or any other non-rectangular section dividers in React Native, you've probably felt that something is missing. On the web, CSS clip-path makes these shapes trivial.‍‍

.star-badge {
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}

But sadly, it’s not available for React Native apps, so we need to import a third-party library:‍‍

import {MaskedView} from ...

<MaskedView path="..." />

The effect of this code is this pretty shape:

We ended up with cross-platform star-shape masking. The code, however, looks quite different for web and mobile platforms. In this article I’d like to explore what it would take for React Native to support masking with clip-path natively through the CSS-like StyleSheet API and propose solutions.

What is CSS clip-path?

The clip-path CSS property defines a clipping region that acts like a mask: only the parts of an element that fall inside the region are visible, while everything outside is hidden. This region is described by two components: the shape itself and the geometry box that determines how that shape is positioned and sized.

The Shape Function

This defines the geometry of your cut. You can use helper shape functions (circle(), polygon(), inset(), …) or point to a local or remote resource (url(image.png)).

SVG images won’t be supported in the first version of clip-path implementation for React Native; path() shape does not work.

The Geometry Box

This is the reference frame. It tells the renderer which part of the element's "box" to use as the starting point for your shape's coordinates (0,0). The default is border-box, but you can also use padding-box, content-box or margin-box.

__wf_reserved_decorative
The Geometry Box - Margin-box reference box

clip-path in Action

.avatar {
  clip-path: circle(50%);
}

.star-badge {
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}

Styles as presented above should result in the following view.

Clip-Path in Action - View Result

Why React Native Is Different

On the web, the rendering stack is unified under a single engine such as Blink. It parses CSS and computes layout. Then it uses a graphics engine like Skia to paint every pixel on the screen.

In contrast, React Native doesn't draw its own UI by default. Instead, React Native acts as a coordinator. It translates your styles into layout instructions, which the host operating system (iOS or Android) draws using native UI kits.

Essentially, the web simulates a UI inside a browser window, while React Native assembles a UI using the phone's native building blocks.

At the same time, React Native gives us a subset of CSS to use, but not everything. Let's see how this works in practice using the clip-path example.

From Style to Screen

React Native architecture can be treated as a distributed system. It coordinates work across various pieces. This means that to add support for the new attribute, we have to touch and modify a lot of different parts of it.

Let's tackle them one by one.

The journey starts when you write a sample clipPath: 'circle(50% at 25% 25%)' definition in your styles.

Starting at the JS Layer

React files have to be aware of the appearance of our new attribute, thus we need to ensure its visibility to TypeScript or Flow. Its argument is just the text, so it should be as easy as the following change, right?

export type ____ViewStyle_InternalBase = $ReadOnly<{
   // ...
   isolation?: 'auto' | 'isolate',
+  clipPath?: string,
}>;

‍‍Well, not really. The JS layer is also responsible for parsing the argument's value and converting it into an object structure, that is later processed by the native code. It means that we also need to add parsing functionality and relevant types that describe the possible new clipPath input schema.

It has to come along with tests and a few other minor touches. I will skip these parts to keep this article short and to-the-point. Let's see what happens when our new CSS attribute value, in form either of parsed object or a raw string, is passed on to the React Native C++ core layer.

Recently React Native core team added a new, native CSS parser. It means that attribute text values can be parsed, tokenized and converted to native types directly by C++ code! This not only offers a slight performance boost but is also the technically correct approach. However, it's currently in development and hidden by an internal feature flag. I am looking forward to seeing it enabled by default.

C++ as the Source of Truth

React Native's new architecture renderer (a.k.a. Fabric) uses C++ as a shared core between platforms. This is the layer that enables true cross-platform compatibility. The received clip path data needs to be represented in C++ so that both iOS and Android (and other platforms such as Windows, which recently adopted New Architecture by default in v0.81) can consume it.

But let's start from the beginning. To understand how a change of CSS property results in a re-rendered application, we need to walk end-to-end and briefly explain the render pipeline of React Native.

Let's see step by step what changes are required at any specific point to add our new CSS attribute functionality.

CSS Attribute Functionality - Steps Required
If you would like to dive deeper into React Native's new render pipeline, it is very well described in the official documentation of the New Architecture, especially check the Render, Commit, and Mount section.

Everything starts from the process called Reconciliation. During this stage, called Render Phase, React's reconciler computes the diff between old and new props, detects changes to props, and schedules a re-render. The changes in this step were discussed in the previous paragraph.

When props change, React clones Shadow Node with new props. Unlike the old architecture, where this was a slow, asynchronous message to a separate thread, this now happens through direct JSI calls.

Why cloning is helpful here?

Shadow Tree Nodes are immutable due to thread safety reasons. To change the CSS attribute, the renderer cannot mutate the existing node. It has to clone the affected Shadow Node with the new prop. It uses structural sharing to refer back to the old nodes that didn't change, minimizing memory overhead.

That’s the place we need to step in.

Shadow Node is a representation of a React Shadow Tree element. It contains props that originate from JavaScript along with layout information. We need to expand the list of properties and add a new one.

namespace facebook::react {

class BaseViewProps : public YogaStylableProps, public AccessibilityProps {
	 // ...
	 
   // Color
   Float opacity{1.0};
   SharedColor backgroundColor{};

   // Borders
   CascadedBorderRadii borderRadii{};
   CascadedBorderColors borderColors{};
   
 	 // ...
+  std::optional<ClipPath> clipPath{};
}

} // namespace facebook::react

Integrating clipPath into the BaseViewProps class definition

std::optional<T> means in the C++ world that the property is nullable, and ClipPath is our new type that represents this attribute. It generally looks like this:

namespace facebook::react {

// Define individual geometric primitives used for clipping
struct CircleShape {
  std::optional<ValueUnit> r{};
  std::optional<ValueUnit> cx{};
  std::optional<ValueUnit> cy{};
	// ...
};

struct EllipseShape {
  std::optional<ValueUnit> rx{};
  std::optional<ValueUnit> ry{};
  std::optional<ValueUnit> cx{};
  std::optional<ValueUnit> cy{};
  // ...
}
...
// A type-safe union representing any possible basic shape
using BasicShape = std::variant<
	CircleShape,
	EllipseShape,
	InsetShape,
	PolygonShape,
	RectShape,
	XywhShape>;

// Standard box model enumerations for the clip reference box
enum class GeometryBox : uint8_t {
  MarginBox,
  BorderBox,
  ContentBox,
  PaddingBox
};

// Main structure representing a full clip-path value
struct ClipPath {
  std::optional<BasicShape> shape;
  std::optional<GeometryBox> geometryBox;

  bool operator==(const ClipPath &other) const;

// Debugging and serialization helpers
#if RN_DEBUG_STRING_CONVERTIBLE
  std::string toString() const;
#endif

#ifdef RN_SERIALIZABLE_STATE
  folly::dynamic toDynamic() const;
#endif
};

} // namespace facebook::react

Data structures for the clip-path property implementation

We need to ensure that, as the CSS specification says, the clip-path attribute should result in the creation of a stacking context.

It requires a small modification of ViewShadowNode class, which is the Shadow Node representation of <View> component to be mounted. Lack of this adjustment would lead to unexpected results, such as not clipping children of the component.

void ViewShadowNode::initialize() noexcept {
  // ...
  bool formsStackingContext = !viewProps.collapsable ||
      viewProps.pointerEvents == PointerEventsMode::None ||
      !viewProps.nativeId.empty() || viewProps.accessible ||
      viewProps.opacity != 1.0 || viewProps.transform != Transform{} ||
      (viewProps.zIndex.has_value() &&
       viewProps.yogaStyle.positionType() != yoga::PositionType::Static) ||
      viewProps.yogaStyle.display() == yoga::Display::None ||
      viewProps.getClipsContentToBounds() || viewProps.events.bits.any() ||
      isColorMeaningful(viewProps.shadowColor) ||
      viewProps.accessibilityElementsHidden ||
      viewProps.accessibilityViewIsModal ||
      viewProps.importantForAccessibility != ImportantForAccessibility::Auto ||
      viewProps.removeClippedSubviews || viewProps.cursor != Cursor::Auto ||
      !viewProps.filter.empty() ||
      viewProps.mixBlendMode != BlendMode::Normal ||
      viewProps.isolation == Isolation::Isolate ||
      HostPlatformViewTraitsInitializer::formsStackingContext(viewProps) ||
      !viewProps.accessibilityOrder.empty() ||
+     viewProps.clipPath.has_value();
  // ...
  if (formsStackingContext) {
    traits_.set(ShadowNodeTraits::Trait::FormsStackingContext);
  } else {
    traits_.unset(ShadowNodeTraits::Trait::FormsStackingContext);
  }
  // ...
}

Updating Stacking Context logic to include clip-path

‍When Rendering is finished, commit of Shadow Tree is triggered. It promotes the new tree as the “next tree” to be mounted, and also schedules calculation of layout (Yoga).

The next, and the last phase is Mounting: transforming React Shadow Tree into a Host View Tree.

It includes three steps:

  • Tree Diffing, where computation of the diff between previous and the next tree happens.
  • Tree Promotion to ensure the next mount phase computes a diff against proper tree.
  • View Mounting, where the actual rendering changes have to occur.

Okay, we know how information about a property change is propagated from JavaScript up to the Host Platform level. Let's now explore how the actual implementation of clip-path looks for iOS and Android.

Clipping on iOS

React Native uses UIKit to handle and build UI elements, and its most fundamental building block is UIView. In UIKit, every UIView is backed by a CALayer (Core Animation Layer). While the view handles user interactions like touches, the layer is what actually renders the content on the screen and acts like a canvas. Each layer has a mask property that acts as a transparency mask - telling what should be visible and what should be hidden. With that knowledge, let's get down to implementation!

Fortunately for us, there is one custom React Native iOS class (RCTViewComponentView) that extends UIView and every type of created View, no matter if it is <View> or <Image>, inherits from it. This means we only need to add our clip-path logic there and it will work for every React Native component! The place we are looking for is invalidateLayer function, it has to be extended by the block of code shown below.

- (void)invalidateLayer
{
	// ...
  // Handle clip-path property
  if (_props->clipPath.has_value()) {
    CALayer *maskLayer = [RCTClipPathUtils createClipPathLayer:_props->clipPath.value()
                                                 layoutMetrics:_layoutMetrics
                                                     yogaStyle:_props->yogaStyle
                                                        bounds:layer.bounds
                                                   cornerRadii:RCTCornerRadiiFromBorderRadii(borderMetrics.borderRadii)];
    if (maskLayer != nil) {
      self.currentContainerView.layer.mask = maskLayer;
    }
  }
  // ...
}

Modifications to invalidateLayer function‍

‍Where createClipPathLayer is a helper function responsible for calculating the geometry-box and creating the shape path. Let’s take a look at its breakdown.

+ (CALayer *)createClipPathLayer:(const ClipPath &)clipPath
                   layoutMetrics:(const LayoutMetrics &)layoutMetrics
                       yogaStyle:(const facebook::yoga::Style &)yogaStyle
                          bounds:(CGRect)bounds
                     cornerRadii:(RCTCornerRadii)cornerRadii
{
  CGRect box = [self getGeometryBoxRect:clipPath.geometryBox
		                      layoutMetrics:layoutMetrics
			                        yogaStyle:yogaStyle
		                             bounds:bounds];

‍1. Firstly, we need to determine the correct geometry-box bounds. We need to use the proper reference box and adjust its corner radii.

  UIBezierPath *path = nil;
  if (clipPath.shape.has_value()) {
	  // clipPath: circle(50%) content-box
    path = [RCTBasicShapeUtils createPathFromBasicShape:clipPath.shape.value() bounds:box];
  } else if (clipPath.geometryBox.has_value()) {
	  // clipPath: content-box
    path = [UIBezierPath bezierPathWithCGPath:RCTPathCreateWithRoundedRect(box, cornerInsets, nil, NO)];
  }

2. UIKit comes with a helper UIBezierPath class for path operations. For each supported shape, we need to create a path describing it using our new helper createPathFromBasicShape function. If a shape is defined, we create a corresponding path. Otherwise, we use geometry-box path, if one is provided.

  // Skip invalid or empty path
  if (path == nil) {
    return nil;
  }
  
  CAShapeLayer *maskLayer = [CAShapeLayer layer];
  maskLayer.path = path.CGPath;
  return maskLayer;
}

3. If outcome path is correct, mask layer can be created from it using CAShapeLayer class. This mask layer is used as a mask of UIView in invalidateLayer function.

What does createPathFromBasicShape look like? Below you can find an example for the rect shape.

+ (UIBezierPath *)createRectPath:(const RectShape &)rect bounds:(CGRect)bounds
{
  // Define corners
  CGFloat top = bounds.origin.y + RCTResolveValueUnit(rect.top, bounds.size.height);
  CGFloat right = bounds.origin.x + RCTResolveValueUnit(rect.right, bounds.size.width);
  CGFloat bottom = bounds.origin.y + RCTResolveValueUnit(rect.bottom, bounds.size.height);
  CGFloat left = bounds.origin.x + RCTResolveValueUnit(rect.left, bounds.size.width);

	// Create a rect
  CGRect clipRect = CGRectMake(left, top, right - left, bottom - top);

	// Catch for invalid dimensions
  if (clipRect.size.width < 0 || clipRect.size.height < 0) {
    return nil;
  }

  // Adjust radius
  CGFloat borderRadius = rect.borderRadius.has_value() ? RCTResolveValueUnit(rect.borderRadius.value(), MIN(clipRect.size.width, clipRect.size.height)) : 0.0f;
  
  // Use bezierPathWithRoundedRect to create rounder rect bezier path
  return [UIBezierPath bezierPathWithRoundedRect:clipRect cornerRadius:borderRadius];
}

Creation of rect path on iOS

Combining all together, we got working clip-path for iOS in React Native!

Of course, I skipped some implementation details. These include detecting attribute changes, marking layers as requiring invalidation, parsing attribute text values natively, converting CSS types into graphics types, and a few other things. This should still give you the overall gist of how the process works. If you’re curious, you can find links to all the changes here.

Clipping on Android

Android design is a bit different, which is not a surprise.

Every React Native component for Android is represented as a View class. While alternatives like ViewOutlineProvider exist for simple rounded corners, they are often too limited for complex shapes.

The most popular and suitable solution for our case for clipping of Android's View is to override draw method and apply clipping directly to the canvas. Android Canvas class provides dedicated functions for this functionality, which are clipPath and clipRect - that's exactly what we need!

What is different for Android from iOS is that there is no base View class that is used for all types of components (there are some exceptions which break the general solution). However, there is a special React Native helper class BackgroundStyleApplicator, that is used and available for every View.

We can add our clip-path logic there, and call it from all other places. For ReactImageView class, that represents <Image> component, code would look like this:

public override fun draw(canvas: Canvas) {
  BackgroundStyleApplicator.applyClipPathIfPresent(this, canvas) {
    super.draw(canvas)
  }
}

Overriding draw function for clipping purposes

Where applyClipPathIfPresent is a function that can be presented in simplified version as:

public fun applyClipPathIfPresent(view: View, canvas: Canvas, drawContent: (() -> Unit?)?) {
    // ...
    canvas.withSave {
			// ...
			// Calculation of geometry-box
      val bounds = getGeometryBoxBounds(...)
      
      // Path creation
      val path: Path? = if (clipPath.shape != null) {
    	  // clipPath: circle(50%) content-box
        ClipPathUtils.createPathFromBasicShape(clipPath.shape, bounds)
      } else if (clipPath.geometryBox != null) {
        // clipPath: content-box
	      ClipPathUtils.createRoundedRectPath(bounds,
		      GeometryBoxUtil.adjustBorderRadiusForGeometryBox(view, clipPath.geometryBox, ...)
	      )
      } else {
        null
      }

      // Clipping logic
      if (path != null) {
        clipPath(path)
      }

		  // Draw actual content to be clipped
      drawContent?.invoke()
    }
  }

Clipping Android View class

The logic is very similar to iOS:

  1. First, we calculate geometry-box using a new helper function getGeometryBoxBounds.
  2. Then we create a path describing the shape passed to clip-path.
  3. And call the relevant Canvas method to clip the drawing region.

Below you can find how to create a rect shape for Android:

 private fun createRectPath(rect: RectShape, bounds: RectF): Path? {
    // Define corners
    val top = bounds.top + resolveLengthPercentage(rect.top, bounds.height())
    val right = bounds.left + resolveLengthPercentage(rect.right, bounds.width())
    val bottom = bounds.top + resolveLengthPercentage(rect.bottom, bounds.height())
    val left = bounds.left + resolveLengthPercentage(rect.left, bounds.width())

		// ...
		// Create path
    val path = Path()
    if (rect.borderRadius != null) {
      // Rounded corners case
      val referenceDimension = minOf(rect.width(), rect.height())
      val radius = resolveLengthPercentage(borderRadius, referenceDimension)
      path.addRoundRect(rect, radius, radius, Path.Direction.CW)
    } else {
      // Sharp edges case
      path.addRect(rect, Path.Direction.CW)
    }

    return path
  }

Creation of rect path on Android

Everything is wrapped with withSave block. It is done this way so that our modifications to canvas do not affect other drawing calls, as it could lead to unexpected behaviors.

What about clipping View background, children or its effects? We need to quickly examine the Android graphics pipeline and check what draw(Canvas) method actually does. It can be easily checked by looking at View class source code:‍‍

// Step 1, draw the background, if needed
drawBackground(canvas)
// Step 2, save the canvas' layers
// ...
// Step 3, draw the content
onDraw(canvas)
// Step 4, draw the children
dispatchDraw(canvas)
// Step 5, draw the fade effect and restore layers
// ...
// Step 6, draw decorations (foreground, scrollbars)
onDrawForeground(canvas)
// Step 7, draw the default focus highlight
drawDefaultFocusHighlight(canvas)

Android graphics pipeline order

Applying clipping to the canvas before super.draw(canvas) is called ensures that every drawable part of component will be clipped by our code. We just need to add it to all View classes, and our job is done! That was easy, right?

As you can guess, I oversimplified the process again. Initially, it also required a bit more work. We needed to perform type conversions, add adjustments to support the new attribute in several other places, and the most interesting one: compute and cache layout information during Mounting process for proper geometry-box calculation.

However that could be a topic for a separate article and I hope this description provided you with a base idea.

Wrapping Up

Like many other mobile developers, I enjoy building apps in React Native. I can reuse my web skills and apply them to build apps for iOS, Android, and other platforms.

However it’s not without hiccups. Some things that are easy with CSS on the web, require me to use a third-party library that encapsulates native views. A new thing to learn.

By bringing more CSS support to React Native I hope we’ll get closer to the world where we can use all of our web styling knowledge in our mobile apps.

I hope you liked our small exploration of how CSS and the New Architecture work under the hood in React Native, and learned something new.

Three PRs that bring clip-path support to React Native are currently under review. When they are merged and released, I hope you’ll build something cool with it!

Insights

Learn more about React Native

cover

Aug 4Podcast

VisionCamera V5 With Marc Rousavy

Marc Rousavy joins Mike Grabowski to unpack VisionCamera v5, Nitro Modules, the constraints API, migration work, and what changed for React Native camera apps.

cover

Jul 1Podcast

Redraw for Effects Beyond Skia

William Candillon talks about where Redraw fits next to Skia, from physics-based rendering to camera effects, AR, and new UI patterns.

cover

Jun 29Article

Less Boilerplate, More Type Safety: React Navigation 8's Typed Hooks

React Navigation 8 improves typed hooks with screen-aware types, less boilerplate, and fewer unsafe casts in shared UI and nested navigators.

cover

Apr 30Live Stream

Build a React Native App With the TanStack Stack

Join Callstack’s live stream on building a React Native app from the ground up using the TanStack toolbelt.

cover

Apr 28Article

React Native production lessons from React Universe Meetup x Zalando

Lessons from React Universe Meetup x Zalando on React Native in production, covering brownfield performance, feed video, native API access, and on-device AI.

cover

Apr 23Talk

Shipping iOS Live Activities with React: The Voltra Story

How Voltra enables Live Activities and Dynamic Island views using React without writing Swift. A deep dive into iOS constraints and architecture.

cover

Apr 16Live Stream

React Native 0.85: Animation Backend and Key Updates

A recap of React Native 0.85, covering the new animation backend, DevTools improvements, and ecosystem takeaways.

cover

Mar 5Article

Keeping Screens Visible With <Activity mode="hidden">

A cross‑platform workaround to keep screens visible while <Activity mode="hidden"> unmounts effects on inactive screens by setting mode="hidden".

cover

Feb 25Article

React Native Monorepo With pnpm Workspaces

Set up a React Native monorepo with pnpm workspaces for mobile and Next.js web, including Metro fixes, hoisted node-linker, and shared packages.

cover

Feb 18Article

A Practical Guide to React Native Monorepo With Yarn Workspaces

Learn how to set up a React Native monorepo with Yarn workspaces for mobile and web, including Metro, Gradle, and shared packages.

cover

Feb 17Podcast

From Teddy Bears to Voice Agents: Kraen Hansen on Voice AI, Local-First & App Security

What happens when you combine voice agents and teddy bears? A hands-on look at local-first systems, voice AI, and security lessons from poking real apps.

cover

Jan 13Article

Live Activities and Widgets with React: Say Hello to Voltra

Voltra lets you build iOS Live Activities and widgets for React Native using JSX instead of Swift.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.