<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Software Mansion - Medium]]></title>
        <description><![CDATA[We are a developer experience lab &amp; software agency from Kraków, contributors to React Native and Membrane Framework. - Medium]]></description>
        <link>https://medium.com/swmansion?source=rss----e003bf129483---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Software Mansion - Medium</title>
            <link>https://medium.com/swmansion?source=rss----e003bf129483---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 08:47:42 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/swmansion" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Worklets 0.8: The Shareable — Shared Memory’s Missing Piece]]></title>
            <link>https://medium.com/swmansion/worklets-0-8-the-shareable-shared-memorys-missing-piece-d8880d4f2e7b?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/d8880d4f2e7b</guid>
            <category><![CDATA[multithreading]]></category>
            <category><![CDATA[react-native]]></category>
            <category><![CDATA[react-native-worklets]]></category>
            <dc:creator><![CDATA[Tomasz J. Żelawski]]></dc:creator>
            <pubDate>Thu, 19 Mar 2026 16:03:18 GMT</pubDate>
            <atom:updated>2026-03-19T16:03:17.189Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u-8goh4xWOB8mVzdeTlcjA.png" /></figure><p><strong>TL;DR:</strong> Worklets 0.8.0 introduces <a href="https://docs.swmansion.com/react-native-worklets/docs/memory/shareable">the Shareable</a> — a runtime-bound shared memory primitive that powers Reanimated’s Shared Values under the hood. Now it’s available as a lean standalone building block for your own use cases.</p><h3>Sharing JavaScript memory in React Native</h3><p>Sharing memory between JavaScript Runtimes in React Native is difficult. Not only do we have to overcome the limitations of the single-threaded paradigm of JavaScript, but also cover the API needs of typical React Native apps.</p><p>Today we present to you <strong>the Shareable, the last missing shared memory data type in React Native Worklets</strong>. But before we dive into the Shareable itself, let’s take a look at what exactly the other data types are.</p><h3>3 types of shared memory</h3><p>Since every JavaScript Runtime has its own memory space, which by design can’t be accessed from another Runtime’s memory space, we must add intermediate steps, with C++ and JSI — JavaScript Native Interface — that will create a bridge connecting these memory spaces. That way we can create an illusion that even though the memory spaces are totally disjoint, there’s some kind of link between them</p><p>We can distinguish three different types of shared memory, based on their use case:</p><h4>Copy-only memory</h4><p>The simplest of them all, a way to move any value from Runtime A to Runtime B as-is: a number, an object, a string, basically everything that makes sense. However, changes to such a value on any Runtime aren’t reflected in the other Runtimes. <strong>Each Runtime is unaware that it has a copy and simply treats its value as the original</strong>. This is the<strong> </strong><a href="https://docs.swmansion.com/react-native-worklets/docs/memory/serializable">Serializable</a>. <em>Depending on the definition of shared memory, this type might not even be considered one.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DloG-NwpScus7DMoH2NQbA.png" /><figcaption>Serializable copy-only memory model</figcaption></figure><h4>Unbound memory</h4><p>The first type of memory that is actually shared — meaning that changes to it on Runtime A will be seen by Runtime B and all the other Runtimes. This is achieved by keeping the actual “real” value outside of any Runtime, in C++, guarded by mutexes and other thread-safe mechanisms. Each JavaScript Runtime holds just a specific kind of reference to the C++ value. <strong>Each read or write means crossing the boundary between JavaScript and C++</strong>. Because the memory is thread-safe, each Runtime can safely access it at its own discretion. This is the <a href="https://docs.swmansion.com/react-native-worklets/docs/memory/synchronizable">Synchronizable</a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*50jfAwloDXgCx-4X_eCcog.png" /><figcaption>Synchronizable unbound memory model</figcaption></figure><h4>Runtime-bound memory</h4><p>Type of asymmetric shared memory that overcomes some issues of unbound memory — operations on unbound memory are much more expensive than ones on plain JavaScript values — due to the need to cross the boundary between JavaScript and C++ with JSI on each access. <strong>Runtime-bound memory optimizes access time on a single designated JavaScript Runtime</strong>, called the Host Runtime, by simply holding the value there as a plain JavaScript value. Other Runtimes only have a specific kind of reference that “knows” that the actual value lives on another Runtime — if you want to access it with that reference, you must go to C++, obtain the Host Runtime exclusively, read the actual value, and return it all the way back to the accessor Runtime. This is the <a href="https://docs.swmansion.com/react-native-worklets/docs/memory/shareable">Shareable</a>, the spotlight of today’s blogpost.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2kuGf2kYybR95zAOAR0o2Q.png" /><figcaption>Shareable runtime-bound memory model</figcaption></figure><h3>Why Shared Value wasn’t enough</h3><p>The Shareable is by far the most complicated type of memory available in Worklets. You might wonder what purpose such a complicated concept could possibly serve. It turns out it has been there, in React Native, for years — this is exactly how <a href="https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/">Reanimated’s Shared Value</a> (also called the Mutable) works. <strong>A Shared Value is a runtime-bound type of shared memory, specifically tailored for reactivity and driving animations.</strong> It lives on the <a href="https://docs.swmansion.com/react-native-worklets/docs/fundamentals/runtimeKinds/#ui-runtime">UI Runtime</a> where it’s updated and read sometimes even hundreds of times per frame in apps with a lot of animations. When you assign a new animation to it in your component, you actually schedule a call to the UI Runtime with your specific reference, where you update the value — and your animation is also copied to the UI Runtime, as a Serializable.</p><p>We wanted to go a step further with the Shared Value, expanding the possibilities of what you can do with shared memory in your apps. <strong>However, Shared Value’s close relation to animations and the UI Runtime proved to be a serious obstacle when trying to use it in a non-animation use case</strong>. This is why we had to take a step back, separate the shared memory concept from the animation logic, and expose a building block which Reanimated can take and turn into a Shared Value by adding animations to it. The same building block that you can take and do whatever you want with. This building block, the shared memory “primitive”, is the Shareable.</p><p><strong>With React Native Worklets 0.8.0, you can freely use the Shareable in your apps, simplify some of your Shared Value use cases, and even create your own custom shared memory types by building on top of the Shareable. It’s all in your hands now, so go ahead and experiment with it!</strong></p><p>We’re <a href="https://swmansion.com/">Software Mansion</a>: multimedia experts, AI explorers, React Native core contributors, community builders, and software development consultants.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d8880d4f2e7b" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/worklets-0-8-the-shareable-shared-memorys-missing-piece-d8880d4f2e7b">Worklets 0.8: The Shareable — Shared Memory’s Missing Piece</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Three-Lang Problem: shipping Elixir runtime, JS, and WebAssembly as one npm package]]></title>
            <link>https://medium.com/swmansion/the-three-lang-problem-shipping-elixir-runtime-js-and-webassembly-as-one-npm-package-5a7b76ad5b90?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/5a7b76ad5b90</guid>
            <category><![CDATA[webassembly]]></category>
            <category><![CDATA[npm]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[elixir]]></category>
            <dc:creator><![CDATA[Kuba Gonet]]></dc:creator>
            <pubDate>Thu, 12 Mar 2026 15:03:52 GMT</pubDate>
            <atom:updated>2026-03-12T15:03:51.640Z</atom:updated>
            <content:encoded><![CDATA[<h3>The Three-Lang Problem: Shipping Elixir Runtime, JS, and WebAssembly as One npm Package</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V2WbxYhsDqA5hIAdcM2kfg.png" /></figure><p><a href="https://popcorn.swmansion.com/">Popcorn</a> is a library we develop that lets you run Elixir in the browser (zip-ties and glue included!). As you might guess, it has a bit more convoluted setup than a typical Elixir or JS library.</p><p>For a long while, using Popcorn meant copying two JS files from our template into your project as well as installing the popcorn Hex package for Elixir. The only advantage of copying was simplicity of development (for us). <br>A lot of projects use Phoenix which already includes a way to install a JS package. Other projects, like our Elixir language tour are React apps. Integrating via npm install is way easier than manually tracking JS files.</p><p>And frankly, it’s weird we had an official Elixir package and hacks for JS parts.</p><p>We decided to <em>just</em> extract those files into an npm library and make setup simpler for users. It didn’t come easy. You may even say it came with unrelenting perversity of inanimate objects.</p><h3>The Architecture™</h3><p>The main problem is that our architecture choices force us to place JS and Wasm files in carefully chosen places, with carefully chosen filenames. It mainly comes from our use of Emscripten.</p><p>A few words on the tech stack — we use AtomVM, which is a BEAM runtime written in C.<br>It compiles to Wasm via Emscripten, generating a <em>.wasm</em> file and <em>.mjs</em> JS glue code. We take them and throw them into an iframe (for <a href="https://x.com/JakubGonet/status/2028875935106109520">isolation</a>). Then it spawns the WebWorkers it needs to emulate threads.<br>End-user code communicates with it by exchanging messages back and forth with the iframe.</p><p>Here’s how it looks:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A0x72oHEDkMI3s9lTACPGw.png" /></figure><p>We have one file with the main script that has access to the DOM, another file in an iframe that loads and isolates AtomVM (and has access to the DOM via parent context), and many WebWorkers that load AtomVM directly (but incidentally <strong>use the same file</strong> that is loaded in the iframe).</p><p>It works beautifully until you start poking it:<br>- the iframe has a different view of the base URL than the main JS context.<br>- bundlers don’t have very good support for Wasm.<br>- WebWorkers with shared memory need CORS/COEP headers set by the server.<br>- Emscripten by default is configured to use one instance of the Wasm module and isn’t afraid of global variables.<br>- it also generates the JS in a way that hardcodes the JS glue filename.</p><p>Given all that, we have two requirements for the library:<br>- we want <em>iframe.js</em> to be a separate entrypoint that resolves <em>AtomVM.mjs</em> correctly.<br>- we want <em>AtomVM.mjs</em> to find its Wasm file (both in iframe and WebWorker contexts).</p><h3>Stubbing Your Toe on Imports</h3><p>I mentioned bundlers. For non-frontend developers, these are tools that take your Javascript project and output a single file containing all the JS code. Two popular choices are Esbuild and Rollup. To my complete astonishment, they don’t really care about Wasm and treat it as any other (binary) asset.<br>This is great, since you can have the unique experience of reviewing how different bundlers handle totally nonstandard ways of importing and using Wasm files.</p><p>I wanted to mark generated files as external modules (which means that they aren’t included in the main JS bundle (which means that they keep their filenames)).<br>This made sense to me at the time but had unfortunate implications: <em>iframe.ts</em> entrypoint which imported the external AtomVM module wasn’t resolving the import correctly because entrypoints are controlled by end-user’s code and we need them to be specifically next to <em>AtomVM.mjs</em> script. This would impact how users structure their projects.</p><p>The logical way of fixing that is to either change <em>AtomVM.mjs</em> to be bundler-friendly (removing the need to handle this file specially) or provide a plugin for users that configures it correctly. I really wanted to avoid the second one since it meant more setup for end-users and possibly more things that can break when you configure your bundler in a non-standard way.</p><p>Here are two fun excerpts from Emscripten-generated JS code.</p><h4>Exhibit One:</h4><pre>function findWasmBinary() {<br>  if (Module[&quot;locateFile&quot;]) {<br>    return locateFile(&quot;AtomVM.wasm&quot;);<br>  }<br>  // Use bundler-friendly `new URL(..., import.meta.url)` pattern; works in browsers too.<br>  return new URL(&quot;AtomVM.wasm&quot;, import.meta.url).href;<br>}</pre><h4>Exhibit Two:</h4><pre>worker = new Worker(new URL(&quot;AtomVM.mjs&quot;, import.meta.url), {...});</pre><p><em>Long story short</em>, Emscripten really wants you to keep the generated filenames unchanged and place them in the same directory.<br>Bundlers, on the other hand, really want to keep everything in the same bundle (and change the filenames to include content hash).<br>This was the second signal to try to improve <em>AtomVM.mjs</em> code to make it bundle-able.</p><blockquote><strong>Small note: WebWorkers</strong></blockquote><blockquote>There are two main ways to use your code in WebWorkers. You can either provide a link to a script, similar to how HTML imports scripts or you can pass the JS string with script on creation (this approach is deprecated to my knowledge). I want to emphasize that <strong>both iframe and WebWorkers create new JS contexts</strong> — you don’t have access to variables or loaded scripts across them. If you need anything (a function, a variable, an entire library), it needs to be included in the JS code passed to the WebWorker.</blockquote><p>Emscripten has an option to remove the need for a separate <em>.wasm</em> file by including the binary blob in the script, in the form of a base64 encoded string (with a <em>mere</em> 33% size increase due to encoding).<br>WebWorkers need a separate script to load and bundlers have an option for that. This wouldn’t be a problem if we didn’t have the same script that is loaded in both the main browser context and WebWorkers, behaving differently in each.</p><p>We can remove the hardcoded <em>.wasm</em> name by using base64.<br>Now we need to remove the import for the script itself when inside WebWorker context. Well, what if we just pack <strong>the entire script into base64 string</strong> (which <strong><em>includes</em></strong> base64-packed VM code)?</p><p>Some thoughts shouldn’t be thought.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0f0eGP2NCTeqbsIOwi6qjg.png" /><figcaption>Such a great idea, let’s write that one down.</figcaption></figure><h3>Plugins</h3><p>At this point, I was three weeks into research, mostly reading generated code, and the Emscripten and bundler docs. Trying to eliminate Wasm files or working around Emscripten looked like a dead-end.<br>I came back to the original idea of wrapping the the necessary logic into a plugin that will integrate with the user’s project.</p><p>This has numerous advantages compared to the previous attempt such as: not losing your sanity.</p><p>I also realized after many plugin drafts that we don’t need any sophisticated bundler features, it’s enough to put our files in the output directory and make sure bundlers don’t touch them.<br>In other words, we came back to <em>cp template.js actual-file.js</em> but with one additional (and useful!) abstraction. This allows us to have extremely simple plugins for both and to integrate with Vite, too (which uses both under the hood).</p><p>Full workflow: we develop Popcorn locally with TypeScript and other tools, we then ship both our custom code (API and plugins) and AtomVM compiled via Emscripten, and with some strategic file placement and hooking into the user’s build pipeline we force all of that to work.</p><p>This solves both of our previously stated constraints:</p><blockquote>We want iframe.js to be a separate entrypoint that resolves AtomVM.mjs correctly.</blockquote><p>Popcorn ships source files, which we then copy. <em>iframe.js</em> is a sibling file of <em>AtomVM.mjs</em>, no special handling needed to resolve imports.</p><blockquote>We want AtomVM.mjs to find its Wasm file (both in iframe and WebWorker contexts).</blockquote><p>Same as above. We also don’t modify the filenames so the generated Emscripten code always refers to files that exist (and not, for example, have a content hash appended to its name).</p><p>We’ve been using this approach in the Elixir language tour for a while now, and it works nicely — <a href="https://github.com/software-mansion/popcorn/pull/489">PR</a> replacing the old implementation was satisfyingly red.</p><p>Best part of the diff:</p><pre>export default defineConfig({<br>  base: &quot;/&quot;,<br>  plugins: [<br>    react(),<br> // ...<br>    svgr(),<br>+   popcorn({ bundlePath: &quot;./public/wasm/bundle.avm&quot; }),<br>    cookOnChange()<br>  ],</pre><p>It simplifies the developer experience a lot, with both JS-first projects and Elixir-first projects.</p><h3>Packing It All Up</h3><p>It’s available to use now. You’ll need the <a href="http://twitter.com/swmansion/popcorn"><em>@swmansion/popcorn</em></a> npm package and <em>popcorn</em> Hex package. We have <a href="https://github.com/software-mansion/popcorn/tree/main/examples">examples</a> to show how it works (especially <em>hello-popcorn</em> with an Elixir-first approach and <em>hello-react</em> with a JS-first approach).</p><p>Since the last release (which was ages ago), we’ve also worked a lot on improving AtomVM and our use of it. You can find a changelog in <a href="https://github.com/software-mansion/popcorn/releases">Github releases</a>.</p><p>We also saw some really cool demos: Steffen’s <a href="https://popcorn-lv.steffend.me/">unmodified LiveView working in the browser</a> and Peter’s <a href="https://petermm.github.io/popcorn_live_view/req-demo">collection of small demos</a> (building on Steffen’s site, I especially like the sqlite one).</p><p>We’re also cooking something in the shadows — <a href="https://youtu.be/CTAud5O7Qqk">float on</a> until that time. It may be slightly interesting.</p><p>Take care,<br>Kuba!</p><p>We are Software Mansion — Elixir experts, AI explorers, React Native core contributors, community builders, and software development consultants. Need help with building your next product? You can <a href="https://swmansion.com/contact?utm_source=medium&amp;utm_medium=display">hire us</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5a7b76ad5b90" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/the-three-lang-problem-shipping-elixir-runtime-js-and-webassembly-as-one-npm-package-5a7b76ad5b90">The Three-Lang Problem: shipping Elixir runtime, JS, and WebAssembly as one npm package</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Breaking Down the Jelly Slider]]></title>
            <link>https://medium.com/swmansion/breaking-down-the-jelly-slider-9ab9239f6d80?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/9ab9239f6d80</guid>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[shaders]]></category>
            <category><![CDATA[graphics-programming]]></category>
            <category><![CDATA[webgpu]]></category>
            <category><![CDATA[typescript]]></category>
            <dc:creator><![CDATA[Konrad Reczko]]></dc:creator>
            <pubDate>Tue, 10 Mar 2026 14:58:34 GMT</pubDate>
            <atom:updated>2026-03-11T09:38:34.524Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lj4236So6ICUIhT-pp-q1g.png" /></figure><p>After the <a href="https://docs.swmansion.com/TypeGPU/examples/#example=rendering--jelly-slider"><strong>Jelly Slider</strong></a> implementation gained <a href="https://x.com/reczko_konrad/status/1985296691331809574?s=20">a lot of attention</a>, I decided to write this post to explain how it works in detail and show you how to recreate it yourself, or even build your own visualization inspired by it.</p><p>We’ll explore how the simulation functions and how it’s rendered to the screen using only <strong>TypeScript</strong> with the help of <a href="https://docs.swmansion.com/TypeGPU/"><strong>TypeGPU</strong></a>. I’ll walk through the reasoning behind my decisions and share some insights into the trade-offs made along the way. Of course, there’s always room for further improvements and optimizations — it all comes down to the balance between time and effort.</p><blockquote><strong>Disclaimer</strong>:<br>This is <strong>not</strong> a step by step implementation tutorial. I will focus primarily on explaining the <strong>methodology</strong> and will occasionally include snippets from the original code, which you can find in its entirety on the <a href="https://docs.swmansion.com/TypeGPU/examples/#example=rendering--jelly-slider">TypeGPU examples page</a> or <a href="https://github.com/software-mansion/TypeGPU/tree/main/apps/typegpu-docs/src/examples/rendering/jelly-slider">directly in the GitHub repository</a>. Some snippets may be simplified or modified to avoid overwhelming the reader with code. You are free to reference the original implementation at any time, as the function names are kept intact.</blockquote><h3>The Simulation</h3><p>The actual physical simulation happens in two-dimensional space, which greatly simplifies the amount of work we have to do. The implementation supports an arbitrary number of points but is empirically tuned to work with <strong>17</strong>, which provides a good balance between performance and visual complexity.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fxzmTb-DNLsioF2r3qMOVg.png" /></figure><p>The overall structure of the simulation is quite well represented by the code itself:</p><pre>update(dt: number) {<br>  for (let s = 0; s &lt; this.substeps; s++) {<br>    this.#integrate(h, damp, compression);<br>    this.#projectConstraints();<br>  }<br><br>  this.#computeNormals();<br>  this.#computeControlPoints();<br>  this.#updateGPUBuffer();<br>  this.#computeBezierPipeline.dispatchThreads(...BEZIER_TEXTURE_SIZE);<br>}</pre><p>Where:</p><ul><li>h is the duration of a sub-step</li><li>damp is a constant that represents additional resistance to movement</li><li>compression is a normalized value that represents how far apart the ends are</li></ul><p>Let’s go over the process step by step.</p><h4>Integration</h4><p>The main method used for calculating point positions is <a href="https://www.algorithm-archive.org/contents/verlet_integration/verlet_integration.html"><strong>Verlet integration</strong></a>, which — despite its intimidating name — is actually very simple. We can think of it this way: if a point was previously at position <strong>A</strong> and is now at position <strong>B</strong>, its velocity corresponds to the vector from <strong>A</strong> to <strong>B</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bojmc-vBEbvBggiE8QKrWg.png" /></figure><p>Verlet integration updates each point’s position using its current and previous states, without explicitly storing velocity. The next position is estimated from the previous two, with some adjustments applied over time. In this case, there’s no gravity — instead, the middle section of the slider is slightly lifted to fit a <strong>sine-like curve</strong> when compressed.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aYuq561X4PkPpuPiAJKGlQ.png" /><figcaption>The force increases with slider compression</figcaption></figure><h4>Constraint projection</h4><p>As mentioned earlier, the integration relies on the current position, previous position, and small adjustments over time. Apart from applying a varying arching force, we also need to enforce certain <strong>constraints</strong>. If we only applied the force, the points would drift away, and the result wouldn’t look stable.</p><p>To keep the simulation in shape, after each iteration we <strong>project constraints</strong>. In our case, those constraints are:</p><ul><li>the first point should stay fixed at its initial position</li><li>the last point (handle) should stay fixed where the cursor is</li><li>the distance between neighboring points should remain constant</li><li>points near the ends should resist bending</li></ul><p>They are enforced in two ways. The first two are <strong>hard constraints</strong> — we simply set the positions directly. The last two use <strong>distance projection</strong>. For the distance constraint, we measure the actual distance between two points and move each halfway toward their ideal separation. After several iterations, all segments are nearly at their correct lengths.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Vgg_SKmtl9MHQOa8-fg1uw.png" /><figcaption>After the initial disturbance the system will eventually settle</figcaption></figure><p>For bending resistance, the principle is similar, but instead of connecting two actual points equally, we compare a point to where a corresponding position along the y-axis <em>would</em> be, applying a smaller correction. In practice, this means we pull the endpoints back toward a straight line, but only partially.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FnocTwmGC4FoUgIq7ruQZw.png" /></figure><p>These operations are enough for us to know where the slider is, but we still need to compute some additional data useful for rendering — specifically, the <strong>normals</strong> and <strong>control points</strong>.</p><h4>Computing Normals</h4><p>You can think of <strong>normals</strong> as the relative “up” directions at each point. They’re useful in many contexts — most commonly for calculating how light reflects off a surface during rendering. In our case, we won’t use them directly for the simulation (for reasons we’ll cover later), but they’re still important when we approximate the curvature of the slider.</p><p>Calculating normals using three points is straightforward: we take the line defined by connecting the previous and next points and then compute a vector that’s perpendicular to it. For the edge cases — the first and last points — we can simply assume the normal points straight up.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QT-43RJSbU_mvLqCixCgKw.png" /></figure><h4>Computing Control Points</h4><p>The last piece of information we need before rendering relates to how we want to <strong>represent</strong> the slider. There are several options, but the approach I chose is to model it as a series of <strong>quadratic Bézier segments</strong>. This choice works well for two main reasons:</p><ol><li>A quadratic Bézier curve is defined by just three points.</li><li>They can be easily represented as <strong>SDFs</strong> (signed distance fields — we’ll cover what those are in the rendering section).</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ipa7UY2dYmzgD5W0Qv4Mrw.png" /><figcaption>A single quadratic bezier segment</figcaption></figure><p>We already have a list of points for the slider, but we still need a third point for each segment to form the curves. This is where <strong>normals</strong> help. By using them, we can derive <strong>tangent lines</strong>, and the intersection of the tangents between two consecutive points gives us the <strong>control point</strong> for that segment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GS7VCfJgOqnJz6SNx2jU6g.png" /></figure><p>There’s one edge case we need to handle for this method to work reliably. When the tangents are <strong>parallel</strong> or <em>nearly</em> <strong>parallel</strong>, the control point either doesn’t exist (the lines never intersect) or ends up far from where it should be. Fortunately, this is easy to fix — in those cases, we simply place the control point at the midpoint between the two points.</p><h4>Updating the GPU buffers</h4><p>Now that we have all the necessary information, the final step is to prepare the data for use inside the shaders. The key idea here is that, for the data to be accessible on the GPU, we need to upload it into a <strong>buffer</strong>.</p><p><strong>TypeGPU</strong> simplifies this process by providing <strong>schemas</strong>, so we don’t have to manually compute <em>buffer sizes </em>or <em>memory layouts</em>. Thanks to that, the entire GPU resource management for the slider’s data comes down to just a few straightforward steps:</p><pre>class Slider {<br>  // Type declarations<br>  pointsReadonly: TgpuReadonly&lt;d.WgslArray&lt;d.Vec2f&gt;&gt;;<br>  controlPointsReadonly: TgpuReadonly&lt;d.WgslArray&lt;d.Vec2f&gt;&gt;;<br>  #normals: d.v2f[];<br>  #controlPoints: d.v2f[];<br><br>  constructor(...) {<br>    // Buffer creation<br>    this.#root.createReadonly(<br>      // Create a schema for n 2-component vectors<br>      d.arrayOf(d.vec2f, this.n),<br>      // The initial data<br>      this.#pos,<br>    );<br>    this.#root.createReadonly(<br>      d.arrayOf(d.vec2f, this.n - 1),<br>      this.#controlPoints,<br>    );<br>  }<br><br>  #updateGPUBuffer() {<br>    // Updating the buffers<br>    this.pointsReadonly.write(this.#pos);<br>    this.controlPointsReadonly.write(this.#controlPoints);<br>  }<br>}</pre><p>After this step, it’s really up to us how we interpret this data in the rendering, so we can basically treat the entire engine as a <strong>black box</strong>.</p><h3>The Rendering</h3><p>To recap the simulation part, let’s quickly review the data we have and outline how we’ll use it.</p><p>We have:</p><ul><li>an ordered list of <strong>2D points</strong> that define the slider’s path,</li><li>an ordered list of c<strong>ontrol points</strong> computed from those points.</li></ul><p>Looking at the reference render, the slider clearly doesn’t look strictly 2D — yet it only ever deforms along <em>two axes</em>. This means we can render it as a 2D line and simply extrude it along the third axis, placing the slider in 3D space even though the simulation itself is purely 2D.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Aojpc3FvjvOa6FvgEWn4_Q.png" /><figcaption><a href="https://x.com/cerpow/status/1964953851603358112?s=20">The original render by Voicu Apostol</a></figcaption></figure><p>We could build this with a <a href="https://en.wikipedia.org/wiki/Polygon_mesh">mesh</a> and deform it, and it would probably work — but looking at the reference, it’s clear we’ll also need advanced lighting, soft shadows, refraction, and some level of <a href="https://en.wikipedia.org/wiki/Global_illumination">GI (global illumination)</a> or <a href="https://en.wikipedia.org/wiki/Ambient_occlusion">AO (ambient occlusion)</a>. It’s all doable, but for a one‑off example the code would get complex quickly; we’d essentially be building a mini 3D engine from scratch. <strong><em>Fun</em></strong>, but not practical here.</p><p><a href="https://en.wikipedia.org/wiki/Ray_marching">Ray marching</a> ends up being a much better fit:</p><ul><li>The slider needs to be perfectly smooth, which would be hard with a mesh unless we simulate a large number of points. With an SDF for the Bézier curve, we get a smooth surface <em>“for free.”</em></li><li>AO (ambient occlusion) is trivial with ray marching — because of SDFs, a single ray marched a few short steps is enough for good results.</li><li>The background is easy to express as SDFs and extremely cheap to compute.</li></ul><h4>How does ray marching work?</h4><p>There are many great resources that explain what ray marching is and how it works, so I will not go into much detail here. If you want an excellent reference, I recommend <a href="https://iquilezles.org/articles/">the articles by Inigo Quilez</a>. For now, all we need to know is:</p><ul><li>we set a camera position (the camera will stay stationary, which is very convenient for us)</li><li>we define a perspective projection that determines the direction of our rays</li><li>for each pixel on the screen, we send a ray according to that direction — that ray determines what color we assign to the pixel</li></ul><p>The ray checks its <strong>distance</strong> to the scene (the nearest object) and keeps moving forward until that distance is close enough to zero. At that point, it calculates the lighting for that position in space.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PNF0wLp-qW1xbSJWPYBbNg.png" /><figcaption>A simplified sketch of the ray‑marching process</figcaption></figure><h4>The background</h4><p>For the ray to know how far it is from each element in the scene, we need to describe everything using <strong>signed distance fields</strong>. We’ll start by building the background. First, we’ll add a simple plane. We’ll define a function using <a href="https://docs.swmansion.com/TypeGPU/"><strong>TypeGPU</strong></a>, and by adding the `use gpu` directive at the top, the function becomes available inside a shader. We’ll also use the <a href="https://www.npmjs.com/package/@typegpu/sdf">@typegpu/sdf</a> helper package, which gives us SDF functions for common shapes (though you can implement them yourself from any reference — it’s <a href="https://iquilezles.org/articles/distfunctions/">just math</a>).</p><pre>const getMainSceneDist = (position: d.v3f) =&gt; {<br>  &#39;use gpu&#39;;<br>  return sdf.sdPlane(position, d.vec3f(0, 1, 0), 0.06);<br>};</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AjzbmRbnjeuP6-sK8SiY8A.png" /><figcaption>Just a plane for now</figcaption></figure><p>We defined a plane that spans the xz axis at y=0.06. It appears to cover the entire screen because the camera is tilted downward. That’s just an arbitrary choice — we could just as well keep the camera fixed and rotate the scene instead.</p><p>Now let’s create the cutout where the slider will sit. We’ll use a 2D rounded box and then <strong>extrude</strong> it into the third dimension.</p><pre>const rectangleCutoutDist = (position: d.v2f) =&gt; {<br>  &#39;use gpu&#39;;<br>  return sdf.sdRoundedBox2d(<br>    position,<br>    d.vec2f(1, 0.2), // width and height<br>    0.2, // the roundness factor<br>  );<br>};<br><br>const getMainSceneDist = (position: d.v3f) =&gt; {<br>  &#39;use gpu&#39;;<br>  return sdf.opExtrudeY(<br>    position,<br>    rectangleCutoutDist(position.xz),<br>    0.01,<br>  );<br>}; </pre><p>This will produce a slim rounded rectangle floating in space — but that’s not what we need, we actually need the opposite since it’s supposed to be a cutout. Thanks to the nature of SDFs, it’s as simple as <em>flipping the sign</em> on the rounded‑rectangle function.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PPlokR6F_uIOGusLCE6LTA.png" /><figcaption>The effect of flipping the sign of a SDF</figcaption></figure><p>Our rectangle shape was already rounded, but this roundedness only applies to the 2D shape. The <strong>extrusion</strong> that brings it into the third dimension creates sharp edges, which do not match what we want. A solution for that is to use the <strong>inflation</strong> operation, which is very simple in nature — we basically subtract a flat amount from the distance. This means the shape will grow a bit, but all the sharp edges will get smoothed out.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hkk-cVecBm6PU-zhFpPuwA.png" /><figcaption>The inflation operation</figcaption></figure><p>After applying those operations, all that’s left is to patch the hole in the middle. We will do that using the <strong>union</strong> operation, which is basically taking the minimum of two distance fields. We add a plane at a small offset and make our scene the min of the two.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BnOhWm4TGZnEsBRwMg5Rjg.png" /></figure><p>The final code for the background</p><pre>const rectangleCutoutDist = (position: d.v2f) =&gt; {<br>  &#39;use gpu&#39;;<br>  return sdf.sdRoundedBox2d(<br>    position,<br>    d.vec2f(1, 0.2),<br>    0.2,<br>  );<br>};<br><br>const getMainSceneDist = (position: d.v3f) =&gt; {<br>  &#39;use gpu&#39;;<br>  return sdf.opUnion( // basically std.min<br>    sdf.sdPlane(position, d.vec3f(0, 1, 0), 0.06),<br>    sdf.opExtrudeY( // make it 3d<br>      position,<br>      -rectangleCutoutDist(position.xz), // sign flip<br>      0.01,<br>    ) - 0.02, // inflation<br>  );<br>};</pre><p>You may have noticed that the background in the images is not just a uniform color but appears “lit.” There are many approaches to simulating lighting, but we will settle for one of the more popular and simple ones: the <a href="https://en.wikipedia.org/wiki/Phong_reflection_model"><strong>Phong lighting model</strong></a>. It is very well documented, and <strong>TypeGPU</strong> has a <a href="https://docs.swmansion.com/TypeGPU/examples/#example=rendering--phong-reflection">dedicated example</a> for it.</p><p>Basically, all we need is a <strong>light direction</strong> vector and a <strong>surface normal</strong>. The normal is a vector pointing away from the surface at a given point. With these, we can <em>approximate</em> lighting using three components:</p><ul><li><strong>Ambient</strong> — the light that just <em>is</em>. It represents light that has bounced around the environment multiple times, without actually simulating those bounces.</li><li><strong>Diffuse</strong> — the light scattered by the material when it is hit by the light source. It depends on how directly the surface faces the light.</li><li><strong>Specular</strong> — the light that is directly reflected toward the observer, producing visible highlights on shiny surfaces.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dxScsLOMRM0QqKM5IMyBjg.png" /></figure><p>If we look at the example code — cleaned up and reduced to just the core logic — it looks like this:</p><pre>const calculateLighting = (<br>  hitPosition: d.v3f,<br>  normal: d.v3f,<br>  rayOrigin: d.v3f,<br>) =&gt; {<br>  &#39;use gpu&#39;;<br>  // NOTE: This code operates under the assumption that light direction<br>  // is already normalized<br><br>  // the uniform contains the direction from the light, but for<br>  // the calculations we need the direction towards the light<br>  const lightDir = std.neg(lightUniform.$.direction);<br>  // we calculate the direction of the &quot;eye&quot; by taking the a vector from<br>  // the position the ray hit to the ray origin<br>  const viewDir = std.normalize(rayOrigin.sub(hitPosition));<br>  const reflectDir = std.reflect(std.neg(lightDir), normal);<br><br>  // ambient<br>  const ambientLight = AMBIENT_COLOR.mul(AMBIENT_INTENSITY);<br><br>  // diffuse<br>  const diffuseTerm = std.max(std.dot(normal, lightDir), 0.0);<br>  const diffuseLight = lightUniform.$.color<br>    .mul(diffuseTerm);<br><br>  // specular<br>  const specularTerm = std.max(std.dot(viewDir, reflectDir), 0) **<br>    SPECULAR_POWER;<br>  const specularLight = lightUniform.$.color.mul(specularTerm);<br><br>  // saturate(x) is basically max(0, min(1, x)))<br>  return std.saturate(diffuseLight.add(ambientLight).add(specularLight));<br>};</pre><p>One important detail we’ve conveniently ignored so far is that we’ve been using <strong>normals</strong> as if we already had them — <em>but we don’t</em>.</p><p>In typical 3D models, <strong>normals</strong> are provided per vertex, along with <strong>position</strong> data and other attributes such as <strong>texture coordinates</strong>. The rendering pipeline simply interpolates them across the surface.</p><p>Here, however, we’re not working with a traditional mesh but rendering a <strong>signed distance field</strong>, which means there are no predefined vertices and no stored normals, so we need to compute the normal ourselves — fortunately, this is conceptually pretty easy, even if it can be computationally expensive.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V-fy4baXj69Ph2NI0_u7VA.png" /><figcaption>Computing the normal for any point using SDFs</figcaption></figure><p>The example above computes a normal for an arbitrary point in 2D space, with <strong>greatly </strong>exaggerated distances for clarity. This process generalizes easily to 3D — you simply sample additional points and move the vectors into three-dimensional space. The important detail is that these sample points should be <em>arranged symmetrically</em> and contribute equally along each axis to produce a correct and unbiased <strong>normal</strong>. In the <strong>Jelly Slider</strong>, for performance reasons, we did not use the typical six-point normal approximation with two samples per axis. Instead, we used <a href="https://iquilezles.org/articles/normalsSDF/">four points corresponding to the vertices of a regular tetrahedron</a>. The underlying idea remains the same, and in practice, the results are not noticeably worse.</p><p>A key performance insight becomes obvious when we look at the main scene with normal visualization, where the absolute values of xyz components are mapped to rgb colors.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*We-8_b6a3o9gaEeOHToXsQ.png" /><figcaption>Normal visualization for main scene</figcaption></figure><p>From the image above, it’s clear that a constant normal of [0, 1, 0] would already be a reasonable approximation for the entire scene — and for most of it, even a <em>perfect</em> one. The surface is largely flat and aligned in a single direction, so computing a full SDF-based normal at every point would be unnecessary overhead. That’s why, in the code, the normal calculation for the main scene is simplified to this:</p><pre>const getNormalMain = (position: d.v3f) =&gt; {<br>  &#39;use gpu&#39;;<br>  // check if we are outside the cutout area<br>  if (std.abs(position.z) &gt; 0.22 || std.abs(position.x) &gt; 1.02) {<br>    return d.vec3f(0, 1, 0);<br>  }<br>  // the full tetrahedral normal approximation<br>  return getNormalMainSdf(position, 0.0001);<br>};</pre><p>The getNormalMainSdf function uses one of my favorite <strong>TypeGPU</strong> features: <a href="https://docs.swmansion.com/TypeGPU/fundamentals/slots/"><strong><em>slots</em></strong></a>. Since there are multiple <strong>SDFs</strong> in the scene for which we may want to compute normals, we avoid duplicating code by implementing the tetrahedral normal approximation once and defining the actual SDF function as a slot. The <strong><em>slot </em></strong>represents any function that takes a position and returns a distance. This allows us to <strong>reuse</strong> the same normal computation logic across different parts of the scene by simply plugging in a different <strong>SDF</strong> implementation.</p><pre>const getNormalFromSdf = tgpu.fn([d.vec3f, d.f32], d.vec3f)(<br>  (position, epsilon) =&gt; {<br>    &#39;use gpu&#39;;<br>    // create the offset vectors (scaled by some small value)<br>    const offset1 = k.xyy * epsilon;<br>    const offset2 = k.yyx * epsilon;<br>    const offset3 = k.yxy * epsilon;<br>    const offset4 = k.xxx * epsilon;<br><br>    // scale the vectors by distance<br>    // sdfSlot.$ will be substituted with a function of choice later<br>    const sample1 = offset1 * sdfSlot.$(position + offset1);<br>    const sample2 = offset2 * sdfSlot.$(position + offset2);<br>    const sample3 = offset3 * sdfSlot.$(position + offset3);<br>    const sample4 = offset4 * sdfSlot.$(position + offset4);<br><br>    // create and normalize our normal from samples<br>    const gradient = sample1 + sample2 + sample3 + sample4;<br>    return std.normalize(gradient);<br>  },<br>);<br><br>// fill the slot with our main scene sdf (which we created earlier)<br>const getNormalMainSdf = getNormalFromSdf.with(sdfSlot, getMainSceneDist);</pre><p>You may have noticed that the main scene — aside from currently lacking any <em>jelly</em> — looks somewhat flat. That’s because there are no <strong>shadows</strong>. While we could add them, shadow calculations in an SDF setup tend to be quite <strong>expensive</strong>, and in this case, we’ll rely on a couple of other <em>tricks</em> instead.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*h7nqzcstmho4R6Zv-jzrNA.png" /><figcaption>Scene before and after AO</figcaption></figure><p><a href="https://en.wikipedia.org/wiki/Ambient_occlusion"><strong>Ambient occlusion</strong></a> is a pretty common technique in computer graphics, but in the context of <strong>SDFs,</strong> I would argue it’s even more important due to the inherent nature of <strong>distance fields </strong>— specifically the fact that at any point in space, we already <em>know</em> the distance to the closest element of the scene, with no additional tricks required. Combined with the fact that we know how to calculate the <strong>normal</strong> at any point, we can fairly accurately estimate how <strong>occluded</strong> that point is, meaning how exposed it should be to ambient light.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*S5HorluBO1fJov2bEPq16A.png" /><figcaption>Occlusion calculation overview</figcaption></figure><p>The diagram above illustrates how the occlusion calculation works over three steps, assuming each sample is treated equally, which in practice is usually not the case. In the <strong>Jelly Slider</strong>, we also use three steps, but each consecutive step contributes only <strong>half</strong> as much as the previous one.</p><pre>const calculateAO = (position: d.v3f, normal: d.v3f) =&gt; {<br>  &#39;use gpu&#39;;<br>  let totalOcclusion = d.f32(0);<br>  let totalWeight = d.f32(0);<br>  let sampleWeight = d.f32(1);<br><br>  for (let i = 1; i &lt;= 3; i++) {<br>    const sampleHeight = (AO_RADIUS / 3) * d.f32(i);<br>    // get the point for AO sample<br>    const samplePosition = position + normal * sampleHeight;<br>    const distanceToSurface = getSceneDistForAO(samplePosition);<br><br>    // std.max is here to safeguard from numerical errors<br>    // distanceToSurface should never be &gt; sampleHeight<br>    const occlusionContribution = std.max(0, sampleHeight - distanceToSurface);<br><br>    totalOcclusion += occlusionContribution * sampleWeight;<br>    totalWeight += sampleHeight * sampleWeight;<br>    sampleWeight *= 0.5;<br>  }<br><br>  const rawAO = 1.0 - totalOcclusion / totalWeight;<br>  return std.saturate(rawAO);<br>};</pre><p>Since we already have the scene distance function and normal computation in place, the <strong>ambient occlusion</strong> implementation essentially comes down to performing three fixed ray marching steps along the <strong>normal</strong> and measuring the returned distances. While three steps are not <em>free</em>, they are still <strong>significantly</strong> cheaper than computing proper shadows.</p><blockquote>It is important to stress that <strong>ambient occlusion</strong> is not a replacement for <strong>shadows</strong>. They model fundamentally different phenomena and only happen to produce a similar sense of depth in our very flat scene. The slider does use shadows, but they are not calculated using ray marching.</blockquote><h3>Jelly time</h3><p>Now that we’ve established a solid foundation for rendering the scene, prepared the cutout for the <strong>jelly</strong> to sit in, and set up the simulation infrastructure, we’re ready to tackle arguably the most important part — actually rendering the slider itself.</p><p>There are <strong>many</strong> feasible ways to get it on the screen — and quite a few were explored before settling on the final approach. Here are a couple of screenshots from different phases of <em>experimentation</em>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XQJyMpwb0G_oNq6xA_y2Xw.png" /><figcaption>Jelly at different stages of development</figcaption></figure><p>There were two main challenges to solve, and for a long time, they felt mutually exclusive:</p><p>1. <strong>Appearance</strong> — we want the jelly to have a smooth surface and be as close to the reference in visual quality as possible. <br>2. <strong>Performance</strong> — the goal was to have it run smoothly even on mid to low-end phones.</p><p>The final approach took some iteration, but thankfully, a few things were working in our favor. The key idea was to pick the right primitive to construct the <strong>SDF</strong> from.</p><p>The initial attempt was to build it from a high number of <strong>points</strong> and <strong>line</strong> segments, since distances to those are very cheap to compute. This <strong>failed</strong> mainly because the physics simulation became the bottleneck at higher point counts. To achieve a convincing curve — <em>especially</em> when heavily bent — the number of required points was simply <strong>too high</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-EUWi_POd6GBjW682BE1Zg.png" /></figure><p>The second idea was to use <a href="https://en.wikipedia.org/wiki/B%C3%A9zier_curve"><em>Bézier curves</em></a> (as you already know, this is the approach I ultimately landed on — just not immediately). <em>Bézier</em> curves give you very <em>smooth</em> visuals with a <strong>fraction</strong> of the control points, which is a huge advantage. The downside is that computing the distance to a <em>Bézier</em> curve is not trivial, both <strong>computationally</strong> and <strong>conceptually</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*c1Jfv_2Qy6tI4Mf-B23YSg.png" /></figure><p>Calculating the distance to the <strong>Jelly Slider </strong>— which consists of 16 Bézier segments — for every ray is <em>technically</em> possible, but computationally far too expensive, even for mid-range desktops, let alone mobile devices.</p><p>To put this into perspective: in the best-case scenario for a 2K output at <em>2560</em> by <em>1440</em> pixels, we would need to solve <strong>16</strong> quadratic equations per ray, roughly 8 to 64 times per pixel per frame. Factoring that out gives approximately <strong>1,887,436,800</strong> quadratic equations to solve every single frame — <strong>clearly</strong> not a viable approach.</p><p>That said, this is a worst-case, brute-force estimate. There is, <em>of course</em>, a lot of room for optimization.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*A7PSUlc4cmbjBdLABm1bGg.png" /><figcaption>The three optimization angles</figcaption></figure><p>There are <strong>three</strong> main avenues we can explore in search of optimizations, though the first two are inherently connected.</p><p>The key observation is that the slider is <em>fundamentally</em> two-dimensional. We only expand it into three dimensions using an SDF <strong>extrusion</strong> along the third axis, but the core information — <em>the curvature</em> — lives strictly in 2D.</p><p>This means we can <strong>precompute</strong> the expensive Bézier distance calculations once within a limited 2D region around the slider. Instead of <strong>solving</strong> quadratic equations at render time, we evaluate them ahead of time and store the results in a <em>texture</em>.</p><p>At runtime, this reduces the problem to a simple <strong>texture lookup</strong>. Not only is that <em>dramatically</em> cheaper than solving equations per pixel, but it also gives us <strong>interpolation</strong> for free*, allowing us to reduce the resolution while still maintaining <em>smooth</em> results.</p><blockquote>* Of course, not completely free, but still cheaper than doing it by hand.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ft3viojRSBFQ7n5wFk4Pzg.png" /><figcaption>What the Jelly Slider looks like without makeup</figcaption></figure><p>Since we’re already precomputing the data, we also store some useful auxiliary information to further <strong>reduce</strong> runtime work. Because the Jelly Slider is fundamentally 2D, we can pack quite <strong>a lot</strong> of data into a single pixel.</p><p>Each <strong>vec4f<em> </em></strong>in the texture is laid out as follows:</p><ul><li><strong>x</strong> — the raw <strong>distance</strong> to the nearest Bézier segment</li><li><strong>y </strong>— the <strong>progress</strong> value, meaning how far along the slider we are, normalized to the 0–1 range</li><li><strong>z</strong> and <strong>w</strong> — the precomputed <strong>normal</strong> at that point</li></ul><p>By storing the normal directly, we avoid additional texture samples to <em>reconstruct</em> it, and we also retain the full effective <strong>resolution</strong> of the data. Reconstructing normals from quantized distance values would introduce additional <strong>error</strong> and visibly degrade the result. Precomputing and storing them explicitly keeps both the shader logic simpler <em>and</em> the visual quality higher.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fiSUpg1EyEfgGFqFR2XDpg.png" /><figcaption>Why precomputing normals is helpful</figcaption></figure><p>Great, so we now have a <strong>2D texture</strong> containing all the information we need — but how do we actually <em>use it</em>?</p><p>One of the great properties of <strong>SDFs</strong> is that you can evaluate them anywhere in space by simply passing in a position and the primitive parameters. That convenience <strong>no longer</strong> fully applies here. The texture has limited <strong>bounds</strong>, so we need to know <em>when</em> to sample it and how to map <strong>world space</strong> positions to <strong>texture space</strong>.</p><p>This is where the third optimization helps — and conveniently solves this problem as well. We define a <strong>bounding volume</strong> in world space that fully <em>contains</em> the slider.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/732/1*GtSqlMAVhqHGPQ2bWrc4tA.png" /></figure><p>This helps in two ways.</p><p><strong>First</strong>, ray–box <strong>intersection</strong> tests are simple and computationally cheap, so we can quickly determine whether a ray even has a chance of hitting the slider.</p><p><strong>Second</strong>, once we transform a point into the box’s local space — effectively redefining the coordinate system with one corner of the box as the origin — we regain the convenient <strong>SDF-like </strong>behavior. A normalized position inside this box space directly maps to <strong>texture coordinates</strong>, which tells us exactly where to sample the precomputed distance field to retrieve the jelly slider data.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IzrHuEQ2X1qCwUsDchSATg.png" /><figcaption>Moving to local space (in 2D slice for simplicity)</figcaption></figure><p>The code that converts our encoded data into a meaningful <strong>shader variable</strong> looks like this:</p><pre>const sdInflatedPolyline2D = (p: d.v2f) =&gt; {<br>  &#39;use gpu&#39;;<br>  const bbox = getSliderBbox();<br><br>  // checking where to sample the texture as shown above<br>  const uv = d.vec2f(<br>    (p.x - bbox.left) / (bbox.right - bbox.left),<br>    (bbox.top - p.y) / (bbox.top - bbox.bottom),<br>  );<br>  const clampedUV = std.saturate(uv);<br><br>  // decoding the slider info<br>  const sampledColor = std.textureSampleLevel(bezierTexture.$, filteringSampler.$, clampedUV, 0);<br>  const segUnsigned = sampledColor.x;<br>  const progress = sampledColor.y;<br>  const normal = sampledColor.zw;<br><br>  return LineInfo({<br>    t: progress,<br>    distance: segUnsigned,<br>    normal: normal,<br>  });<br>};</pre><p>This function computes the distance to the slider for a given 2D point. On its own, that would make the slider <em>infinite</em> along the third dimension, so we add a helper that <strong>extrudes</strong> the shape into 3D. This both <strong>bounds</strong> the volume and adds an end cap, implemented as a <em>half-pie</em> <strong>SDF</strong> computed from the last two points of the slider.</p><pre>const sliderSdf3D = (position: d.v3f) =&gt; {<br>  &#39;use gpu&#39;;<br>  const poly2D = sdInflatedPolyline2D(position.xy);<br><br>  let finalDist = d.f32(0);<br>  if (poly2D.t &gt; 0.94) {<br>    finalDist = cap3D(position);<br>  } else {<br>    const body = sdf.opExtrudeZ(<br>      position,<br>      poly2D.distance,<br>      LINE_HALF_THICK<br>    ) - LINE_RADIUS;<br>    finalDist = body;<br>  }<br><br>  return LineInfo({<br>    t: poly2D.t,<br>    distance: finalDist,<br>    normal: poly2D.normal,<br>  });<br>};</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WE8Juup9rrg-LI3kv4tezA.png" /><figcaption>Slider with and without the cap</figcaption></figure><p>Actually, the fully rendered preview in the image above is a bit <strong>premature</strong>. At this stage, we only have the <strong>raw</strong> information about the slider, so it actually looks more like <em>this</em>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W_F6jOk844PZ9Q-laA2DbA.png" /><figcaption>The slider with no additional rendering logic</figcaption></figure><p>Since our current <strong>lighting</strong> logic only knows how to render the ground, it treats rays that hit the slider the same way. Another issue that becomes immediately obvious near the cap area is that linear interpolation is <strong>not enough</strong> to produce believable lighting for the slider. We will need some additional <em>smoothing</em> logic along the edges.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6rKkkJChMMZvOn0NEZLpYQ.png" /><figcaption>Creating better normals for slider edges</figcaption></figure><p>Our slider is fixed along the x-axis, so creating rounded <strong>normals</strong> for the edges to better simulate the smooth shape is fairly straightforward. We check the z position of the hit and create a vector pointing in the z direction, vec3f(0, 0, 1)with the same sign as the z position. Then, using the slider width, which is <em>conveniently</em> constant, we blend between the <strong>interpolated normal</strong> read from the <strong>texture</strong> and this z vector.</p><p>This requires some <strong>trial and error</strong> with the blending ratios to get the smoothing right, but it ultimately allows us to match the <em>curvature</em> of the cap <em>almost</em> perfectly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BvAVPNENOvPPz7hOmhWVnA.png" /></figure><p>Now the only core thing left is to make the slider <strong>transparent</strong>. That might sound <em>scary</em> and <em>complicated</em>, but we actually already have most of the pieces in place. The main missing part is the <a href="https://en.wikipedia.org/wiki/Refraction"><strong>refraction</strong></a> logic so the light bends realistically inside the slider. Once we determine where the <a href="https://en.wikipedia.org/wiki/Refraction"><strong>refracted</strong></a> ray should travel, we can simply <strong>march</strong> along that direction while <em>ignoring</em> the slider itself.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Wwvdc7lUrAxOHVkJeidjag.png" /><figcaption>How to simulate transparency</figcaption></figure><p>In code, this becomes a <strong>simple conditional</strong> branch in the ray marching loop. rayMarchNoJelly is a small ray marching routine that runs for six steps, which is <strong>more</strong> than enough to produce convincing refractions. If nothing is hit after six steps, we simply take the closest point reached so far, which turns out to be a <em>good enough</em> approximation.</p><pre>if (hitInfo.distance &lt; SURF_DIST &amp;&amp; hitInfo.objectType === ObjectType.SLIDER) {<br>  const hitPosition = rayOrigin + rayDirection * distanceFromOrigin;<br><br>  const N = getNormal(hitPosition, hitInfo);<br><br>  const eta = 1 / JELLY_IOR;<br>  const refrDir = std.refract(rayDirection, N, eta);<br><br>  const env = rayMarchNoJelly(hitPosition, refrDir);<br>  const progress = hitInfo.t;<br>  const jellyColor = jellyColorUniform.$;<br><br>  const scatterTint = jellyColor.rgb * 1.5;<br>  const absorb = (1 - jellyColor.rgb) * 20;<br><br>  const T = beerLambert(absorb * (progress ** 2), 0.08);<br><br>  const lightDir = std.neg(lightUniform.$.direction);<br><br>  const forward = std.max(0, std.dot(lightDir, refrDir));<br>  const scatter = scatterTint * (JELLY_SCATTER * forward * progress ** 3);<br>  const refractedColor = env * T + scatter;<br><br>  return d.vec4f(refractedColor, 1);<br>}</pre><p>The code is simplified by assuming a <strong>constant thickness</strong>, which turns out to be <em>good enough</em>. We could ray march inside the volume or derive a formula to calculate how much jelly the light travels through, but that would either be <strong>expensive</strong> or <strong>time-consuming</strong> for a detail that is <em>barely</em> noticeable.</p><p>Putting everything we discussed in this article together, we get:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WG8nWfRB6Ci8mGvOlscBkg.png" /><figcaption>The final product</figcaption></figure><p>Not quite a 1:1 match with the original example, but <em>close enough</em> without diving into too much additional <strong>detail</strong>. Everything that is missing can be derived from what we have covered so far. The <strong>shadow</strong> is simply the slider texture <strong>projected</strong> onto the plane, the <em>subtle </em><strong>glow</strong> is derived from the slider distance, the <strong>highlight</strong> in the middle is entirely <em>hard-coded</em>, and the text rendered on the ground is just an image.</p><p>The code is public, and I <strong>encourage</strong> you to explore and experiment with it. With what you know now, you will probably be able to quickly identify the remaining tricks used in the implementation.</p><blockquote>Thank you for reading to the end :) I hope this article was detailed enough to convey some useful techniques and spark interest in graphics programming while still staying engaging. If you have any questions, feel free to reach out on the <a href="https://discord.gg/YADgYBQw">TypeGPU Discord server</a>, where I am often around, or contact me <a href="https://x.com/reczko_konrad">directly on X</a>.</blockquote><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9ab9239f6d80" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/breaking-down-the-jelly-slider-9ab9239f6d80">Breaking Down the Jelly Slider</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Future of Video in React Native: Moving from expo-av to expo-video]]></title>
            <link>https://medium.com/swmansion/the-future-of-video-in-react-native-moving-from-expo-av-to-expo-video-6f4f78e51196?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/6f4f78e51196</guid>
            <category><![CDATA[expo-video]]></category>
            <category><![CDATA[react-native-development]]></category>
            <category><![CDATA[expo-sdk-55]]></category>
            <category><![CDATA[react-native]]></category>
            <dc:creator><![CDATA[Wiktor Gut]]></dc:creator>
            <pubDate>Fri, 27 Feb 2026 14:38:36 GMT</pubDate>
            <atom:updated>2026-02-27T14:38:34.751Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LIx4Q7C37avZx88-5TM1Ng.png" /></figure><p>The release of Expo SDK 55 marks a turning point for media handling in React Native. Following its previous deprecation,<strong> </strong><strong>expo-av has now been fully removed</strong>, with <strong>its functionality officially split into two specialized, modern packages: </strong><strong>expo-video and </strong><strong>expo-audio.</strong></p><p>Having recently led the migration to expo-video at <a href="https://github.com/Expensify/App"><strong>Expensify</strong></a> (as part of the Software Mansion team), we’ll walk you through the key differences between the libraries and share what we learned along the way, so you can migrate with confidence.</p><h3>Decoupling logic from UI</h3><p><strong>The most important architectural change is the separation of concerns.</strong> We are moving from the monolithic &lt;Video/&gt; component to a dual-system: VideoPlayer for logic control and &lt;VideoView/&gt; for the UI.</p><p>This split closely <strong>mirrors native media handling</strong>, where playback logic and visual rendering have always been distinct entities. By aligning with Android’s ExoPlayer/PlayerView and iOS’s AVPlayer/AVPlayerViewController, <strong>expo-video gives developers the same granular control and architectural clarity found in native development.</strong></p><h3>expo-av: initialization</h3><pre>const videoRef = useRef&lt;Video&gt;(null);<br><br>&lt;Video<br>  ref={videoRef}<br>  source={{ uri: VIDEO_URL }}<br>/&gt;</pre><h3>expo-video: initialization</h3><pre>const player: VideoPlayer = useVideoPlayer(VIDEO_URL);<br><br>&lt;VideoView player={player} /&gt;</pre><p>Plus, you can now define the player’s initial state directly during initialization — not just via props on the View, which is much more intuitive:</p><pre>const player = useVideoPlayer(VIDEO_URL, (player) =&gt; {<br>  player.loop = true;<br>  player.muted = true;<br>  player.playbackRate = 1.75;<br>});</pre><h3>VideoPlayer vs. VideoView: method mapping</h3><p>Since playback logic has moved from the Video ref to the VideoPlayer object, most methods (play/pause, volume/mute, playback rate, etc.) are now accessed directly through the player. Only a few presentation-related methods remain on the VideoView. This ensures that the native playback logic and the visual presentation layer remain decoupled.</p><h3>expo-av: monolithic methods</h3><pre>videoRef.current.playAsync();<br>videoRef.current.pauseAsync();<br>videoRef.current.setRateAsync(newRate, true);<br>videoRef.current.setIsMutedAsync(!isMuted);<br>videoRef.current?.presentFullscreenPlayer();<br>videoRef.current?.dismissFullscreenPlayer();</pre><h3>expo-video: player vs. view methods</h3><pre>// Playback logic (Player)<br>player.play();<br>player.pause();<br>player.playbackRate = newRate;<br>player.muted = !muted;<br><br>// Presentation logic (View)<br>videoViewRef.current?.enterFullscreen();<br>videoViewRef.current?.exitFullscreen();</pre><h3>Synchronous vs. asynchronous calls</h3><p><strong>Another game-changing update in </strong><strong>expo-video is the introduction of synchronous function calls</strong>. This preserves the execution order of your code without the need for async/await boilerplate.</p><h4>expo-av: toggle playback (async)</h4><pre>const togglePlayback = useCallback(async () =&gt; {<br>  if (!videoRef.current) return;<br>  if (isPlaying) {<br>    await videoRef.current.pauseAsync();<br>  } else {<br>    await videoRef.current.playAsync();<br>  }<br>}, [isPlaying]);</pre><h4>expo-video: toggle playback (sync)</h4><pre>const togglePlayback = useCallback(() =&gt; {<br>  player.playing ? player.pause() : player.play();<br>}, [player]);</pre><h3>Modern state management</h3><p><strong>Finally, </strong><strong>expo-video addresses one of the biggest pain points of </strong><strong>expo-av: state updates via the </strong><strong>onPlaybackStatusUpdate callback</strong>. In the old API, this single, massive function was triggered by every state change (including time updates), forcing developers to parse a huge AVPlaybackStatus object and trigger multiple setState calls.</p><p><strong>expo-video solves this with a subscription-based approach, splitting updates into atomic, manageable events.</strong></p><h4>useEvent</h4><p>Use this hook when you need a value (like isPlaying) that stays in sync with a specific event (playingChange). You can also provide a default value:</p><pre>const { isPlaying } = useEvent(player, &quot;playingChange&quot;, {<br>  isPlaying: player.playing,<br>});</pre><h4>useEventListener</h4><p>You can use this hook to react to events immediately. For optimal performance, you should avoid using useEffect for event-driven logic and instead handle reactions directly within this listener:</p><pre>useEventListener(player, &quot;playToEnd&quot;, () =&gt; {<br>  console.log(&quot;Video finished playing&quot;);<br>});</pre><h3>Performance impact</h3><p>In expo-av, a typical status update handler looked like this:</p><pre>const handlePlaybackStatusUpdate = useCallback((status: AVPlaybackStatus) =&gt; {<br>  if (status.isLoaded) {<br>    setIsPlaying(status.isPlaying);<br>    setPosition(status.positionMillis);<br>    if(status.rate &gt; 2){<br>      setRateText(&quot;It&#39;s really fast&quot;);<br>    }<br>  }<br>}, []);</pre><p>In expo-video, we achieve the same result with significantly better performance and less boilerplate:</p><pre>const { isPlaying } = useEvent(player, &quot;playingChange&quot;, {<br>  isPlaying: player.playing,<br>});<br><br>const { currentTime } = useEvent(player, &#39;timeUpdate&#39;, {<br>  currentTime: 0<br>});<br><br>useEventListener(player, &quot;playbackRateChange&quot;, (payload) =&gt; {<br>  if(payload.playbackRate &gt; 2){<br>    setRateText(&quot;It&#39;s really fast&quot;);<br>  }<br>});</pre><p><strong>Why is this better?</strong> In this model, isPlaying only updates when the playingChange event occurs, and the playbackRateChange logic only executes when the rate actually changes. In expo-av, both would be re-evaluated every time currentTime is updated (which happens multiple times per second), leading to unnecessary overhead.</p><h3>Just the tip of the iceberg</h3><p>If you want to see these differences in action, you can check out this <a href="https://snack.expo.dev/@guccio_w/expo-video-demo?platform=ios"><strong>Expo Snack demo</strong></a><strong> </strong>with our side-by-side comparison.</p><p>The differences highlighted above are just the beginning. expo-video offers a wealth of new possibilities: from custom buffering and reusing the same VideoPlayer across different screens to native thumbnail generation and robust Picture-in-Picture support. <strong>React Native and Expo developers now have more power than ever to tailor the video experience to their needs.</strong></p><p>You can explore the full potential through expo-video docs at docs.expo.dev/versions/latest/sdk/video</p><h3>Need support with your migration?</h3><p>Migrating from expo-av to expo-video in large-scale projects is more than just a syntax swap — it gives you a chance to refresh your workflow and boost performance.</p><p>If your team has encountered a unique edge case, needs a performance audit, or simply wants professional help navigating this transition, <a href="https://swmansion.com/contact">reach out to us at Software Mansion</a>. As creators of some of the key tools within the React Native ecosystem (such as Reanimated or Gesture Handler), we’re ready to help you make the most of expo-video and the new SDK 55.</p><p>We’re <a href="https://swmansion.com/">Software Mansion</a>: multimedia experts, AI explorers, React Native core contributors, community builders, and software development consultants.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6f4f78e51196" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/the-future-of-video-in-react-native-moving-from-expo-av-to-expo-video-6f4f78e51196">The Future of Video in React Native: Moving from expo-av to expo-video</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing Gesture Handler 3.0: Hook-Based API, Deeper Reanimated Integration & More]]></title>
            <link>https://medium.com/swmansion/introducing-gesture-handler-3-0-hook-based-api-deeper-reanimated-integration-more-9185b0c8e305?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/9185b0c8e305</guid>
            <category><![CDATA[react-native-reanimated]]></category>
            <category><![CDATA[gesture-handler]]></category>
            <category><![CDATA[react-native-gesture]]></category>
            <category><![CDATA[react-native]]></category>
            <dc:creator><![CDATA[Jakub Piasecki]]></dc:creator>
            <pubDate>Thu, 05 Feb 2026 15:12:55 GMT</pubDate>
            <atom:updated>2026-02-05T16:08:46.711Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P41xKmDOlCZExwW3-oorwA.png" /></figure><p><strong>Meet the first beta release of </strong><a href="https://github.com/software-mansion/react-native-gesture-handler/releases/tag/v3.0.0-beta.1"><strong>Gesture Handler 3</strong></a><strong>. This release brings your favorite gesture handling library up to speed in the new world, where the New Architecture is the Architecture, manual memoization is quickly becoming a relic of the past, thanks to the React Compiler, and interactions are more beautiful and more performant than ever, thanks to Reanimated 4.</strong></p><h3>New API based on hooks</h3><p>To adapt to today’s environment, we had to make some changes, the most notable of which is <strong>the new hook-based API</strong>, which is compatible with React Compiler. This introduces a conceptual shift compared to previous implementations, where the gesture lifecycle was fully managed by the component it was attached to. In the new API, gestures are managed entirely using hooks, and components are responsible only for attaching them to the correct views.</p><p>If you have been using Gesture Handler so far, the new API will look familiar, but with some significant changes. The most noticeable ones are the renamed callbacks — onStart becomes onActivate, and onEnd becomes onDeactivate. The new names align more closely with the gesture state flow.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dZ0g9Vcn9rYtnj1CBkKfyg.png" /></figure><p>You can see the most important changes in the <a href="https://docs.swmansion.com/react-native-gesture-handler/docs/guides/upgrading-to-3">updated migration guide</a> in our documentation.</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2FxVhwi7skJRg%3Ffeature%3Doembed&amp;display_name=YouTube&amp;url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DxVhwi7skJRg&amp;image=https%3A%2F%2Fi.ytimg.com%2Fvi%2FxVhwi7skJRg%2Fhqdefault.jpg&amp;type=text%2Fhtml&amp;schema=youtube" width="854" height="480" frameborder="0" scrolling="no"><a href="https://medium.com/media/712572e1023a6604c680b46d06825c69/href">https://medium.com/media/712572e1023a6604c680b46d06825c69/href</a></iframe><h3>Saying farewell to the legacy Architecture</h3><p>Gesture Handler has supported the New Architecture since version <a href="https://github.com/software-mansion/react-native-gesture-handler/releases/tag/2.3.0">2.3.0</a>. Keeping support for both the new and the legacy architectures proved difficult at times, and prevented us from utilizing the new features. <strong>Since the New Architecture has become the only architecture, Gesture Handler 3 will no longer support the legacy architecture.</strong></p><p>This shift enabled us to make significant internal changes, unlocking previously unavailable capabilities:</p><ul><li>You can now effortlessly attach gestures to Text components and inline fragments.</li><li>The StateManager, previously only available in select callbacks, is now generally available on the JavaScript and main threads.</li><li>The button implementation has been completely revamped.</li><li>The new API comes with support for React Native’s built-in Animated API.</li></ul><p>…and there are many more enhancements on the horizon.</p><h3>Updated Reanimated integration</h3><p>We have also overhauled our integration points with Reanimated, making them even more performant. In combination with the new API, this <strong>enabled us to implement a long-requested feature — using </strong><a href="https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/glossary#shared-value"><strong>SharedValues</strong></a><strong> directly in the gesture config.</strong> This, finally, allows for changing the gesture properties with no re-renders.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WieJiXJI-gYnfOfq_pYeYQ.png" /></figure><p>The improved integration will also benefit projects like <a href="https://rnrepo.org/">RNRepo</a> — a repository for React Native pre-built artifacts. Now, it will be possible to build the projects independently of each other.</p><h3>Everything is still there</h3><p>We are aware that adopting new things isn’t easy. In order to make the switch as simple as possible, we decided to keep all APIs available in the previous versions. This means that <strong>upgrading to Gesture Handler 3 should be as simple as installing the new version</strong> and checking <a href="https://docs.swmansion.com/react-native-gesture-handler/docs/guides/upgrading-to-3">the migration guide</a> to adapt to a few name changes.</p><p>Adopting the new API itself can be done gradually — component by component or screen by screen. The only caveat is that the hooks API is not cross-compatible with the previous ones. To set up relations, like simultaneous, between the gestures, all of them will need to use the hooks API.</p><p>If you can use LLM Agents in your codebase, you can also try our <a href="https://github.com/software-mansion/react-native-gesture-handler/blob/696d301202b6ab34bbca0d60783aa96271f7fea7/skills/gesture-handler-3-migration/SKILL.md">migration skill</a>, which should make the adoption even easier.</p><h3>Thanks to Shopify</h3><p>As always, we at <a href="https://swmansion.com/">Software Mansion</a> would like to thank <a href="https://shopify.engineering/">Shopify</a> for their continued support of our open-source projects and trust throughout the years.</p><p>We’re <a href="https://swmansion.com/">Software Mansion</a>: multimedia experts, AI explorers, React Native core contributors, community builders, and software development consultants.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9185b0c8e305" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/introducing-gesture-handler-3-0-hook-based-api-deeper-reanimated-integration-more-9185b0c8e305">Introducing Gesture Handler 3.0: Hook-Based API, Deeper Reanimated Integration &amp; More</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Behind the Scenes of React Native Multithreading: Vision Camera V5 x React Native Worklets]]></title>
            <link>https://medium.com/swmansion/behind-the-scenes-of-react-native-multithreading-vision-camera-v5-x-react-native-worklets-a102c37b32ae?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/a102c37b32ae</guid>
            <category><![CDATA[react-native-worklets]]></category>
            <category><![CDATA[react-native]]></category>
            <category><![CDATA[multithreading]]></category>
            <dc:creator><![CDATA[Tomasz J. Żelawski]]></dc:creator>
            <pubDate>Thu, 29 Jan 2026 15:35:28 GMT</pubDate>
            <atom:updated>2026-01-30T13:15:42.165Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4KAuH716fi9DUScwVQ3iGg.png" /></figure><p>If you’ve been working with React Native for a while, you’ve probably hit a wall where animations, gestures, or app performance <em>should</em> have been smooth, but weren’t.</p><p>For a long time, React Native developers had to choose between developer comfort and performance. JavaScript was easy and expressive, but pushing it too far was challenging. <strong>As apps grew more ambitious, adding real‑time camera features, advanced graphics, or audio processing, it became clear that we needed better tools for concurrency.</strong></p><p>In this article, we tell the story of how the React Native Worklets library was born, what it does, what it brings to the ecosystem, and how it powers tools like the new VisionCamera v5. Let’s dive in!</p><h3>How React Native Worklets was born</h3><p>At first <a href="https://docs.swmansion.com/react-native-worklets/">React Native Worklets</a> wasn’t a separate project. The library was originally developed as an internal component for tools like <a href="https://docs.swmansion.com/react-native-reanimated">React Native Reanimated</a> or <a href="https://docs.swmansion.com/react-native-gesture-handler/">React Native Gesture Handler</a>, allowing developers to create smooth animations and interactions that felt truly native.</p><p>In the early days of React Native, your animation logic would exist solely in JavaScript, running on the same thread, the JS thread, as the React part of your application. This meant that any expensive operation, such as data processing, network activity, or even unrelated JavaScript work, could negatively impact animation performance. Reanimated addressed this by introducing a way to execute animation logic outside of React’s render cycle, safe from JS thread bottlenecks.</p><p><strong>The technical mechanism behind this approach became known as <em>worklets</em> — small, isolated pieces of JavaScript code designed to run on the UI thread, on a separate JavaScript runtime.</strong> They had a restricted execution environment by design, which made their behavior predictable and safe to run concurrently.</p><p>An example of how worklets can be used to react to gestures synchronously on the UI thread to trigger animations and layout changes:</p><pre>const pan = Gesture.Pan()<br>    .onChange((event) =&gt; {<br>      &#39;worklet&#39;;<br>      offset.value = event.translationX;<br>    })<br>    .onFinalize(() =&gt; {<br>      &#39;worklet&#39;;<br>      offset.value = withSpring(0);<br>    });</pre><p>As Reanimated evolved, it became clear that this execution model was useful far beyond animations. <strong>The same characteristics that made worklets good for animations (low latency, independence from React, and concurrent execution) also applied to gesture handling, graphics rendering, camera frame processing, audio processing, and other performance-critical tasks.</strong></p><p>For example, react-native-vision-camera introduced “Frame Processors” — a mechanism to run a JS function on every Frame the Camera “sees”:</p><pre>```ts<br>const frameProcessor = useFrameProcessor((frame) =&gt; {<br>  &#39;worklet&#39;<br>  const objects = detectObjects(frame)<br>  const label = objects[0].name<br>  console.log(`You&#39;re looking at a ${label}.`)<br>}, [])<br>return &lt;Camera frameProcessor={frameProcessor} /&gt;<br>```</pre><p>At that point, worklets started to outgrow their original context. Developers and library authors needed access to multithreading capabilities even when animations weren’t involved. So, <strong>we realized that keeping worklets tightly coupled to Reanimated equaled unnecessary dependencies and limited adoption.</strong></p><p><strong>Extracting worklets into a standalone library was a practical decision that allowed us to focus more on multithreading and provide a general-purpose solution for concurrency in React Native.</strong></p><h3>React Native Worklets as a multithreading engine</h3><p>Today, <strong>React Native Worklets enables full-scale multithreading within the React Native framework. </strong>You can think of it as an engine that lets you run JavaScript logic concurrently, without writing native code.</p><p>The key idea is simple: purely native apps can utilize multiple CPU cores to increase performance — this should be easily done in React Native too. Heavy calculations, continuous processing, or real-time data handling don’t have to execute within your components. <strong>With React Native Worklets, you define what pieces of your JavaScript code should run independently, keeping your UI responsive and predictable.</strong></p><p>Thanks to it, instead of jumping into native modules or complex bridges, developers can stay in JavaScript and use familiar APIs. This is exactly why worklets are such a good fit for many specialized libraries.</p><h3>Integrating React Native Worklets and VisionCamera</h3><blockquote>react-native-vision-camera (often referred to simply as “VisionCamera”) is a powerful, high-performance Camera library for React Native. Next to photo, video, or snapshot capture, it also supports real-time “Frame Processing” via useFrameProcessor(…).</blockquote><blockquote>Since the Camera runs on a separate Camera Thread, VisionCamera conventionally would have to perform a Thread-hop to call the JS Frame Processor, which can cause stalling, or even worse — requires serialization for a Frame — at 4k, that is ~33MB per Frame, or ~1GB/s.</blockquote><blockquote>Thanks to <em>worklets</em>, VisionCamera instead synchronously calls your JS Frame Processor on the Camera Thread without a Thread-hop nor serialization! <strong>Without worklets, VisionCamera Frame Processors would’ve never existed.</strong></blockquote><blockquote>As a user, you don’t need to configure anything — if react-native-worklets is installed, VisionCamera V5 can use it automatically thanks to <a href="https://github.com/mrousavy/nitro/blob/main/packages/react-native-nitro-modules/src/worklets/installWorkletsSupport.ts">Nitro’s worklets integration</a>.</blockquote><h3>What’s next?</h3><p>The react-native-worklets library is still young, and there’s plenty of room to grow. We’re actively working on making it easier to adopt, as multithreading shouldn’t feel like an advanced trick; it should be something devs reach for naturally when they want to improve app performance.</p><p>We’re also really excited to see what the community will build on this foundation. Rendering, audio, automation, and real-time processing: these are all areas where react-native-worklets can simplify work and make limitations disappear. So, take a look at our docs and <a href="https://docs.swmansion.com/react-native-worklets/docs/">get started</a>!</p><p>We’re <a href="https://swmansion.com/">Software Mansion</a>: multimedia experts, AI explorers, React Native core contributors, community builders, and software development consultants.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a102c37b32ae" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/behind-the-scenes-of-react-native-multithreading-vision-camera-v5-x-react-native-worklets-a102c37b32ae">Behind the Scenes of React Native Multithreading: Vision Camera V5 x React Native Worklets</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Voice AI: How We Built a Multi-Speaker AI Agent using Gemini]]></title>
            <link>https://medium.com/swmansion/voice-ai-how-we-built-a-multi-speaker-ai-agent-using-gemini-a59e08fb18aa?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/a59e08fb18aa</guid>
            <category><![CDATA[gemini]]></category>
            <category><![CDATA[multimedia]]></category>
            <category><![CDATA[webrtc]]></category>
            <category><![CDATA[voice-ai]]></category>
            <category><![CDATA[games]]></category>
            <dc:creator><![CDATA[Bernard Gawor]]></dc:creator>
            <pubDate>Thu, 29 Jan 2026 11:57:52 GMT</pubDate>
            <atom:updated>2026-01-29T11:57:51.047Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GjvlmUm5xZK9rzulgNcOPA.png" /></figure><p>Picture a lively dinner party: glasses clinking, half-finished sentences, and three people laughing at the same time. To a human, navigating this is instinctual. To an AI, it is a nightmare. We have effectively mastered the predictable flow of a one-on-one chat. <strong>But handling a group conversation, where people interrupt and talk over each other, is much more difficult.</strong></p><p>Together with the Fishjam team, we set out to showcase our Selective Forwarding Unit solution by building a unique demo app that solves this problem. That’s how the Deep Sea Stories game came to life.</p><p>The premise is simple: a group of detectives enters a conference room to solve a mystery. The twist? The “Riddle Master”, the entity that knows the secret solution and answers questions is actually a <strong>Gemini Voice AI Agent.</strong> This required the agent to listen, understand, and respond to a group of users in real-time.</p><h3>The anatomy of a voice agent</h3><p>First, let’s look at how an AI Voice Agent typically processes data. It typically operates through a modular pipeline that includes the following steps:</p><ul><li><strong>Speech-to-text (S2T):</strong> The system converts the user’s spoken input into text using models like Google Speech-to-Text, OpenAI Whisper or ElevenLabs’ transcription service.</li><li><strong>Large Language Model (LLM):</strong> The transcribed text is processed by an LLM (e.g. Gemini, GPT-4, Claude) to understand the context and generate an appropriate text response.</li><li><strong>Text-to-speech (TTS):</strong> The text response is converted back into natural-sounding speech using services like Google Cloud TTS, ElevenLabs or Azure TTS.</li><li><strong>Real-time audio streaming:</strong> The audio is delivered back to the user with minimal latency.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ac9HEAU5aN6Pqi4iwpgEiQ.png" /></figure><p>A second architecture gaining popularity, and notably used in the newest Gemini Live API models is <strong>Speech-to-Speech</strong>. Unlike traditional pipelines that convert speech to text and back again, this architecture feeds raw audio directly into the model and generates audio output in a single step. This unified approach not only reduces latency but also preserves non-verbal features, enabling the model to recognize and replicate subtle human emotions, tone, and pacing with high fidelity.</p><h3>One-to-one vs. group contexts</h3><p>Most standard SDKs make setting up a <strong>one-on-one</strong> conversation relatively simple. For example, using the Gemini Live API SDK:</p><pre>const { GoogleGenAI } = require(&quot;@google/genai&quot;);<br><br>// 1. Setup<br>const ai = new GoogleGenAI({ apiKey: &quot;YOUR_API_KEY&quot; });<br><br>async function startAgent() {<br>  // 2. Connect<br>  const session = await ai.live.connect({<br>    model: &#39;gemini-2.5-flash-native-audio-preview-12-2025&#39;,<br>    config: { responseModalities: [&quot;AUDIO&quot;] },<br>  });<br><br>  console.log(&quot;Agent Connected!&quot;);<br><br>  // 3. Listen for the Agent&#39;s Voice<br>  session.receive(async (msg) =&gt; {<br>    // This loop runs every time the AI sends an audio chunk<br>    if (msg.serverContent?.modelTurn?.parts) {<br>      const audioData = msg.serverContent.modelTurn.parts[0].inlineData.data;<br>      console.log(`Received Audio Chunk (${audioData.length} bytes)`);<br>      // In a real app, you would send &#39;audioData&#39; to your audio output device<br>    }<br>  });<br><br>  // 4. Send Your Voice (Simulated)<br>  // Real apps pipe microphone data here continuously<br>  console.log(&quot;Sending audio...&quot;);<br>  await session.sendRealtimeInput([{<br>    mimeType: &quot;audio/pcm;rate=16000&quot;,<br>    data: &quot;BASE64_ENCODED_PCM_AUDIO_STRING_GOES_HERE&quot; <br>  }]);<br>}<br><br>startAgent();</pre><p>However, these SDKs assume a single audio input stream. In a conference room, audio streams are <strong>distinct, asynchronous, and overlapping</strong>. We had to determine how to aggregate these inputs for the Riddle Master without losing context or introducing unacceptable latency.</p><p>We evaluated three specific architectural strategies to handle the multi-speaker environment:</p><ul><li><strong>Server-side aggregation:</strong> This method involves mixing all player audio streams into a single channel before sending it to the AI Agent. While simple to implement, mixing audio makes it incredibly difficult for the Speech-to-Text (S2T) model to transcribe accurately, especially when users talk over one another. This results in “hallucinations” or missed queries.</li><li><strong>Agent per client:</strong> This approach assigns a separate Voice AI agent to every single player in the room. This creates a chaotic user experience (all agents speaking at once) and prevents a shared game state. It is also cost-prohibitive, as every user stream consumes separate processing tokens.</li><li><strong>Server-side filtering using VAD:</strong> In this approach, we implemented a centralized gatekeeper using Voice Activity Detection (VAD). We wait for a player to speak, lock the “input slot” and forward only that specific player’s audio to the AI agent. Once they stop speaking, the lock is released, allowing another player to ask questions. This is the solution we finally went with.</li></ul><h3>Beyond one-on-one: A “Deep Sea Stories” game web app</h3><h3>Key technologies</h3><ul><li><strong>Fishjam:</strong> A real-time communication platform handling peer-to-peer audio streaming via WebRTC (SFU). (Not familiar with WebRTC/SFUs? <strong>Check out our </strong><a href="https://fishjam.io/blog/webrtc-p2p-sfu-mcu-and-all-you-need-to-know-about-them-596b6ccb6ddf"><strong>guide</strong></a>)</li><li><strong>Gemini GenAI Voice Agent:</strong> Provides an easy SDK that makes creating voice agents and initializing audio conversations simple.</li></ul><h3>Architecture overview</h3><p>The game logic is handled on the backend, which manages the conferencing room and peer connections.</p><ul><li><strong>Player connection:</strong> When players join the game using frontend client, they connect audio/video via the Fishjam Web SDK. (See: <a href="https://docs.fishjam.io/tutorials/react-quick-start">Fishjam React Quick Start</a>).</li><li><strong>The bridge:</strong> When the game starts, the backend creates a <a href="https://docs.fishjam.io/tutorials/agents"><strong>Fishjam Agent</strong></a>. This agent acts like a “ghost peer” in the audio-video room; its sole purpose is to capture audio of the players and forward it to the AI, and vice versa.</li><li><strong>The brain:</strong> The backend initiates a WebSocket connection with the Gemini agent and forwards the audio stream from players to Gemini and vice versa.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LsemHlvUJTPqggfdy214qA.png" /></figure><h3>Implementation details</h3><h3>1. Initalizing Clients and game room</h3><pre>import { FishjamClient } from &#39;@fishjam-cloud/js-server-sdk&#39;;<br>import GeminiIntegration from &#39;@fishjam-cloud/js-server-sdk/gemini&#39;;<br><br>const fishjamClient = new FishjamClient({<br>  fishjamId: process.env.FISHJAM_ID!,<br>  managementToken: process.env.FISHJAM_TOKEN!,<br>});<br><br>const genAi = GeminiIntegration.createClient({<br>  apiKey: process.env.GOOGLE_API_KEY!,<br>});<br><br>const gameRoom = await fishjamClient.createRoom();</pre><h3>2. Creating the Fishjam agent</h3><p>When the first player joins the game room, we create the Fishjam agent to capture players’ audio on the backend.</p><pre>import GeminiIntegration from &#39;@fishjam-cloud/js-server-sdk/gemini&#39;;<br><br>const { agent } = await fishjamClient.createAgent(gameRoom.id, {<br>  subscribeMode: &#39;auto&#39;,<br>  // Use our preset to match the required audio format (16kHz)<br>  output: GeminiIntegration.geminiInputAudioSettings,<br>});<br>// agentTrack enables to send audio back to players <br>const agentTrack = agent.createTrack(GeminiIntegration.geminiOutputAudioSettings);</pre><h3>3. Configuring and initializing the AI riddle master</h3><p>When users select a story scenario, we configure the Gemini agent with the specific context (the riddle solution and the “Game Master” persona).</p><pre>const session = await genAi.live.connect({<br>  model: GEMINI_MODEL,<br>  config: { <br>      responseModalities: [Modality.AUDIO],<br>      systemInstruction: &quot;here&#39;s the story: ..., and its solution: ... you should answer only yes or no questions about this story&quot;,<br>  },<br>  callbacks: {<br>    // Gemini -&gt; Fishjam<br>    onmessage: (msg) =&gt; {<br>      if (msg.data) {<br>        // send Riddle Master&#39;s sudio responses back to players<br>        const pcmData = Buffer.from(msg.data, &#39;base64&#39;);<br>        agent.sendData(agentTrack.id, pcmData);<br>      }<br><br>      if (msg.serverContent?.interrupted) {<br>        console.log(&#39;Agent was interrupted by user.&#39;);<br>        // Clears the buffer on the Fishjam media server<br>        agent.interruptTrack(agentTrack.id);<br>      }<br>    }<br>  }<br>});</pre><h3>4. Bridging audio (the glue)</h3><p>The final piece of the puzzle is the bridge between the SFU and the AI. We capture audio streams from the Fishjam agent (what the players are saying) and pass them through a custom VAD (Voice Activity Detection) filter. This filter implements a “mutex” lock mechanism: it identifies the first active speaker, locks the channel to their ID, and forwards only their audio to Gemini. All other simultaneous audio is ignored until the active speaker finishes their turn.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/869/1*byZUD65Rxyeqzi6Ezx0NUg.png" /></figure><p>Below is the simplified code of this logic:</p><pre>// State to track who currently &quot;holds the floor&quot;<br>let activeSpeakerId: string | null = null;<br><br>// We capture audio chunks from ALL players in the room<br>agent.on(&quot;audioTrack&quot;, (userId, pcmChunk) =&gt; {<br>    vadService.process(userId, pcmChunk);<br>});<br><br>// VAD Processor Logic<br>vadService.on(&quot;activity&quot;, (userId, isSpeaking, audioData) =&gt; {<br>    if (activeSpeakerId === null &amp;&amp; isSpeaking) {<br>        activeSpeakerId = userId; // Lock the floor<br>    }<br><br>    // We only forward audio if it comes from the person holding the lock<br>    if (userId === activeSpeakerId) {<br>        voiceAgentSession.sendAudio(audioData);<br><br>        // If the active speaker stops speaking (silence detected), release the lock<br>        if (!isSpeaking) {<br>             // (Optional: Add a debounce delay here to prevent cutting off pauses)<br>             activeSpeakerId = null; <br>        }<br>    }<br>});</pre><h3>Challenges in group AI</h3><p>Building a multi-user voice interface introduces unique challenges compared to 1-on-1 chats:</p><ul><li><strong>Floor control:</strong> Standard Speech-to-Text models can struggle when multiple players speak simultaneously. Determining which player the AI should respond to or if it should simply listen requires careful handling.</li><li><strong>Latency:</strong> Real-time responsiveness is critical for immersion. The entire pipeline (Audio → Text → LLM → Audio) must execute in milliseconds.</li><li><strong>Audio quality:</strong> Maintaining clear audio through transcoding and streaming across different networks is essential.</li></ul><p>Fortunately, Fishjam’s WebRTC implementation largely solves the latency and audio quality issues. The challenges of Floor Control needed carefully structured implementation on the backend, but it was not really that hard!</p><h3>Try the game yourself!</h3><p>We have implemented the functionality described above in a live demo. Gather your friends and try to solve a mystery with our AI Riddle Master!</p><ul><li>Play the Demo: <a href="https://deepsea.fishjam.io/">Deep Sea Stories</a></li><li>View the Code: <a href="https://github.com/fishjam-cloud/examples/tree/main/deep-sea-stories">GitHub Repository</a></li></ul><p>If you are working on AI-based features with real-time video or audio and need assistance, feel free to reach out to us on <a href="https://discord.com/invite/srB534gj2Y">Discord</a>.</p><p>We are Software Mansion — software development consultants, a team of React Native core contributors, multimedia and AI experts. Drop us a line at <a href="http://projects@swmansion.com/">projects@swmansion.com</a> and let’s find out how we can help you with your project.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a59e08fb18aa" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/voice-ai-how-we-built-a-multi-speaker-ai-agent-using-gemini-a59e08fb18aa">Voice AI: How We Built a Multi-Speaker AI Agent using Gemini</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Agents With LangGraph Part 4/4: How We Built Our Marketing Agent]]></title>
            <link>https://medium.com/swmansion/building-agents-with-langgraph-part-4-4-how-we-built-our-marketing-agent-594a5a42de8b?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/594a5a42de8b</guid>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[langchain-agents]]></category>
            <category><![CDATA[langgraph-tutorial]]></category>
            <category><![CDATA[langgraph-use-cases]]></category>
            <dc:creator><![CDATA[Piotr Zborowski]]></dc:creator>
            <pubDate>Wed, 21 Jan 2026 15:53:05 GMT</pubDate>
            <atom:updated>2026-01-21T16:34:51.293Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RD2MefTJGJssEeMhp6pJjQ.png" /></figure><p>In the <a href="https://medium.com/p/ac4d2ad10bed">previous parts of our series</a>, we’ve explored the key concepts behind building AI agents with LangChain and LangGraph, and we’ve constructed a fully-functional AI chatbot along the way. <strong>Today, we’re diving into a case study from the development of our very own marketing agent.</strong></p><p>At Software Mansion, we specialize in building AI tools. Finding places where people might benefit from them is time-consuming, so we wanted to automate the process with an AI agent identifying opportunities to showcase our solutions. In this article, we’ll tell you how we built the agent that uses a search engine to find websites where our products could be promoted. The code is available <a href="https://github.com/software-mansion-labs/marketing-agent-web">here</a>.</p><p>Our approach relies on a system consisting of three agents: Critic, Selector, and Search. <strong>Assigning non-overlapping tasks to different agents improves token usage efficiency, simplifies history management, and enhances results by keeping each context smaller. Different foundation models can also be used per agent based on task complexity.</strong></p><h3>The Critic agent</h3><p>First, the Critic takes specific website content as input and evaluates if it’s suitable for advertising our products. We use structured output, so the model lists the pros and cons of selecting the website. The process starts by loading a description of the product to advertise, then introducing the Critic’s role via a system prompt, and finally passing the website content for evaluation.</p><p>Since multiple websites are passed to the Critic, we use batch invocation to save time. Our agentic workflows are I/O-bound, spending most of their time waiting for responses from the foundation model API, so running several invocations concurrently helps speed up the process.</p><p>To use batch invocation, we simply need to pass a list of initial states instead of a single one, and we receive a list of results in return:</p><pre>initial_states = [{...}, {...}, ...]<br>responses: list[BaseMessage] = model.batch(initial_states)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/129/1*fPghftV0SREZUCwRCrzxPQ.png" /><figcaption>The Critic workflow</figcaption></figure><h3>The Selector agent</h3><p>Another agent, very similar in structure, is the Selector. At this point, we have a list of candidate websites along with evaluations of their suitability for advertising. The Selector uses these evaluations to decide which website to return.</p><p>First, the product description is loaded, then we define the Selector’s role through a system prompt, and finally, the selection is made. Structured output is used again to ensure the response follows the correct format.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/129/1*0J4EyCZhSZF9_o5eacGISQ.png" /><figcaption>The Selector workflow</figcaption></figure><h3>The Search agent</h3><p>The Search agent is the core of the system, orchestrating the two remaining agents. In our case, it uses a search engine based on DuckDuckGo as a tool.</p><p>Like the other agents, it begins by loading the product description. Then, the foundation model uses a tool to search the web and selects which websites to load based on their titles and summaries. Once loaded, the content is passed to the Critic. Based on the Critic’s feedback and the iteration limit, the model either continues searching (goes back to search) or summarizes the results by calling the Selector.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/188/1*CTSVBpc0ZUq_Fx3BDr_5Ew.png" /><figcaption>The Search workflow</figcaption></figure><h3>The marketing agent in action</h3><p>The main goal of our project was to automate the process of finding marketing opportunities for <a href="https://docs.swmansion.com/react-native-executorch/">React Native ExecuTorch</a>. The prototype was initially built with <a href="https://huggingface.co/docs/smolagents/index">smolagents</a>, but we later migrated to the LangChain environment, which gave us greater control, reduced token usage, and produced more precise results.</p><p>As the project matured, we expanded it to cover other products and built a web UI to give everyone at our company — not just AI developers — easy access to the agentic system.</p><p>Now, users of our marketing agent can conveniently store configurations for multiple products they want to promote. As an example, we’ll show you the setup for our React Native ExecuTorch library:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tx3lYBkc27hMlckYp6gZyQ.png" /></figure><p>First, the user needs to describe the product, the use cases, the audience, and other important information. Next, there is a field where agentic prompts can be edited. Each comes with a description of its role in the system, so that the user knows exactly what to edit without having to understand the workflow of our subagents.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6XVP3WmeOPK2SDVsFTctHg.png" /></figure><p>Configurations are stored in the browser memory, but they can also be imported or exported. Once the configuration is ready, users just need to click “Start Search” and wait for the results. When the search is complete, they get a list of websites for advertisement, along with justifications for choosing them.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_aJKq4XC9mKf-zdHs3ZKlw.png" /></figure><p>That was a quick glimpse into our real-world project, which has saved us a lot of time and is now used in our products. We hope that after reading this series, you’re ready to build efficient agentic systems with LangGraph. Good luck!</p><p>We are Software Mansion — software development consultants, a team of React Native core contributors, multimedia and AI experts. Drop us a line at <a href="http://projects@swmansion.com/">projects@swmansion.com</a> and let’s find out how we can help you with your project.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=594a5a42de8b" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/building-agents-with-langgraph-part-4-4-how-we-built-our-marketing-agent-594a5a42de8b">Building Agents With LangGraph Part 4/4: How We Built Our Marketing Agent</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Huawei x Software Mansion: Bringing React Native Support to HarmonyOS NEXT]]></title>
            <link>https://medium.com/swmansion/huawei-x-software-mansion-bringing-react-native-support-to-harmonyos-next-82e02bd75549?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/82e02bd75549</guid>
            <category><![CDATA[react-native-open-harmony]]></category>
            <category><![CDATA[react-native]]></category>
            <category><![CDATA[huawei-developers]]></category>
            <category><![CDATA[react-native-development]]></category>
            <dc:creator><![CDATA[Przemek Sosna]]></dc:creator>
            <pubDate>Wed, 14 Jan 2026 16:26:47 GMT</pubDate>
            <atom:updated>2026-01-14T16:59:58.232Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xVUbb9x9vFQlpZzC6AQrmw.png" /></figure><p>Several years ago, Huawei, together with industry partners, started working on a brand new operating system. This is how OpenHarmony was born, an open-source competitor to the well-established iOS and Android markets. Similarly to how Google builds Android on top of AOSP, Huawei develops its proprietary HarmonyOS NEXT distribution from the OpenHarmony codebase, which powers Huawei devices.</p><p><strong>We’re happy to share that Software Mansion has been cooperating with Huawei from the start on this project, and we’ve been taking an active part in developing React Native OpenHarmony (RNOH) — a React Native library letting you run apps directly on OpenHarmony devices.</strong></p><h3>Understanding HarmonyOS Next</h3><p>Since HarmonyOS Next is a completely separate operating system, native apps from other platforms aren’t directly compatible with it. However, while it differs from Android and iOS, it still feels familiar and shares many concepts when it comes to interactions and overall user experience.</p><p>The Developer Experience is also quite familiar. You can use <a href="https://developer.huawei.com/consumer/en/arkts/">ArkTS</a> and C++ to write HarmonyOS Next-native applications, similar to using Swift on iOS and Kotlin on Android.</p><p>ArkTS is based on TypeScript, so this means developers don’t need to learn a new technology from scratch to build apps for HarmonyOS Next. To make the development process more comfortable, Huawei has prepared a dedicated IDE for Harmony called <a href="https://developer.huawei.com/consumer/en/deveco-studio/">DevEco Studio</a>. It’s similar to Android Studio, so if you have prior experience with Android native development, the onboarding process should be pretty seamless.</p><h3>Developing React Native Open Harmony (RNOH)</h3><p>As already mentioned, <strong>React Native Open Harmony (RNOH) is a library that makes it possible to run React Native apps on OpenHarmony devices. You can use it by either integrating it into an existing app or starting a new project from scratch.</strong> RNOH is built using React Native’s New Architecture to ensure seamless performance and reliability. By the way, if you haven’t migrated your app yet, it’s the best time to do so, because from React Native 0.82 onwards, only the New Architecture is supported. This means the migration is crucial if you want to benefit from the latest features — you can visit our <a href="https://swmansion.com/react-native-new-architecture">website to learn more about the benefits of migrating</a> and get support.</p><p>For now, RNOH supports React Native 0.77 and 0.72, and we’re currently working on adding support for 0.82 to ensure developers can take advantage of the latest features. However, for legacy use cases, we’ve also created a Fabric-based adaptation for version 0.61 to maintain compatibility with older applications.</p><p>While React Native Open Harmony is still a young project, it has already established its role in the HarmonyOS ecosystem. Many well-known applications have adopted it, including — but not limited to — major social media platforms like YY and Xiaohongshu (Little Red Book), as well as CTrip (the Chinese domestic version of Trip.com).</p><p><strong>Today, more than 27 million devices on the Chinese market are running on HarmonyOS Next. Already, more than 300 000 apps and meta-services are available, and there are more than 10 million registered developers. It’s exciting for us to be part of this ecosystem!</strong></p><h3>How we helped create React Native Open Harmony</h3><p>Our cooperation with Huawei began as a series of proof-of-concept implementations, eventually growing into a long-term collaboration, with our team developing a significant portion of RNOH’s core code.</p><p>Although Fabric is mostly written in C++, integrating it with HarmonyOS Next still posed significant challenges. We had to put considerable effort into bridging the gap between ArkTS and React Native’s codebase, as well as mapping React Native’s core components to their native equivalents on HarmonyOS Next.</p><p>In addition, we’ve built the native OpenHarmony implementation of the TurboModule system from scratch, which is used to access native APIs for functionality like networking and vibrations. Through this work, <strong>we’ve become familiar with HarmonyOS’s runtime environment, component system, UI rendering pipeline, and native development workflow using ArkTS. We’ve learned how to bridge OpenHarmony’s platform-specific conventions with React Native’s cross-platform architecture — knowledge that we continue to apply as we expand RNOH’s capabilities and performance.</strong></p><p>As modern React Native apps rely heavily on a vibrant ecosystem of open source libraries, we’ve also made sure that key libraries are supported on OpenHarmony. Without them, it wouldn’t be a practical and usable platform for React Native apps.</p><p><strong>Our Software Mansion team took on the task of porting several widely used libraries, helping us to further increase our OpenHarmony expertise</strong>. Some of the libraries ported by our team include:</p><ul><li>react-native-reanimated</li><li>react-native-gesture-handler</li><li>react-native-screens</li><li>react-native-webview</li><li>react-native-svg</li><li>react-native-safe-area-context</li></ul><p>At the same time, Huawei worked on porting other important libraries, significantly expanding OpenHarmony’s compatibilities within the broader React Native ecosystem.</p><p>This project has been a rewarding journey for our team, allowing us to deepen our expertise in React Native and its modern Fabric architecture even more.</p><p>Beyond the React Native side, it also allowed us to gain a much deeper understanding of HarmonyOS NEXT itself, strengthening our ability to integrate React Native with diverse native ecosystems.</p><h3>What’s in store for React Native OpenHarmony?</h3><p>Our partnership with Huawei continues as we work on introducing new features, resolving bugs, improving performance, and updating React Native OpenHarmony to stay compatible with the latest versions of React Native.</p><p>If you need help migrating your existing application to HarmonyOS or want to develop a new application that’s compatible with Huawei’s platform, you can always reach out to us at <a href="/service/mailto:projects@swmansion.com">projects@swmansion.com</a>. We’ll be happy to use our first-hand experience to help you build your HarmonyOS app!</p><p>We’re <a href="https://swmansion.com/">Software Mansion</a>: multimedia experts, AI explorers, React Native core contributors, community builders, and software development consultants.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=82e02bd75549" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/huawei-x-software-mansion-bringing-react-native-support-to-harmonyos-next-82e02bd75549">Huawei x Software Mansion: Bringing React Native Support to HarmonyOS NEXT</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building Agents With LangGraph Part 3/4: RAG and Long-Term Memory]]></title>
            <link>https://medium.com/swmansion/building-agents-with-langgraph-part-3-4-rag-and-long-term-memory-ed34d4d96972?source=rss----e003bf129483---4</link>
            <guid isPermaLink="false">https://medium.com/p/ed34d4d96972</guid>
            <category><![CDATA[langgraph-chatbot]]></category>
            <category><![CDATA[langgraph]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[langgraph-tutorial]]></category>
            <dc:creator><![CDATA[Piotr Zborowski]]></dc:creator>
            <pubDate>Mon, 12 Jan 2026 15:02:33 GMT</pubDate>
            <atom:updated>2026-01-21T16:16:03.983Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_ZKBxCuhRA0IljE3iO-otw.png" /></figure><p>In the <a href="https://blog.swmansion.com/building-agents-with-langgraph-part-2-4-adding-tools-a7955432c220">previous part of our series</a>, we’ve added web search and intelligent shutdown to our chatbot to explore tools and structured output.</p><p><strong>In this tutorial, we’re going to enrich our chatbot with two functionalities — Retrieval Augmented Generation and long-term memory. You can find scripts from this part </strong><a href="https://github.com/software-mansion-labs/agents-course/tree/main/Part%203"><strong>here</strong></a><strong>.</strong></p><p>If you’re not familiar with how Retrieval Augmented Generation works, make sure to read <a href="https://blog.swmansion.com/retrieval-augmented-generation-explained-840cbd744c99">our in-depth article on RAG</a>.</p><h3>Adding Retrieval Augmented Generation to our LangGraph chatbot</h3><p>Let’s start by loading the doc(s) — as an example, we’re going to use <a href="https://docs.swmansion.com/react-native-executorch/">the React Native ExecuTorch documentation</a>. The library lets you run AI models natively on edge devices:</p><pre>from langchain_community.document_loaders import WebBaseLoader<br><br>loader = WebBaseLoader(&quot;https://docs.swmansion.com/react-native-executorch/&quot;)<br>docs = loader.load()</pre><p>Then, we need to split the loaded docs into chunks:</p><pre>from langchain_text_splitters import RecursiveCharacterTextSplitter<br><br>text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)<br>all_splits = text_splitter.split_documents(docs)</pre><p>We also need to create a vector store for chunks, which is based on an embedding model. Embeddings don’t have to match those used by the foundational model, since they’re only needed for retrieving text from the vector store. A popular option here is <a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2">all-MiniLM-L6-v2</a>.</p><pre>from langchain_core.vectorstores import InMemoryVectorStore<br>from langchain_huggingface import HuggingFaceEmbeddings<br><br>embeddings = HuggingFaceEmbeddings(model_name=&quot;sentence-transformers/all-MiniLM-L6-v2&quot;)<br>vector_store = InMemoryVectorStore(embeddings)</pre><p>Next, we add the chunks to the vector store:</p><pre>_ = vector_store.add_documents(documents=all_splits)</pre><p>The only thing left is to retrieve the relevant pieces for the user’s prompt. To do that, we update ask_llm. In this example, we just pass the retrieved text directly as context. Alternatively, you could call the LLM separately to generate a summary of that context — feel free to try that approach as an exercise.</p><pre>def ask_llm(state: State) -&gt; State:<br>    user_query = input(&quot;query: &quot;)<br><br>    retrieved_docs = vector_store.similarity_search(user_query)<br>    context = &quot;\n\n&quot;.join([doc.page_content for doc in retrieved_docs])<br>    user_message = HumanMessage(f&quot;Context:\n{context}\n\nUser question:\n{user_query}&quot;)<br><br>    # homework: try to add an intermediate step, where a prompt is created by the LLM based on the query and context, before passing it to the LLM below.<br><br>    answer_message: AIMessage = model_with_search.invoke(<br>        state[&quot;messages&quot;] + [user_message]<br>    )<br><br>    return {<br>        &quot;messages&quot;: [user_message, answer_message],<br>    }<br><br>)</pre><p>Check <a href="https://github.com/software-mansion-labs/agents-series/blob/main/Part%203/7%20-%20chatbot%20with%20RAG.py">our GitHub</a> repository to see what the final code should look like. The parts added in this tutorial are highlighted below:</p><pre>from langchain_core.vectorstores import InMemoryVectorStore<br>from langchain_huggingface import HuggingFaceEmbeddings<br>from langchain_community.document_loaders import WebBaseLoader<br>from langchain_text_splitters import RecursiveCharacterTextSplitter<br><br>embeddings = HuggingFaceEmbeddings(model_name=&quot;sentence-transformers/all-MiniLM-L6-v2&quot;)<br>vector_store = InMemoryVectorStore(embeddings)<br><br>loader = WebBaseLoader(&quot;https://docs.swmansion.com/react-native-executorch/&quot;)<br>docs = loader.load()<br><br>text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)<br>all_splits = text_splitter.split_documents(docs)<br><br>_ = vector_store.add_documents(documents=all_splits)<br><br><br>def ask_llm(state: State) -&gt; State:<br>    user_query = input(&quot;query: &quot;)<br><br>    retrieved_docs = vector_store.similarity_search(user_query)<br>    context = &quot;\n\n&quot;.join([doc.page_content for doc in retrieved_docs])<br>    user_message = HumanMessage(f&quot;Context:\n{context}\n\nUser question:\n{user_query}&quot;)<br><br>    # homework: try to add an intermediate step, where a prompt is created by the LLM based on the query and context, before passing it to the LLM below.<br><br>    answer_message: AIMessage = model_with_search.invoke(<br>        state[&quot;messages&quot;] + [user_message]<br>    )<br><br>    return {<br>        &quot;messages&quot;: [user_message, answer_message],<br>    }<br><br>)</pre><pre>query: Hi<br>answer:  Hello! How can I assist you today?<br>query: what&#39;s react native executorch?<br>answer:  React Native ExecuTorch is an on-device AI and large language model (LLM) toolkit tailored for the React Native ecosystem. It allows developers to run AI models and LLMs locally on mobile devices, leveraging Meta’s ExecuTorch AI framework. Here are some key aspects of React Native ExecuTorch:<br><br>1. **On-Device Model Execution**: It enables AI models and LLMs to operate directly on the device, ensuring that user data remains private and eliminating the need for external API calls.<br><br>2. **Cost-Effective**: By performing computations on-device, it reduces dependency on cloud infrastructure, which can lower server costs and minimize latency.<br><br>3. **Privacy-Focused**: By executing models locally, it ensures that data stays on the device, offering maximum privacy for users.<br><br>4. **Developer-Friendly**: With a declarative API for on-device inference, developers do not need extensive AI expertise to implement these models in their React Native applications.<br><br>5. **Part of PyTorch Edge**: It is built on the ExecuTorch foundation, which is part of the PyTorch Edge ecosystem, enabling efficient AI deployment for cross-platform applications in React Native.<br><br>This toolkit is ideal for developers looking to integrate AI capabilities within their React Native apps without relying on external cloud services.<br>query: Can I see it in action?<br>answer:  To see React Native ExecuTorch in action, you can download and use &quot;Private Mind,&quot; which is an on-device AI chatbot. This application operates entirely offline and showcases the capabilities of React Native ExecuTorch. Here’s how you can interact with it:<br><br>1. **Download and Install**: You can download Private Mind from the App Store or relevant app distribution platforms.<br><br>2. **Features to Explore**:<br>   - **Chat Freely**: Engage in unrestricted conversations with the AI chatbot.<br>   - **Privacy Assurance**: Experience the privacy benefits as all data processing occurs locally on your device.<br>   - **Model Testing and Benchmarking**: Browse, test, and evaluate local language models supported by React Native ExecuTorch.<br>   - **Customization**: Customize AI assistants to fit your workflow and personal style.<br><br>This application serves as a practical demonstration of how React Native ExecuTorch enables the deployment of AI models on mobile devices. It illustrates the privacy, cost-effectiveness, and variety of models you can leverage through this toolkit.</pre><h3>Adding long-term memory</h3><p>So far, you’ve seen how to add short-term memory — the message history within a single conversation. There’s also long-term memory, which persists across workflow invocations. In this article, we’ll focus on a simple example of resuming a conversation, though the topic is much broader. You can read more about it <a href="https://docs.langchain.com/oss/python/langchain/long-term-memory">here</a>.</p><p>First, we need to set up a checkpointer and a store, which we’ll pass as parameters when compiling the graph:</p><pre>from langgraph.checkpoint.memory import InMemorySaver<br>from langgraph.store.memory import InMemoryStore<br><br>checkpointer = InMemorySaver()  # used to store individual states<br>store = InMemoryStore()  # used to store states acrross threads<br>workflow = graph.compile(checkpointer=checkpointer, store=store)</pre><p>To be able to resume the conversation, you need to identify it with thread_id:</p><pre>config = {&quot;recursion_limit&quot;: 100, &quot;configurable&quot;: {&quot;thread_id&quot;: &quot;default-session&quot;}}</pre><p>Now, you pass the config when invoking the workflow, and you can resume thanks to get_state:</p><pre># we start a thread<br>workflow.invoke(<br>    {&quot;iteration&quot;: 0},<br>    config=config,<br>)<br><br># now we resume the conversation (thread) in another invocation<br>workflow.invoke(<br>    workflow.get_state(config),  # last state of the agent<br>    config=config,<br>)<br><br># homework: modify the agent so that it stores the state externally and loads the summarization or truncated history of your previous discussions when you run the script and resumes from there.</pre><p>Your script should look like <a href="https://github.com/software-mansion-labs/agents-course/blob/main/Part%203/8%20-%20long-term%20memory%20across%20threads.py">this</a>, with the part added in this tutorial shown below:</p><pre>from langgraph.checkpoint.memory import InMemorySaver<br>from langgraph.store.memory import InMemoryStore<br><br>checkpointer = InMemorySaver()<br>store = InMemoryStore()<br>workflow = graph.compile(checkpointer=checkpointer, store=store)<br><br>config = {&quot;recursion_limit&quot;: 100, &quot;configurable&quot;: {&quot;thread_id&quot;: &quot;default-session&quot;}}<br><br>workflow.invoke(<br>    {&quot;iteration&quot;: 0},<br>    config=config,<br>)<br><br>workflow.invoke(<br>    workflow.get_state(config),<br>    config=config,<br>)</pre><p><strong>Congrats! You’ve finished building your agentic AI chatbot in LangGraph</strong>.</p><h3>What’s next in the series?</h3><p>In part 4, we’re diving into a real-world agentic system we built to identify marketing opportunities. This case study offers a hands-on look at how a LangGraph-based system can help your team in practice. So, be sure to check it out <a href="https://blog.swmansion.com/building-agents-with-langgraph-part-4-4-how-we-built-our-marketing-agent-594a5a42de8b">here.</a></p><p>We are Software Mansion — software development consultants, a team of React Native core contributors, multimedia and AI experts. Drop us a line at projects@swmansion.com and let’s find out how we can help you with your project.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ed34d4d96972" width="1" height="1" alt=""><hr><p><a href="https://medium.com/swmansion/building-agents-with-langgraph-part-3-4-rag-and-long-term-memory-ed34d4d96972">Building Agents With LangGraph Part 3/4: RAG and Long-Term Memory</a> was originally published in <a href="https://medium.com/swmansion">Software Mansion</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>