# Jing Su - Full Content > Jing Su's blog sharing experiences about living in the UK, career, personal life, and books. This file contains the full markdown content of all posts on jinghuangsu.com. --- # Writing --- ## How to Transfer Money from Taiwan to the UK in 2025? - **URL:** https://www.jinghuangsu.com/writing/uk/how-to-transfer-from-taiwan-to-uk - **Date:** 2025-07-29 - **Category:** uk - **Tags:** UK, UK working holiday, international transfer ## Introduction Recently, while preparing to move to the UK, I thought it would be a good idea to open accounts with some well-reviewed online banks like [Monzo](https://monzo.com/) and [Revolut](https://www.revolut.com/). However, after downloading the apps and attempting to apply, I realized it wasn't as straightforward as I thought. Monzo was a dead end from the start, requiring a UK local address for the application, which I didn't have. Revolut seemed simpler, only needing identification and a visa number, but when I tried to register with my Taiwanese phone number, the verification code never arrived, leaving me unable to proceed. That's when I discovered [Wise](https://wise.com/), a transfer platform that allows you to open a UK account from Taiwan. You can transfer money from a Taiwanese bank to Wise, and once you're in the UK and have opened a Monzo or Revolut account, you can transfer the money there. ## Applying for a Wise Account For a detailed guide on how to apply for a Wise account, you can refer to the official documentation [Wise Account Opening Tutorial](https://wise.com/zh-hk/blog/open-wise-account). After successfully opening an account, you can set up a GBP account, but you'll need to transfer Β£20 first to receive your Sort Code and Account Number. ## Transferring Money from a Taiwanese Bank to Wise ### TL;DR | Bank Name | Required Documents | Process Summary | Transfer Speed | Total Fees | | :----------------- | :----------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------- | :------------- | | Cathay United Bank | ID, Seal | Fill out the [Foreign Exchange Transfer Application Form](https://www.globalmyb2b.com/GEBANK/images/download/tai/t07.pdf), takes about two working days | About 1 day | Around NT\$800 | | Taishin Bank | ID, Seal | Fill out the [Foreign Currency Transfer Application Form](https://www.taishinbank.com.tw/TSB/export/sites/TSB/files/digital/Online.pdf), takes about two working days | Within 2 hours | Around NT\$560 | To make an international transfer from a Taiwanese bank, you usually need to set up the overseas recipient bank account as a designated account. Most banks now offer online services for setting up designated accounts, where you can fill in the recipient's details online and complete the binding through video verification. However, not all banks' online binding services support accounts in all currencies. For example, with Cathay United Bank and Taishin Bank, if you want to add a GBP recipient account, you still need to visit a branch in person. Also, the following information is based on my personal experience. The actual details you need to fill in should be based on what's shown in the Wise App! ### Cathay United Bank **What to bring?** ID and seal. **Process** The staff will ask you to fill out the [Foreign Exchange Transfer Application Form](https://www.globalmyb2b.com/GEBANK/images/download/tai/t07.pdf). The information I filled in was basically the account details from the Wise App: In addition to the fields in the screenshot, you'll also need to sign and date the application form. The staff were very patient, and I ended up rewriting the form three times πŸ˜‚. After completing the process, the staff informed me that the account would appear in the online banking's designated accounts section in about two working days. **Transfer and Fees** After waiting two working days, the Wise account finally appeared in the online banking's "Designated Accounts" section πŸŽ‰. All that was left was to confirm the recipient bank's information was correct before proceeding with the international transfer. Transfers made during the bank's business hours (weekdays 09:00 ~ 15:30) would be received the next day! As for fees, Cathay United Bank charges NT\$300, and Wise charges a service fee of Β£2.16. Additionally, intermediary banks charge an extra Β£10. So, regardless of the transfer amount, the total fee per transfer is approximately NT\$800 (300 NTD + 12.16 GBP). ### Taishin Bank **What to bring?** ID and seal. **Process** The process is similar to Cathay Bank, involving filling out the [Foreign Currency Transfer Application Form](https://www.taishinbank.com.tw/TSB/export/sites/TSB/files/digital/Online.pdf). After completion, it also takes about two working days for the designated account to appear in Taishin's online banking. **Transfer and Fees** Once the details are confirmed, you can proceed with the transfer. Unlike Cathay United Bank, transfers with Taishin Bank can sometimes be received in less than two hours, with the latest being within a day. The fees are Β£11.01 charged by Taishin and a Β£2.16 service fee by Wise. The total cost is slightly less than Cathay, and the transfer speed is much faster. ## Transferring from Wise to Monzo After finding a rental in the UK, you can apply for a Monzo account. The application process requires identification (such as a passport) and some basic information like address and salary. After completing the application, you'll receive a physical Debit Card in about 1-2 days, and your account will be ready to use. Transferring from Wise to Monzo is also very intuitive. On the homepage, click "Add Money," select "Wise," enter the amount to transfer, and you'll be redirected to Wise's transfer page. Confirm the details, and the transfer will be successful. The user experience with Monzo is excellent. It's rare to find online banking software with such a rich interface and features in Taiwan, and I highly recommend it! --- <>

Personal Recommendations

{/* Space for Monzo icon */}
Monzo

Monzo

Primary Use

I've been very satisfied with Monzo's intuitive operation and comprehensive features. Besides having a physical card, it also supports shared accounts, which is a big plus for users like me with partners! Also, using my referral link, you'll get a Β£20 reward after making any purchase within 30 days of opening your card!

Referral Link
{/* Space for Wise icon */}
Wise

Wise

For Transfers

If you want to transfer money to the UK from Taiwan, Wise is a great option. Besides serving as an intermediary account, if you need to pay rent in advance upon arriving in the UK, you can also transfer the money through Wise.

Referral Link
--- ## Logs of a Software Engineer | EP.3: Five-Year Anniversary - **URL:** https://www.jinghuangsu.com/writing/career/my-swe-career-ep3 - **Date:** 2025-07-03 - **Category:** career - **Tags:** career, software engineer, frontend engineer ## Preface This year marks my fifth year as a software engineer, and coincidentally, it's also the year I embark on a journey to seek employment abroad. While I'm uncertain about securing my ideal job in the UK, reflecting on the past five years, I've learned and experienced a great deal as a software engineer. This journey has been both miraculous and fortunate, perhaps worth documenting properly. Five years is neither long nor short. Looking back at the [Logs of a Software Engineer | EP.1: From Zero to One](/writing/career/my-swe-career-ep1) and [Logs of a Software Engineer | EP.2: JKOPay](/writing/career/my-swe-career-ep2) I wrote back then, I feel that I grew very quickly when I first entered the workplace. Every day was filled with endless new technologies and knowledge, making life both fulfilling and challenging. ## Stability Over Novelty After two years at Jkopay, I joined an American company that aligned with my job-seeking goals at the timeβ€”good compensation, opportunities for international collaboration, and a great team culture. However, my pursuit of new technologies gradually faded over these three years. Initially, I thought I had become lazy, leading to a dulling of excitement towards new technologies. To reignite that passion, I started writing a [Frontend Newsletter](https://jing-tech.substack.com) in 2024, hoping to maintain my sensitivity to new technologies by organizing trends and knowledge. But I slowly realized that I hadn't lost my passion for software technology; instead, I began to value the stability of systems and frameworks more. When I first joined this American company, I noticed the team wasn't using popular Meta Frameworks like [Next.js](https://nextjs.org/) or [Remix](https://remix.run/), but a self-developed framework. At the time, I was somewhat disappointed, especially since Next.js was at its peak, full of buzz, and I had no hands-on experience with it. But three years later, I gradually saw the value of this self-developed framework. Although it uses somewhat outdated technology and the development experience is nothing to boast about, it stably supports various needs across the company's service projects. Whether it's vertical scaling or further splitting into a micro-frontend architecture, it can handle it. This architectural flexibility gave me a new understanding of frontend engineering. If you really want to learn new technologies, you might as well practice and explore by writing blogs or managing your own Side Projects, which was why I participated in the [Ironman Contest](https://ithelp.ithome.com.tw/users/20140431/ironman/6604) and wrote the [Newsletter](https://jing-tech.substack.com). In work, what's often more important isn't using the newest technology but writing stable and efficient code. Unless a new technology or framework can clearly offer value beyond stability, hastily introducing it in team development might increase maintenance costs and risks. ## Staying Open Initially, I was very unfamiliar with backend development. I remember during every meeting when assigning tasks for the next Sprint, I would instinctively avoid backend tasks, preferring to choose frontend projects I was familiar with. Whenever I saw a task that involved backend work, a voice in my head would say, "I'm not qualified; I'll mess it up." These voices were actually my own self-doubt. Not long after, the company underwent several reorganizations, leaving some teams with backend needs but insufficient backend engineers. That's when I started to actively engage in backend development. Some projects were really confusing at first, but as long as I dared to ask and spent time on them, I gradually became familiar with them. Even though I'm not a backend expert, at least I no longer avoid it like before, treating it merely as a problem to be solved. ## Feature Delivery At my previous company, I had never encountered A/B Testing. The development process was relatively straightforward; all features would go directly into development and launch as long as the project manager deemed them okay. It wasn't until I joined my current company that I truly understood the process and value of A/B Testing. Since my team is responsible for consumer-facing products, the two most important things after feature delivery are usually: Error Monitoring and observing the performance of A/B Testing. The success of the latter is typically judged based on the Success Metrics defined by the project manager in the One Pager. - If the feature's goal is to increase GMV, then we need to observe whether the Treatment group's revenue performance after the feature launch is better than the Control group's. If it's significantly higher, it means the feature's release is positive. - If the feature is simply a service migration, A/B Testing can serve as a Feature Flag, where theoretically, the Treatment and Control groups' performances should be nearly identical. If one group's performance is abnormal and statistically significant, it indicates something's amiss, requiring further investigation. - If the feature's goal is to increase the Click-Through Rate (CTR) of a certain block, it needs to be verified with other tracking tools, such as embedding event tracking in the code, querying the database, or observing click ratios and usage behaviors through third-party analytics platforms like Amplitude. After three years of this baptism, I've realized that the real difficulty in developing new features often isn't the development itself but how to effectively monitor and verify after feature delivery. When monitoring results don't meet expectations, how do we find the problem and make improvements? This work is no longer a solo effort by engineers but requires close collaboration with the team's data analysts and project managers to find clues from the data, observe users' behaviors with the new feature, and even rethink and redesign the feature. The entire verification and adjustment process can sometimes take several times longer than the development itself, but it's also what determines whether the product can create value for the company. ## Scrum Master The role of Scrum Master was something I only learned about after joining this company. The core task of this role is to assist the team in smoothly executing the agile development process. After a year with the company, I had the opportunity to take on this role, which also allowed me to have more exchanges and collaborations with project managers and supervisors. From planning the backlog for each Sprint, understanding each team member's Bandwidth, discussing the next phase's task details and required resources with supervisors and product managers, to assigning tasks to team members, and even the retrospective and adjustments after the Sprint, these became part of my daily routine outside of development. At the same time, ensuring that the project's Stakeholders are clearly aware of the team's current progress and how to reasonably insert urgent tasks into the Sprint requires thoughtful design and communication. For example, I would encourage project owners to synchronize their current progress with the relevant project group weekly, keeping supervisors or project managers on the same page. And when there's an emergency task, first list all potential items for insertion, clarify which are truly "urgent and important," and then, after coordinating with supervisors and product managers, carefully insert them into the current Sprint. Through such mechanisms, we can meet business needs while maintaining the stability of the team's development rhythm, avoiding situations where "insertions get out of control" during Sprint retrospectives. However, for me, the real challenge isn't these processes and planning but the human aspect. More difficult than assigning tasks is: How to ensure each team member clearly knows the projects they'll be responsible for next? And how to discuss with supervisors whether any members, based on career development considerations, wish to try different types of projects? Taking "letting members know what the next project is" as an example, the challenge often comes from information opacity. Most of the time, project managers may not be able to provide the demand planning for the next Sprint in advance. Apart from divining from the quarterly roadmap, I usually adopt two strategies: 1. Increase demand forecasting: Proactively coordinate with project managers to provide slightly more tasks than the current Sprint requires, giving the team leeway to predict future directions. 2. Proactively create tasks: Discuss with supervisors about internal processes or technical debts that the team can improve. When members finish their projects, they can optimize the team's system while waiting for the next task. In short, the role of Scrum Master is quite interesting and can also enhance one's communication skills. During this time, I've learned a lot through this position. ## Other Thoughts - **Ownership at Work:** Over the years, I've gained a deeper understanding of "work ownership." Regardless of rank, becoming the Go-to Person for a certain project or system is the best way to increase one's influence within the team. - **Helping Others Solve Problems:** This process itself is also a learning opportunity. Often, while clarifying others' problems, I gain a deeper understanding of certain knowledge points. It's not just collaboration but mutual growth. - **Deliberately Challenging Oneself, Doing What Feels Difficult:** Growth often comes from stepping out of one's comfort zone. When you start exploring unfamiliar areas, what you learn often exceeds expectations. Making oneself a Yes Man who says, "I can try," can accelerate learning speed and reduce fear of the unknown, replacing it with curiosity. - **Treating Projects Like One's Own Children:** Whenever responsible for a new feature or project, try to treat it like caring for a child. Besides self-testing, also add Feature Flags so that if something goes wrong, you can respond immediately. After the feature goes live, conduct real-time monitoring to ensure everything works as expected. ## Conclusion Writing down these fragmented thoughts is also a way to organize the journey of these three years. If there are any new insights in the future, I'll add them gradually! Looking at the rapid progress of AI, sometimes I wonder if the profession of software engineer will become like the textile workers during the Industrial Revolution. After all, the current top LLM models can write code several times faster than the average person, with quality that's not bad or even better, and the top models we use now will surely be the worst in the future πŸ˜‚. If this hypothesis holds, I hope this article isn't the last episode of γ€ŠLogs of a Software Engineer》. Even if roles change in the future, I still look forward to using AI to create a product I can truly be proud of in the AI era. --- ## Why I Choose Linear as My Personal Project Management Tool? - **URL:** https://www.jinghuangsu.com/writing/productivity/solo-project-management-with-linear - **Date:** 2025-06-27 - **Category:** productivity - **Tags:** productivity, Personal Project Management, Linear [Linear](https://linear.app) is a project management and issue tracking tool specifically designed for software teams. The market is flooded with project management tools, such as Jira, often criticized for its complexity, or Asana, which is comprehensive but somewhat bulky. In comparison, Linear is faster, more lightweight, and most importantly, boasts an exceptionally well-designed user interface that is not only clean but also packed with shortcuts. ## Why Choose Linear? If there's a simple and effective software that can help me take notes, manage personal projects, or track learning progress and reflections, I'd be more than willing to pay for it. Linear is exactly that kind of tool. Before Linear, I used [Jira](https://www.atlassian.com/software/jira) and [Notion](https://www.notion.com/) for project management. Notion offers great flexibility, but such high flexibility often comes with a steep learning curve. Additionally, its annual fee is \$216 (for the business plan), and its AI isn't as advanced as top-tier models. Jira, on the other hand, is feature-rich but suffers from an outdated and poor user experience, feeling more like a product from a bygone era. In contrast, Linear not only supports multiple platforms but is also one of the few tools built on a Local-First architecture with a powerful Sync Engine, a technology well-regarded in the software industry for its seamless user experience. For AI functionalities, I integrate Claude Pro through Linear MCP. Linear\'s annual fee is \$96, and with Claude Pro\'s annual fee of \$179, the total comes to \$275. Although slightly more expensive than Notion overall, the combination of a top-tier model with Linear's outstanding user experience made me a loyal user from the first try. ## Managing Personal Projects Having previously served as a Scrum Master at a company, I gained some understanding of agile project management, which I've simplified and incorporated into my personal management system. My agile project management process is roughly divided into planning, execution, and review phases, with each project cycle set to 2 weeks (termed as Sprint or Cycle). ### Planning Phase During this phase, I allocate time (termed as Sprint Grooming) to list all pending tasks, estimating the time and priority for each. This step is crucial as the volume of tasks affects the execution phase's effectiveness. It's advisable not to assign too many tasks at once, basing estimates on past experiences. For example, if only 50% ~ 70% of tasks were completed in the past 3 planning cycles, it's wise to reduce the task load, aiming for 80% ~ 90% completion in each cycle. ### Execution Phase During the execution phase, I plan the next day's tasks the night before and review the day's task completion. However, post-work learning time can be limited, so it's important to be realistic. A key principle is ensuring 80% ~ 90% task completion in each planning cycle. Linear also provides weekly reminders on project completion rates, offering a moment for brief reflection or self-encouragement πŸ˜†. ### Review Phase Finally, at the end of each planning cycle, I set aside time for review. This includes assessing the cycle's completion rate, mood during execution, and overall reflections. It's also a time to consider improvements for the next cycle. A common issue I face is overloading tasks, leading to unnecessary pressure and counterproductivity. ## Integrating Linear with Claude Pro In May this year, Linear officially [introduced MCP functionality](https://linear.app/changelog/2025-05-01-mcp), enabling users to manipulate platform data via AI (e.g., Claude Pro). This release has made many tasks more convenient than ever. Below, I share some use cases of integrating Linear with Claude Pro. ### Batch Task Creation Recently, I wanted to delve into AI-related knowledge and decided to complete Professor Hung-Yi Lee's [AI course](https://speech.ee.ntu.edu.tw/~hylee/ml/2025-spring.php) at NTU, tracking my progress via Linear. Without Claude Pro's assistance, I'd need to manually copy and paste each lecture's content into Linear and establish dependencies between lectures and assignments. For instance, some assignments require completing certain lectures first, a process that would take at least an hour of copying and pasting. Now, I simply right-click the course page to "View Page Resource" for the HTML content (providing a link fails, so I use the page's HTML content directly), then prompt Claude Pro to create tasks in Linear based on each lecture's content and establish dependencies between lectures and assignments. ### Note-Taking I recently wrote an article on [AI + Socratic x Software Engineering Learning](https://jinghuangsu.com/zh-hant/writing/ai/scoratic-method), mentioning how I use the Socratic method with AI to practice solving LeetCode problems. After each dialogue, I ask Claude Pro to summarize the learning notes, which I then record in Linear, adding a note-taking functionality to my project management tool. ## Conclusion Although most project management tools are designed for team collaboration, about two years ago, I started wanting to manage my life more systematically for a more structured daily routine. It then occurred to me to apply team management methods learned at work to my personal life, which surprisingly worked well. Linear's product design and user experience are truly impeccable, and I highly recommend it to everyone. --- ## AI + Socratic x Software Engineering Learning - **URL:** https://www.jinghuangsu.com/writing/ai/scoratic-method - **Date:** 2025-06-25 - **Category:** ai - **Tags:** AI, learning methods, productivity, software engineering learning At the end of 2022, with the launch of GPT-3.5 by OpenAI, the era of AI officially began. Today, leading AI labs are competing to build the most powerful language models. The capabilities of large language models (LLMs) continue to evolve over time, significantly boosting productivity across various fields. In this article, I will share how I use the Socratic Method of questioning, aided by AI, to learn software engineering concepts. ## What is the Socratic Learning Method? The Socratic Learning Method involves guiding someone through a series of questions and answers to stimulate critical thinking and refine arguments, ultimately helping them form their own viewpoints. This method originates from the ancient Greek philosopher Socrates, who never forced his opinions on others. Instead, he used a series of questions to help others identify contradictions in their arguments, encouraging them to refine and analyze their thoughts deeply to form their own conclusions. ## Why Use the Socratic Learning Method? ### Common Challenges As a software engineer, whether working on projects, learning new technologies after hours, or preparing for interviews, most problems can find similar solutions online, sometimes even allowing for copy-pasting. From my experience, to meet deadlines, I've often used pre-built solutions or, when practicing coding problems, quickly glanced at optimal solutions from experts before attempting to understand and replicate them. While this approach saves time, have you ever felt, like me, that after solving a problem, a voice inside asks, "Cool! But how exactly does this work?" However, with the next project or task arriving, this question gets shelved. If someone then asks, "Why did you write it this way?" I might offer superficial reasons but often can't explain the underlying principles. ### Past Learning Methods #### Packages When I wanted to delve deeper into the underlying principles of a technology or package, my approach was roughly as follows: 1. Read the official documentation to understand the basic usage of the API; 2. Search for simplified implementations of the package, usually via GitHub keyword searches or in the [build-your-own-x](https://github.com/codecrafters-io/build-your-own-x) repo; 3. Attempt to understand the original or mini-version's code, which often takes the longest time and presents challenges like grasping the overall architecture, API entry points, core logic, and dependencies. The third step is usually the most time-consuming and prone to getting stuck. Facing large packages, an API might involve several modules and dozens of files, making it easy to get lost in understanding the architecture rather than the core logic. #### Coding Problems For me, solving coding problems often feels unrewarding. Each problem requires considerable time to think through, and my best solutions often turn out to be brute-force compared to experts'. Seeing their solutions, I'm often amazed, "Wow, you can write it like this!" and then try to understand and replicate them. Now, with AI, inputting a problem often yields more elegant and efficient solutions than mine, making the process even more frustrating. Despite this, many companies still use LeetCode for interviews, making it a necessary part of learning. The learning effect from the above methods isn't great for me, often stopping at "I understand this code" without internalizing it as my own solution. Solving problems often becomes "solved" rather than "learned." ## The Socratic Learning Method? Here's how I apply the Socratic learning method to coding problems, using them as an example. Whether for interviews or personal problem-solving, the steps are roughly the same: 1. Read the problem to understand its requirements; 2. Think through the solution approach, draft pseudocode, and discuss the time complexity with the interviewer; 3. If optimization is possible, continue discussing until the interviewer is satisfied; 4. Start coding, ensuring you and the interviewer are on the same page; 5. Finally, submit and check if it passes. When conversing with AI, we should aim to follow a similar process. Unlike an interviewer, AI can offer more guidance or hints, especially helpful for unfamiliar problems. #### How to Use? I start by creating a project in Claude and pre-writing the following prompt in the Project Instruction: ```markdown As an expert in algorithms and LeetCode problems, I’m currently working on a LeetCode question. I’ll provide you with the question. Here’s what I’d like you to do: - Help me solve the problem in an optimized way β€” from zero to hero β€” with clear explanations at each step. - Don’t give me the final answer directly. Instead, provide me with a solution skeleton. - Use Socratic questioning with me β€” one question at a time. I don’t want to jump straight to the answer. I value the process of communication and discovery. - Start with a solution in JavaScript Please focus the conversation on my output and thought process. If I get stuck at any point, feel free to give me hints and explain the concepts in a simple and easy-to-understand way, treat me like a high school student. ``` By inputting the problem into the project, I can start a dialogue with AI. Throughout, like Socrates, it continuously poses questions, prompting me to think through solutions, leading to many aha! moments in this back-and-forth. If your note-taking software supports MCP interfaces, like Linear, you can also ask AI to organize the conversation into notes for easier review of where you got stuck. ```markdown Could you write a comprehensive article and add it to the Linear app ticket XXX that includes the following sections: 1. **The Question** - A clear restatement of the original problem. 2. **Core Concepts Discussed** - The main ideas and techniques we mentioned in our conversation (e.g., DFS, backtracking, etc.). 3. **Our Thinking Process and Solution Evolution** - A breakdown of how we approached the problem step by step. - Include both the original and improved solutions. - Clearly state the time and space complexity of each approach. 4. **Visual Representation** - A conceptual or visual explanation showing the gap between the brute-force and optimized solution. 5. **Review and Learning Summary** - A quick reference section to help me revisit the topic in the future. - Highlight any pain points or areas I found confusing during our discussion. - Include a clean and reusable code template. 6. **Reflections and Similar LeetCode Problems** - Your final thoughts on this question. - List related problems from LeetCode (search if needed) to help reinforce and extend my learning. ``` The same approach applies to learning packages, by integrating GitHub and using clear keywords (Socratic Method) for similar effectiveness! ## Conclusion "Socratic questioning" is a learning method I encountered through a video by Sal Khan, founder of Khan Academy. Initially trying this questioning style with Claude Sonnet 3.5 already provided a great experience. With the evolution of Claude Pro, including more powerful model capabilities, GitHub integration, and MCP interface support, Claude now offers a nearly seamless learning experience. --- ## 2025 UK Working Holiday Visa Guide - **URL:** https://www.jinghuangsu.com/writing/uk/youth-mobility-scheme-visa-application - **Date:** 2025-06-24 - **Category:** uk - **Tags:** uk, visa, personal In July 2024, I was fortunate enough to win the UK Working Holiday Visa lottery. While the memory is still fresh, I want to document the entire application process. This article will mainly cover the application steps, including document preparation, the application process, and pre-departure preparations. I hope this can be of help to those who are planning to apply. ## Background ### Eligibility - Aged between 18 and 30 (must not have turned 31 at the time of application) - Hold a passport from the Republic of China (Taiwan) - Have proof of funds of at least Β£2,530 and have not previously applied for or received this visa. ### Application Period There are two lottery rounds each year, in January and July. January offers 800 spots, while July offers 200. ### Application Fees Due to recent increases in the IHS (Immigration Health Surcharge) and application fees, here are the actual costs I incurred in 2024 for reference. However, the exact amount may vary depending on when you apply. - Application fee: \$406 USD - Immigration Health Surcharge (IHS): \$2,113.86 USD - VFS Global Visa Service Fee: NT\$3,343 At the time, the exchange rate was approximately 1 USD to 32.5 NT, totaling around NT\$84,000. Exchange rate fluctuations can affect the total cost, so it might be cheaper at current rates. ### Lottery Odds The odds of winning the lottery vary. It took me three tries to win, but I've seen people lucky enough to win on their first attempt. ## Application Process ### Before the Lottery Remember to regularly check the [British Office Taipei's Facebook page](https://www.facebook.com/britishofficetaipei/). They usually announce the lottery dates and related information, such as how to register and required details, about a month before the lottery. ### Registering for the Lottery The lottery registration process is quite straightforward. Just follow the official announcement and send the specified information to the provided email address. You should receive a confirmation email after sending, indicating successful registration. However, there are instances where the confirmation email isn't received after sending the registration email. This happened to me during my first lottery attempt. I sent the email right at the registration time but didn't receive a confirmation, which was nerve-wracking. I ended up resending nearly 10 emails before finally getting a confirmation. It's unclear if sending multiple emails affected my chances (the official site states to send only once), but I didn't win that time 😭. For subsequent lotteries, I changed my strategy and waited half a day or a day before sending, and didn't encounter the issue again (or maybe I was just lucky). For the lottery, consider sending your registration email a bit later, not necessarily right at the start. } className="mt-2" /> ### Lottery Results and Application Based on my three lottery experiences, results usually come out a bit earlier than the official announcement. You might want to start checking your email a few days in advance! As for the application process, there's plenty of information shared by predecessors online. I referred to this article πŸ‘‰ [2025 UK Working Holiday | YMS Visa Application Process (Including Timeline)](https://vocus.cc/article/65a4074efd89780001e2cdad?utm_source=jinghuangsu.com). Here, I'll briefly note the parts I found confusing: - **Bank Proof of Funds (Need to prove the account has the equivalent of Β£2,530, with the amount remaining for at least 28 consecutive days)**: Since Taishin Bank doesn't provide account transaction history services, I applied in person for a **paper bank balance certificate plus an English version of the transaction history** (Note: Taishin's certificate is bilingual, so no need to specify an English version). After logging into Taishin's online banking, you can switch the interface to English at the top header. Select the account you want to export the history from, click "Transaction History," and you can download the last three months' history (click "Print" to save as PDF). I didn't use a translation service for this. } className="mt-4" /> - **"Expected Entry Date"**: When applying for the visa, you'll fill in an "Expected Entry Date." It's advisable not to set a date too far in the future. The safest approach is to set it 180 days from the application date. For example, if you submit your application on September 16, 2024, setting the entry date as March 15, 2025, would be safest. If you set a date beyond 180 days, you might still get the date you requested if you're lucky, but there's also a chance you'll be asked to resubmit the date or have it randomly adjusted to an unexpected time. I received the approval notice from UK Visas and Immigration within about a week after completing my application at VFS, which was quite fast. The actual visa entry date matched the expected entry date I had applied for. ### Visa Extension (Vignette Transfer) In May of this year, due to personal reasons, I couldn't travel to the UK within the entry period of my original vignette. My vignette's expiry date was June 9, 2025, so in mid-May, I reapplied for a new vignette, going through the application process again. For detailed steps and required documents, refer to this article πŸ‘‰ [ο½›UK}2020 UK Working Holiday – Self-funded Visa Extension Application (Vignette Transfer) (Occasionally Updated)](https://wanderingwithalice.com/2021/02/24/%EF%BD%9B%E8%8B%B1%E5%9C%8B%EF%BD%9D2020%E8%8B%B1%E5%9C%8B%E6%89%93%E5%B7%A5%E5%BA%A6%E5%81%87%EF%BC%8D%E8%87%AA%E8%B2%BB%E5%BB%B6%E7%B0%BD%E7%94%B3%E8%AB%8B%EF%BC%88vignette-transfer%EF%BC%89/?utm_source=jinghuangsu.com). Here are some common questions I encountered: - **Will the Visa Validity Period Be Shortened?** The VFS staff said it really depends on luck. Some people get a full two-year visa again, but I wasn't so lucky. Although I successfully applied for a vignette extension, the visa's expiry date remained unchanged, meaning I lost three months. - **Can the Vignette Extension Application Be Rejected?** I also looked up a lot of information and was quite worried that UKVI wouldn't approve it. However, based on actual experience, as long as the reason for extension is clearly stated, it's usually approved. - **Can You Apply Early?** Yes. My vignette was due to expire in June, but I submitted the extension application in mid-May and successfully received a new vignette. (Note: Each vignette is only valid for three months, so it's recommended to apply within "three months before the expected entry date" to avoid wasting money on repeated applications.) - **How Long Does the Vignette Extension Process Take?** From submission to receiving the new vignette, it took about a week. ### Vignette Extension Fees - Application fee: \$214 USD - VFS Global Visa Service Fee: NT\$3,450 This added over NT\$10,000 to the cost πŸ’Έ ## Timeline | Date | Event | | ---------------- | ---------------------------------------------- | | 2024/07/23–07/25 | YMS Application Lottery Open | | 2024/07/23 | Sent Lottery Email | | 2024/07/25 | Received Lottery Winning Notice | | 2024/09/16 | Submitted Visa Application at VFS | | 2024/09/22 | Received Visa Approval Notice | | 2025/05/05 | Applied for Vignette Transfer (Extension) | | 2025/05/16 | Submitted Vignette Transfer Application at VFS | | 2025/05/27 | Received Vignette Transfer Approval Notice | ## Conclusion Overall, the UK Working Holiday Visa application process shouldn't be too difficult. I hope this helps those planning to apply and gives an idea of the potential costs. The visa itself is a significant expense, and once in the UK, there are additional costs like accommodation and living expenses to consider. --- ## #0 Why This Website Was Created - **URL:** https://www.jinghuangsu.com/writing/musings/about-jinghuangsu-blog - **Date:** 2025-06-20 - **Category:** musings - **Tags:** musings, blogging, personal I'm Jing, currently a software engineer and the author of [jing-tech.me](https://www.jing-tech.me), planning to move to London, UK, in the middle of the year to start my working holiday life. Before this website was established, I mainly wrote technical articles. Strangely enough, there's always a time each year when I get the itch to revamp my blog. However, this time it's not about revamping jing-tech.me but creating a brand new website, hoping to share more diverse content. ### What Will Be Shared? Currently, there's no specific plan, but coincidentally, I have the opportunity to go on a working holiday in the UK this year. Naturally, I'll share some experiences from the working holiday and life in the UK. Additionally, I'm quite fond of reading books on personal growth, investment, and productivity. However, I tend to forget what I've read, so I thought of recording some of the insights and experimenting with them to verify their feasibility (and to break my bad habits). ### The Website's Structure? The overall structure of the website is as you see on the homepage. Below is a brief introduction to the content of each page. #### [Blog Posts](/writing) As mentioned above, I'll share some experiences and observations from the UK, along with some personal experiments and book reviews. #### [Feeds](/feeds) Random thoughts or viewpoints will be posted here. However, there won't be too much content since I'm not someone who's good at commenting, so the content here might be more casual. #### [Bookmarks](/bookmarks) I've always had the habit of bookmarking interesting websites or tools. If a tool proves to be really useful, I might even become a subscriber. The bookmarks page will share some websites I find valuable. #### [Newsletter](https://jinghuangsu.substack.com/) Last year, while managing the technical blog, I also ran a [newsletter](https://jingtech.substack.com/) that claimed to be weekly. But as I wrote, I felt more like I was just regurgitating information, converting what I saw into my own language to share with others. But does second-hand information really have more value than the original? My answer is no, so I stopped (Β΄β—”β€‹βˆ€β—”`). This time, I want to write something different. After July this year, I'll be unemployed, taking this opportunity to become an independent creator and share some development insights. Whether it will generate carbon dioxide on cloud hosting platforms or bring value to others, let's wait and see. #### [Technical Blog](https://www.jing-tech.me) For software engineers, if we're talking about hot topics in recent years, AI is definitely the first term that comes to mind. News like "Major software companies lay off how many people again," "Software engineers will be replaced by AI," or "The CEO of a cutting-edge AI lab says what percentage of code will be written by AI in a few months" are not baseless, but the idea of replacement is somewhat exaggerated. However, the impact is real, especially with the rapid advancement of AI programming tools recently, making me reflect since last year on what my value as a software engineer is. The first shock to my heart was figuring out what to write to avoid becoming just a battery for large language models in the AI era. Of course, seeing the last update time of my technical blog (June 2024) shows I still have no idea XD. In any case, the technical blog will continue, but in a different form, perhaps not just about brushing up on interview questions or related content. ### Conclusion Finally, if you think there's room for improvement on this website or you're curious about the technical details behind it, feel free to contact me via [Email](mailto:hello@jinghuangsu.com). --- ## Logs of a Software Engineer | EP.2: JKOPay - **URL:** https://www.jinghuangsu.com/writing/career/my-swe-career-ep2 - **Date:** 2020-10-15 - **Category:** career - **Tags:** career, software engineer, frontend engineer, jkopay ## Introduction Last week, LinkedIn popped up a notification saying, "OOO liked your work anniversary." It suddenly hit meβ€”it's been a full year since I joined the company. The journey has been filled with luck and growth, and I think it’s worth writing down. If you haven’t read the first episode, check out [Logs of a Software Engineer | EP.1: From Zero to One](/writing/career/my-swe-career-ep1). ## What I Learned This Year Looking back at myself when I first joined the companyβ€”I was just someone who had done a few simple projects and had no idea what real software engineering or teamwork was like. That clueless version of me is still vivid in my mind. Honestly, even a year later, I still feel like I haven’t improved that much (maybe?). In a fast-paced startup, there's rarely time to onboard newbies with hand-holding. Not long after joining the team, I was given my first real project. That experience forced me to grow quickly and taught me a lot. I'm extremely grateful to my manager for trusting me and to the challenging environment that pushed me forward. Over the past year, I participated in four projects. Though none were massive in scale, the deadlines were tight, which was overwhelming for someone still learning the ropes. I ended up working overtime almost every dayβ€”came close to sleeping at the office. Here are some of the key lessons I’ve gathered: ## Technical Skills ### Modular UI Components When I first started, I struggled a lot with UI development. Before joining the company, I had never worked with concepts like _component reuse_ or _CSS-in-JS_, and I’d never even heard of Storybookβ€”a tool that helps with component development and testing. I still remember the first time I discussed API formats with a backend engineer. I panicked just because their variable names didn’t match what I had expected in the component. I even asked my manager, β€œWhat am I supposed to do now?” Back then, I was used to building everything myselfβ€”from writing the API to consuming it. There was no concept of collaboration or standard processes. Honestly, I wasn’t even sure what my job was πŸ«₯. A year later, I helped build out our internal component libraryβ€”from just 4 or 5 shared components to more than 20. I learned that a truly reusable and scalable component isn’t just about replicating UIβ€”it requires deep communication with designers to understand the design logic and use cases. Once the final design is handed over, you need to write a flexible, extensible component and iterate based on actual usage feedback. ### React & Redux Our tech stack is React + Redux. In the beginning, I didn’t even know you could have multiple `React.useEffect`s in a single componentβ€”or what a Custom Hook was. When building my first project, I didn’t understand any of these patterns, so I dumped everythingβ€”UI, logic, data handlingβ€”into one giant file. It ended up being over 1,000 lines and completely unmanageable. My manager eventually reviewed the code and told me it was unusableβ€”I had to rewrite everything from scratch. Worst of all, I dragged my manager into working weekends with me to get it done. Q_Q Now, I’ve gained a better grasp of the React API and started considering performance and architecture in development. For example, I evaluate whether to use `React.useMemo` or `React.useCallback` for optimization, and I’m mindful of component re-renders to keep things clean and predictable. I’ve also become proficient in managing complex, multi-step forms using React + Redux with Formik and Yup. This includes validation, state management, and error handlingβ€”all contributing to a smoother user experience. Lately, I’ve been responsible for migrating a legacy MPA project to a SPA. This has deepened my understanding of the need for abstraction and modularity as a platform scales. Balancing flexibility with maintainability is something I’m currently focusing on and actively learning. ### Code Quality I used to write code with a β€œjust make it work” mindset, with no real sense of software design principles. Looking back at code I wrote just a few months ago is painfulβ€”side effects everywhere, messy naming, poor readability, and barely extensible. Now, I strive to write functions that are immediately understandable to reviewers. I’ve adopted functional programming principles to reduce side effects, improve abstraction, and make code more reusable and extensible. In some projects, I also introduced testing workflows, practicing both TDD (Test-Driven Development) and BDD (Behavior-Driven Development). The benefit? You clarify product requirements and user flows up front, reduce basic bugs, and gain confidence during the testing phase. ## Product Development Process In software development, delays from any party can affect the whole schedule. This is especially true for frontend engineers, since we heavily rely on both design and backend deliverables. If everyone submits work at the last minute, frontend often bears the brunt. So what do we do when the design isn’t finalized or APIs aren’t ready? We can’t afford to wait for everything to be perfect before starting development. When design is still in progress, I’ll begin by drafting all page structures and user flows based on known requirements, and work closely with the project manager to validate the direction. If BDD is adopted, I’ll sync on feature specs and start building the layout prototype for early feedback. If the API isn't done yet, I’ll ask backend engineers for a draft of the API spec. As long as I have a schema, I can use [Mock Service Worker (MSW)](https://mswjs.io/) to simulate API responses. This lets me test page behavior, response formats, and even error scenarios. By the time design and mock APIs are finalized, I can quickly fill in the remaining logic and move everything to the testing environment for QA, keeping things on track. ## Communication Skills Effective communication drastically reduces misunderstandings between teams and is critical for timelines and alignment. For example: - When talking with the PM, I clarify realistic timelines and confirm whether the features match their expectations to avoid building the wrong thing. - When working with designers, I discuss feasibility, understand the logic behind designs, and ensure that our implementation meets their standards. - When integrating with backend or mobile teams, I clarify API or WebView function definitions to ensure smooth collaboration. - When reporting to my manager, I summarize progress, roadblocks, solutions, and strategic decisions clearly and concisely. These situations all require continuous communication and coordination to ensure mutual satisfaction. I'm still learning how to convey information efficiently and empathetically in these collaborations. My goal is to handle communication challenges more smoothly in the future and make every partnership more productive. ## Conclusion When I joined a year ago, the frontend team was still in its infancyβ€”just me and my manager. We didn’t have much of a dev culture yet. Everything from building projects to setting up Nginx and deployment pipelines had to be figured out through hands-on trial and error. Yes, I fell into countless pitfalls and made countless mistakes. The pressure was real, and time off was rare. But solving problems with my own hands brought a level of fulfillment that's hard to describe. I’m deeply grateful to my manager and all the teammates who were always patient with my basic, beginner questions. Today, our frontend team has grown to five strong membersβ€”each one incredibly skilled. I'm excited for what's ahead. --- ## Logs of a Software Engineer | EP.1: From Zero to One - **URL:** https://www.jinghuangsu.com/writing/career/my-swe-career-ep1 - **Date:** 2020-10-13 - **Category:** career - **Tags:** career, software engineer, frontend engineer ## Starting from Zero To give everyone context, I majored in accounting in my five-year junior college, then I took up finance when I transferred to a university. You read it right, these two majors have absolutely nothing to do with computer science! If I followed the usual track of people who graduated from these courses, I should have entered the finance industry or went on to be an employee of the Big Four accounting firms, yada yada. So how on Earth did I become a front-end engineer? ## Spark of Passion ### An Opportunity Every twist and turn in life presents an opportunity, be it through a friend's recommendation or the thrilling discovery of one's true passion. So, what was my turning point? When I first entered university, I couldn’t care less how to select courses, nor was I concerned with the thought of dealing with challenging professors. The only purpose I had at that time was to enjoy university life and nothing could stand in my way! Due to this carefree attitude, it was no surprise that I was prone to encounter difficult subjects; and out of these, the most notable was statistics. The easygoing mentality quickly faded away after just one week into the semester in this class. Little did I know, this unexpected encounter would mysteriously propel me into the captivating world of programming. During the first week of statistics class, the professor said something that shocked me, > This course is not intended to teach you how to approach future graduate school exams. The midterm and final exams will not focus on testing your ability to solve statistical problems using formulas. Instead, the goal of this course is to equip you with the skills to analyze and solve statistical problems. By the end of this course, I hope you will have developed a strong aptitude for tackling statistical challenges. Additionally, please remember to bring your laptop to class in the future, as we will be utilizing the R language. Back then, my reaction was like, β€˜What the heck is this 'R' language? Programming in a business school? It felt totally out of left field!’ I even thought about dropping the course, but all the other professors were fully booked. So, I had no other option but to stick with this professor's stats class and dive headfirst into the wild world of programming. ### About That Course The workload for this course was enormous, and to top it off, it came around once every two weeks. Each session bombarded us with about twenty statistical analysis questions, all requiring the use of R language for data analysis and presentation. I remember being clueless about even the basics, like what a loop was, so it took me countless hours to complete the homework (around 10-15 pages of A4 size). However, despite the difficulty, there was always a rewarding sense of accomplishment once I finished it. Even though the course was overall challenging, it became the key that unlocked the door to self-learning programming for me. It equipped me with problem-solving skills, transformed my learning attitude, broadened my perspective, and sparked a genuine passion for data analysis and the desire to pursue a career in that field. Unfortunately, due to the heavy course load in my junior year's first semester, I couldn't dedicate much time to furthering my programming studies. I managed to take a few Fintech-related courses within my department, but they were more like introductions and lacked actual programming involvement. ## Another Window ### An Accident One day during my junior year, out of the blue, I found myself inexplicably searching for a CS-related keyword on the internet. To my surprise, the search results presented me with a university assignment question, labeled with the intriguing letters 'CS61A.' Without a second thought, I instinctively fed this string of characters into Google, and to my astonishment, it was like stumbling upon a hidden treasure. > CS61A (Structure and Interpretation of Computer Programs,) - UC Berkeley's mandatory foundation course for freshmen in the Computer Science department ### CS61A's Inspiration to Me CS61A not only provided me with the course content itself but also revolutionized my entire approach to learning. Picture a course with an impressive team of nearly 60 teaching assistants - the sheer scale of it left me in awe of the abundant resources available at the world's top universities (no wonder their tuition fees are so steep). What struck me was that these teaching assistants were around my age. The difference was that they had already begun building their resumes, seeking internships, managing their LinkedIn profiles, and showcasing their portfolios on personal blogs. It made me question whether I had been too complacent and passive in shaping my own future. Whenever I felt like giving up, I would turn to their blogs, which became my driving force to persist in my learning journey at that time. ### Front-end However, what led me to shift from aspiring to be a data analyst to stepping into the realm of front-end development? It all started when I was learning how to crawl web data. And let me tell you, anyone who's done web scraping knows that having a solid understanding of HTML can make your life a whole lot easier. So, I decided to get myself a Web Bootcamp course from Udemy. And once I got started, there was no looking backβ€”I jumped headfirst into the thrilling universe of front-end development! ## Passion After my junior year, I became completely obsessed with coding. It became a daily requirement for me, spending more than eight hours a day diving into various high-quality courses like CS61A. It was during this time that I truly discovered what it means to be passionate about something! Of course, along the way, I had my doubts. Would this really lead to success? Is this truly what the industry demands? However, insecurities are only natural. The crucial part is how you persist in the face of those uncertainties. If you give up, everything just comes to a halt. Whenever I felt exhausted or lacked motivation, I sought inspiration from role models. They could be experts in front-end development or even close friends. Just glancing at the number of commits on their GitHub or reading the insightful articles on their blogs was enough to reignite my drive hahaha. While persistence is vital, it's also crucial to regularly assess whether you're on the right track. How did I do this? In the beginning, I devised a plan to track my weekly progress, maintain a daily record, and conduct weekly reviews. I mainly used Toggl, a time-tracking tool, to keep tabs on my study hours. Cultivating the habit of planning my weekly goals and keeping daily records served as a means to evaluate my progress. From the summer of my senior year until the time I secured my interview, I dedicated over 2,000 hours to learning programming. ## From Zero to One And that's where it all began. The reason I'm sharing my story is to let more people know that going from 0 to 1 might not be a cakewalk, but it's definitely doable. Transitioning successfully into a new field is not a matter of chance; it takes some real time and effort. Finally, I want to leave you with a quote I absolutely love: > You can’t connect the dots looking forward; you can only connect them looking backwards. So you have to TRUST that the dots will somehow connect in your future.” – Steve Job This quote has been a lifesaver for me during those moments of self-doubt. Even if things don't work out as planned or I stumble along the way, guess what? It's just another dot in the crazy canvas of my life. And who knows? Someday, in some unexpected situation, it might just come in handy! --- # TIL (Today I Learned) --- ## Radix UI - Popper - **URL:** https://www.jinghuangsu.com/til/radix-ui-popper - **Date:** 2026-01-27 - **Category:** radix-ui - **Tags:** React, JavaScript, UX ## Introduction Every tooltip, popover, dropdown menu, and context menu in Radix UI needs to answer the same question: "Where should this floating thing appear?" And it's harder than it looks. The floating element needs to stay near its anchor, avoid clipping off-screen, flip when there's no room, position an arrow correctly, and animate from the right origin point. In the Floating UI series, we explored how positioning works from the ground up: the [coordinate system](/til/floating-ui-coordinate-system) that maps elements to screen positions, the [middleware pipeline](/til/floating-ui-middleware) that transforms those coordinates through composable functions, and the [useFloating hook](/til/floating-ui-use-floating) that ties it all together in React. We also looked at how Radix builds its component primitives with the [Slot pattern](/til/radix-ui-slot) for prop merging and [Collection API](/til/radix-ui-composition) for compound component coordination. Now we arrive at the component that brings positioning into Radix's world: . This internal utility wraps 's `useFloating` hook into a compound component API that powers `Tooltip`, `Popover`, `DropdownMenu`, `ContextMenu`, `Select`, and more. The raw Floating UI hook is imperative: you wire refs manually, configure middleware arrays, and track arrow elements yourself. Popper transforms all of that into declarative JSX: ```tsx // Raw Floating UI β€” imperative, manual wiring const { refs, floatingStyles } = useFloating({ placement: 'bottom', middleware: [offset(10), flip(), shift(), arrow({ element: arrowRef })], }); // Radix Popper β€” declarative, automatic coordination ``` This article breaks down how Popper achieves this transformation, then builds a working mini-implementation to see these ideas in action. --- ## Architecture Overview ### Component Hierarchy Popper uses four components arranged in a compound pattern. The root provides context, the anchor registers a reference element, the content handles positioning, and the arrow positions itself based on the content's placement. ```js file=index.html Popper Component Hierarchy Popper (Root) Manages anchor state, renders no DOM PopperProvider Context anchor: Measurable | null, onAnchorChange: (anchor) => void PopperAnchor Registers anchor element PopperContent Orchestrates Floating UI, provides content context reads anchor PopperArrow Positions via content context ``` ### Context Design Radix uses **two levels of context** to coordinate between components: **PopperContext** sits at the root level. It holds a reference to the anchor element and a setter function. When `PopperAnchor` mounts, it registers itself here. When `PopperContent` mounts, it reads the anchor from here and passes it to Floating UI. ```tsx type PopperContextValue = { anchor: Measurable | null; onAnchorChange(anchor: Measurable | null): void; }; ``` **PopperContentContext** sits inside the content. It shares positioning results (which side the content landed on, arrow coordinates, whether the arrow should be hidden) with `PopperArrow`. ```tsx type PopperContentContextValue = { placedSide: Side; onArrowChange(arrow: HTMLSpanElement | null): void; arrowX?: number; arrowY?: number; shouldHideArrow: boolean; }; ``` Why two contexts instead of one?} content={<>The root context coordinates what to position relative to (the anchor). The content context coordinates where things ended up (placement results). These are different lifecycles: the anchor registers once, but placement results update on every reposition. Separating them avoids unnecessary re-renders of the anchor when only positioning data changes.} /> ### Data Flow The components communicate through a four-step handshake: ```js file=index.html Popper Data Flow 1 Anchor registers PopperAnchor calls onAnchorChange(element) 2 Content positions useFloating reads anchor from context, runs middleware 3 Arrow registers PopperArrow calls onArrowChange(span) 4 Arrow positions Reads arrowX, arrowY, placedSide from context ``` 1. **PopperAnchor** registers the anchor element (or a virtual ref) with the root context 2. **PopperContent** reads the anchor, passes it to `useFloating`, and calculates position via the middleware pipeline 3. **PopperArrow** registers its span element with the content context, triggering a recalculation with the `arrow` middleware included 4. **PopperContent** provides the final arrow coordinates back through context, and **PopperArrow** positions itself --- ## Component Deep Dive ### Popper (Root) The root component is deliberately minimal. It manages a single piece of state: the anchor element. ```tsx const Popper: React.FC = (props) => { const { children } = props; const [anchor, setAnchor] = React.useState(null); return ( {children} ); }; ``` `Popper` renders no DOM element at all. It's purely a context provider, giving consumers complete flexibility over their JSX structure. This is the same pattern used across Radix β€” roots provide coordination, not markup β€” as we saw with the [Collection API](/til/radix-ui-composition). ### PopperAnchor This component handles two modes: DOM anchors and virtual anchors. ```tsx const PopperAnchor = React.forwardRef((props, forwardedRef) => { const { virtualRef, ...anchorProps } = props; const context = usePopperContext(ANCHOR_NAME); const ref = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); const anchorRef = React.useRef(null); React.useEffect(() => { const previousAnchor = anchorRef.current; anchorRef.current = virtualRef?.current || ref.current; if (previousAnchor !== anchorRef.current) { context.onAnchorChange(anchorRef.current); } }); return virtualRef ? null : ; }); ``` There are two subtle patterns here worth noting. **The effect has no dependency array.** It runs on every render. This is intentional: `virtualRef.current` can change without triggering a re-render (that's how refs work), so the effect needs to check on every render cycle whether the anchor has actually changed. **It tracks the previous anchor** to avoid calling `onAnchorChange` unnecessarily. Without this check, the content would reposition on every render even when the anchor hasn't moved. When using a virtual anchor, the component renders nothing. The virtual ref just needs to implement the `Measurable` interface β€” an object with a `getBoundingClientRect` method. This is how Radix's `ContextMenu` anchors to the right-click position, and how `Select` could anchor to a text selection. ### PopperContent This is the most substantial component. It translates Radix's declarative props into Floating UI's imperative configuration. ```tsx interface PopperContentProps extends PrimitiveDivProps { side?: 'top' | 'right' | 'bottom' | 'left'; sideOffset?: number; align?: 'start' | 'center' | 'end'; alignOffset?: number; arrowPadding?: number; avoidCollisions?: boolean; collisionBoundary?: Element | Element[] | null; collisionPadding?: number | Partial>; sticky?: 'partial' | 'always'; hideWhenDetached?: boolean; updatePositionStrategy?: 'optimized' | 'always'; onPlaced?: () => void; } ``` Inside, it converts `side`/`align` to a Floating UI placement string, normalizes collision padding, and sets up the middleware pipeline (detailed in the next section). The `useFloating` hook receives the anchor from context and returns calculated styles: ```tsx const desiredPlacement = (side + (align !== 'center' ? '-' + align : '')) as Placement; // 'bottom' + 'start' β†’ 'bottom-start' // 'top' + 'center' β†’ 'top' const { refs, floatingStyles, placement, isPositioned, middlewareData } = useFloating({ strategy: 'fixed', placement: desiredPlacement, whileElementsMounted: (...args) => { const cleanup = autoUpdate(...args, { animationFrame: updatePositionStrategy === 'always', }); return cleanup; }, elements: { reference: context.anchor, // ← Read from PopperContext }, middleware: [ /* ... */ ], }); ``` **The render structure** uses two nested divs: ```jsx
``` Why two nested divs?} content={<>The outer div is the positioning wrapper β€” it receives Floating UI's calculated styles and should never be styled by consumers. The inner Primitive.div is the actual content element β€” it receives consumer styles, data-side/data-align attributes for CSS selectors, and the forwarded ref. Splitting them prevents consumer styles from interfering with positioning.} /> Two other patterns worth calling out: **Off-screen while measuring.** Before `isPositioned` becomes `true`, the wrapper uses `translate(0, -200%)` to keep content off-screen. This prevents a flash of content at the wrong position while Floating UI measures dimensions. **Animation disabled until positioned.** The inner div sets `animation: 'none'` until positioning is complete. This ensures entrance animations don't play at the wrong position or with the wrong `data-side` attribute. ### PopperArrow The arrow component reads placement data from the content context and positions itself on the opposite side of the content: ```tsx const OPPOSITE_SIDE: Record = { top: 'bottom', right: 'left', bottom: 'top', left: 'right', }; const PopperArrow = React.forwardRef((props, forwardedRef) => { const contentContext = useContentContext(ARROW_NAME); const baseSide = OPPOSITE_SIDE[contentContext.placedSide]; return ( ); }); ``` The arrow is always an SVG pointing "up" by default. The `transform` rotates it to match the placement side: ```js file=index.html Arrow Positioning by Side side="bottom" Anchor Content Arrow at top, rotate(180deg) side="top" Content Anchor Arrow at bottom, translateY(100%) side="right" Anchor Content Arrow at left, rotate(90deg) side="left" Content Anchor Arrow at right, rotate(-90deg) ``` The arrow is wrapped in a `` rather than being a bare SVG because `ResizeObserver` (used by `useSize` to measure the arrow) doesn't report SVG dimensions correctly. The span ensures accurate size measurement. --- ## Middleware Pipeline The middleware pipeline is where the actual positioning happens. If you want a deeper understanding of how the pipeline architecture works β€” the sequential loop, the reset mechanism, and how middleware share data β€” see [Floating UI - Middleware Overview](/til/floating-ui-middleware). Here's Popper's middleware configuration in order: ```tsx middleware: [ offset({ mainAxis: sideOffset + arrowHeight, alignmentAxis: alignOffset }), avoidCollisions && shift({ mainAxis: true, crossAxis: false, limiter, ...detectOverflowOptions }), avoidCollisions && flip({ ...detectOverflowOptions }), size({ ...detectOverflowOptions, apply: ({ elements, rects, availableWidth, availableHeight }) => { ... } }), arrow && floatingUIarrow({ element: arrow, padding: arrowPadding }), transformOrigin({ arrowWidth, arrowHeight }), hideWhenDetached && hide({ strategy: 'referenceHidden', ...detectOverflowOptions }), ] ``` Order matters. Each middleware can modify `x`, `y`, or `placement`, affecting everything that runs after it. As we saw in the [middleware article](/til/floating-ui-middleware), `flip` can trigger a reset that reruns the entire pipeline with a new placement. ### offset ```tsx offset({ mainAxis: sideOffset + arrowHeight, alignmentAxis: alignOffset }) ``` Creates space between anchor and content. The `mainAxis` value includes `arrowHeight` so the arrow sits in its own space rather than overlapping the anchor. ### shift ```tsx shift({ mainAxis: true, crossAxis: false, limiter: sticky === 'partial' ? limitShift() : undefined, ...detectOverflowOptions, }) ``` Slides content along the viewport edge to stay visible. Note that `mainAxis` in `shift` refers to the axis **parallel** to the reference edge (e.g., X for `'bottom'` placement) β€” this is swapped from `offset` where `mainAxis` means the axis **away** from the reference (Y for `'bottom'`). So `mainAxis: true` here enables horizontal sliding for vertical placements, while `crossAxis: false` prevents adjusting the gap distance. See [Middleware Deep Dive β€” How shift Uses mainAxis and crossAxis](/til/floating-ui-middleware-in-deep#how-shift-uses-mainaxis-and-crossaxis) for a detailed explanation. The `sticky` prop controls how aggressively it keeps content in view: - `'partial'` (default) uses `limitShift()` β€” content can partially leave the viewport, but the arrow stays connected to the anchor - `'always'` has no limiter β€” content always stays fully inside the viewport, even if the arrow detaches ### flip ```tsx flip({ ...detectOverflowOptions }) ``` When there's not enough room on the requested side, flip tries the opposite side. If that also overflows, it tries perpendicular sides and picks whichever has the most space. This is the middleware that triggers a pipeline reset, as we explored in the [middleware deep dive](/til/floating-ui-middleware). ### size ```tsx size({ ...detectOverflowOptions, apply: ({ elements, rects, availableWidth, availableHeight }) => { const { width: anchorWidth, height: anchorHeight } = rects.reference; const contentStyle = elements.floating.style; contentStyle.setProperty('--radix-popper-available-width', `${availableWidth}px`); contentStyle.setProperty('--radix-popper-available-height', `${availableHeight}px`); contentStyle.setProperty('--radix-popper-anchor-width', `${anchorWidth}px`); contentStyle.setProperty('--radix-popper-anchor-height', `${anchorHeight}px`); }, }) ``` Rather than modifying position, this middleware exposes dimensions as CSS custom properties. Consumers use them to write responsive floating content: ```css .select-content { width: var(--radix-popper-anchor-width); } .dropdown-content { max-height: var(--radix-popper-available-height); overflow-y: auto; } ``` ### arrow ```tsx floatingUIarrow({ element: arrow, padding: arrowPadding }) ``` Calculates where to position the arrow element. The `arrowPadding` prevents the arrow from reaching the content's corners. The middleware also reports `centerOffset` β€” when non-zero, Radix hides the arrow because it can't center on the anchor. ### transformOrigin (Custom) A custom middleware unique to Radix. It calculates the CSS `transform-origin` so scale animations grow from the arrow tip toward the anchor. The result is exposed as `--radix-popper-transform-origin`: ```css .popover-content { transform-origin: var(--radix-popper-transform-origin); animation: scaleIn 150ms ease-out; } @keyframes scaleIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } ``` ### hide ```tsx hide({ strategy: 'referenceHidden', ...detectOverflowOptions }) ``` Detects when the anchor scrolls out of view. The content wrapper receives `visibility: hidden` and `pointerEvents: 'none'`, preventing orphaned floating elements. Shared collision detection} content={<>The detectOverflowOptions object (padding, boundary, altBoundary) is shared across shift, flip, size, and hide. This keeps behavior consistent: if content flips to avoid a boundary, shift won't push it back past that same boundary.} /> --- ## Implementation Let's build a working mini-Popper to see these concepts in action. This implementation uses the same architecture as the real Radix Popper: two context levels, compound components, and Floating UI's middleware pipeline. ```tsx file=App.js function App() { const [side, setSide] = useState('bottom'); const [isOpen, setIsOpen] = useState(true); return (
Anchor {isOpen && (

Floating content

side: {side}

)}
); } ``` ```tsx file=popper.js useFloating, autoUpdate, offset, flip, shift, arrow as floatingUIArrow, } from '@floating-ui/react-dom'; // ============================================================ // CONTEXT: Popper (Root Level) // ============================================================ const PopperContext = React.createContext(null); function usePopperContext(component) { const context = React.useContext(PopperContext); if (!context) throw new Error(`<${component}> must be used within `); return context; } // ============================================================ // CONTEXT: PopperContent (Content Level) // ============================================================ const PopperContentContext = React.createContext(null); function usePopperContentContext(component) { const context = React.useContext(PopperContentContext); if (!context) throw new Error(`<${component}> must be used within `); return context; } // ============================================================ // POPPER (Root) β€” context provider, no DOM // ============================================================ const [anchor, setAnchor] = React.useState(null); const contextValue = React.useMemo( () => ({ anchor, onAnchorChange: setAnchor }), [anchor] ); return ( {children} ); } // ============================================================ // POPPER ANCHOR β€” registers element with root context // ============================================================ ({ children, ...props }, forwardedRef) => { const context = usePopperContext('PopperAnchor'); const internalRef = React.useRef(null); const ref = React.useCallback( (node) => { internalRef.current = node; if (typeof forwardedRef === 'function') forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef] ); React.useEffect(() => { context.onAnchorChange(internalRef.current); return () => context.onAnchorChange(null); }, [context]); return (
{children}
); } ); // ============================================================ // POPPER CONTENT β€” configures useFloating, provides arrow context // ============================================================ const OPPOSITE_SIDE = { top: 'bottom', right: 'left', bottom: 'top', left: 'right', }; ( { children, side = 'bottom', sideOffset = 0, align = 'center', alignOffset = 0, arrowPadding = 0, style, ...props }, forwardedRef ) => { const context = usePopperContext('PopperContent'); const [arrowEl, setArrowEl] = React.useState(null); const ARROW_HEIGHT = 8; const placement = align === 'center' ? side : `${side}-${align}`; const { refs, floatingStyles, placement: actualPlacement, middlewareData, } = useFloating({ strategy: 'fixed', placement, whileElementsMounted: autoUpdate, elements: { reference: context.anchor }, middleware: [ offset({ mainAxis: sideOffset + ARROW_HEIGHT, alignmentAxis: alignOffset }), flip(), shift(), arrowEl && floatingUIArrow({ element: arrowEl, padding: arrowPadding }), ], }); const [placedSide] = actualPlacement.split('-'); const arrowX = middlewareData.arrow?.x; const arrowY = middlewareData.arrow?.y; const shouldHideArrow = middlewareData.arrow?.centerOffset !== 0; const contentContextValue = React.useMemo( () => ({ placedSide, arrowX, arrowY, onArrowChange: setArrowEl, shouldHideArrow }), [placedSide, arrowX, arrowY, shouldHideArrow] ); return (
{children}
); } ); // ============================================================ // POPPER ARROW β€” positions based on content context // ============================================================ ({ width = 10, height = 8, style, className, ...props }, forwardedRef) => { const context = usePopperContentContext('PopperArrow'); const ref = React.useCallback( (node) => { if (typeof forwardedRef === 'function') forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; context.onArrowChange(node); }, [forwardedRef, context] ); const baseSide = OPPOSITE_SIDE[context.placedSide]; return (
); } ); ``` ```css file=index.css html { height: 500px; } body { background: white; padding: 20px; font-family: system-ui, sans-serif; min-height: 100%; margin: 0; box-sizing: border-box; } .app { display: flex; flex-direction: column; gap: 20px; } .controls { display: flex; gap: 8px; align-items: center; } .controls label { font-size: 14px; font-weight: 500; } .controls select, .controls button { padding: 4px 10px; border: 1px solid #d1d5db; border-radius: 6px; background: white; font-size: 13px; cursor: pointer; } .controls button:hover { background: #f3f4f6; } .demo-area { display: flex; justify-content: center; align-items: center; height: 300px; border: 1px dashed #e5e7eb; border-radius: 8px; } .anchor { padding: 12px 24px; background: #eff6ff; border: 2px solid #3b82f6; border-radius: 8px; font-weight: 600; color: #1e40af; font-size: 14px; } .content { padding: 12px 16px; background: #faf5ff; border: 2px solid #8b5cf6; border-radius: 8px; color: #5b21b6; font-size: 13px; z-index: 10; } .content p { margin: 0; } .side-label { font-size: 11px; color: #7c3aed; margin-top: 4px; font-family: monospace; } .arrow { color: #8b5cf6; } ``` ```json file=package.json { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "react": "19.0.2", "react-dom": "19.0.2" } } ``` Key implementation details: 1. **Two context levels** β€” `PopperContext` coordinates anchor/content. `PopperContentContext` shares arrow positioning data. This mirrors the real Radix architecture. 2. **Arrow state is lifted** β€” `PopperContent` owns the arrow element state (`setArrowEl`), even though `PopperArrow` provides the actual DOM element. This lets the middleware pipeline include the arrow before `PopperArrow` mounts. 3. **Compound component pattern** β€” Like the [Collection API](/til/radix-ui-composition), each component self-registers via context rather than requiring the parent to iterate children. `PopperArrow` can be nested anywhere inside `PopperContent`. 4. **The `OPPOSITE_SIDE` map** β€” The arrow sits on the opposite side from the content's placement. If content is below the anchor (`side="bottom"`), the arrow points up from the top edge of the content. --- ## Key Design Decisions ### Fixed Positioning Strategy ```tsx useFloating({ strategy: 'fixed' }) ``` Popper uses `fixed` positioning instead of `absolute`. This avoids issues with ancestor elements that have `transform`, `filter`, or `will-change` properties, which create new containing blocks and break absolute positioning. Fixed positioning also avoids scroll container issues. ### Off-Screen While Measuring ```tsx transform: isPositioned ? floatingStyles.transform : 'translate(0, -200%)' ``` Before Floating UI finishes calculating, the content is translated off-screen. This prevents a flash of content at `(0, 0)` before the correct position is computed. The content is still rendered (so it can be measured), just not visible. ### Animation Disabled Until Positioned ```tsx animation: !isPositioned ? 'none' : undefined ``` Combined with off-screen rendering, this prevents entrance animations from playing at the wrong position. Once positioning completes, the animation property is removed and the CSS animation kicks in with the correct `data-side` attribute. ### Scoped Context ```tsx const [createPopperContext, createPopperScope] = createContextScope(POPPER_NAME); ``` Each Popper instance gets its own isolated context scope via `__scopePopper`. This is critical for composite components: `Select` uses `Popover` internally, which uses `Popper`. Without scoped contexts, the nested Popper instances would clash. --- ## Connection to Other Primitives Popper doesn't exist in isolation β€” it's one layer in a composition of primitives that make Radix components work: - **[Slot](/til/radix-ui-slot)** handles prop merging via `asChild`, letting consumers control the rendered element - **[Collection](/til/radix-ui-composition)** tracks items for keyboard navigation in menus and selects - **[Presence](/til/radix-ui-present)** manages mount/unmount animations β€” delaying removal until exit animations complete - **[FocusScope](/til/radix-ui-focus-scope)** traps keyboard focus inside modals and dialogs - **[RovingFocusGroup](/til/radix-ui-roving-focus)** handles arrow-key navigation within item groups A `DropdownMenu`, for example, combines all of these: `Popper` positions the menu, `Presence` animates it in and out, `Collection` tracks menu items, `RovingFocusGroup` handles arrow keys between them, and `FocusScope` traps focus inside. Each primitive handles one concern well, and composition creates sophisticated behavior. --- ## Summary `@radix-ui/react-popper` transforms Floating UI's imperative hook into a declarative compound component API. The key ideas: 1. **Context coordination** β€” The root stores the anchor, content reads it via context. No manual ref wiring. 2. **Lifted arrow state** β€” Content owns the arrow ref so it can include the arrow in middleware configuration before the arrow component mounts. 3. **Middleware pipeline** β€” Seven middleware in order: offset β†’ shift β†’ flip β†’ size β†’ arrow β†’ transformOrigin β†’ hide. Order matters because each builds on previous results. 4. **Virtual anchors** β€” The `Measurable` interface decouples positioning from DOM structure, enabling cursor-anchored context menus and selection popovers. 5. **CSS custom properties** β€” Dimensions and transform origins are exposed as CSS variables, keeping styling in CSS where it belongs. This architecture enables Tooltip, Popover, Menu, Select, and more to share consistent positioning behavior with minimal duplication. Each component just declares its intent (`side="bottom"`, `avoidCollisions`), and Popper handles the rest. ## References - - - [Floating UI - Middleware Overview](/til/floating-ui-middleware) - [Floating UI - Coordinate System](/til/floating-ui-coordinate-system) - [Floating UI - useFloating](/til/floating-ui-use-floating) --- ## Radix UI - Present - **URL:** https://www.jinghuangsu.com/til/radix-ui-present - **Date:** 2026-01-26 - **Category:** radix-ui - **Tags:** React, JavaScript, UX ## Introduction Have you ever tried to add a fade-out animation to a component in React, only to find it disappears instantly? React's declarative nature creates an unexpected challenge: when a component's condition becomes `false`, React removes it immediately, no time for graceful exits. This creates a fundamental tension. CSS animations need the element to exist in the DOM to run, but React's job is to keep the DOM in sync with state. When `isOpen` becomes `false`, React does exactly what it should: removes the element. The animation never gets a chance to play. Radix UI's `Presence` primitive solves this elegantly. It acts as a gatekeeper between React's state and the actual DOM, delaying unmount until animations complete. Let's understand how. ## The Problem Consider this common scenario: a modal that should fade in when opening and fade out when closing. A natural first attempt is to toggle a CSS class based on state: ```jsx file=App.js function App() { const [isOpen, setIsOpen] = useState(false); return (
{isOpen && (
Hello World!
)}
); } ``` ```css file=index.css html { height: 400px; } body { background: white; padding: 40px; font-family: system-ui, sans-serif; min-height: 100%; margin: 0; box-sizing: border-box; } button { padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; } button:hover { background: #f5f5f5; } .modal { margin-top: 16px; padding: 20px; background: #f0f0f0; border-radius: 8px; } .modal.open { animation: fadeIn 300ms ease-out; } .modal.closed { animation: fadeOut 300ms ease-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-10px); } } ``` Click "Open" and you'll see a smooth fade-in. Now click "Close"β€”the modal vanishes instantly. The `.closed` class with `fadeOut` animation is never applied. Why? ### The Race Against Unmount When `isOpen` becomes `false`, React's reconciliation process kicks in: 1. React re-renders the component 2. The conditional `{isOpen && ...}` evaluates to `false` 3. React removes the modal from the DOM **immediately** 4. The `.closed` class is never appliedβ€”the element is already gone Look at line 12: the modal only exists inside `{isOpen && (...)}`. This means whenever the modal renders, `isOpen` is guaranteed to be `true`. The ternary `isOpen ? 'open' : 'closed'` will always pick `'open'`β€”the `'closed'` branch is unreachable code. The instant `isOpen` flips to `false`, React unmounts the element before any class change can occur. This is the core tension: **React optimizes for keeping the DOM in sync with state, but animations need time to complete before removal.** ### What We Need ```js file=index.html What We Need - Flow User clicks "Close" isOpen = false Keep in DOM still rendered Apply fadeOut animation starts Wait 300ms onAnimationEnd Unmount removed ``` We need something that intercepts React's unmount, holds the element in the DOM while the animation plays, listens for animation completion, and only then allows the actual removal. This is exactly what `Presence` does. ## Understanding the Architecture ### The Core Insight: Three States The key insight is that "visible or not" is too simple. We need three states: ```ts type State = | 'mounted' // Component is visible and interactive | 'unmountSuspended' // Exit animation playing, still in DOM | 'unmounted'; // Actually removed from DOM ``` The middle state is the breakthrough. It represents the liminal moment when the user has requested closure, but the element hasn't left yet. ### Why Three States? Consider this scenario without the middle state: 1. User clicks "Close" β†’ Start exit animation 2. User quickly clicks "Open" again (while animating!) 3. What should happen? With only two states (`mounted`/`unmounted`), we'd have no way to know the element is currently animating out. We might: - Restart the animation awkwardly - Show a visual glitch - Lose track of the animation entirely The `unmountSuspended` state gives us a clear signal: "I'm on my way out, but I'm still hereβ€”and I can be interrupted." ### The State Machine The complete state machine looks like this: ```js const stateMachine = { mounted: { UNMOUNT: 'unmounted', // No animation defined ANIMATION_OUT: 'unmountSuspended', // Start exit animation }, unmountSuspended: { MOUNT: 'mounted', // User reopened! Interrupt exit ANIMATION_END: 'unmounted', // Animation finished, now remove }, unmounted: { MOUNT: 'mounted', // Fresh mount }, }; ``` ```js file=index.html Presence State Machine mounted unmountSuspended unmounted ANIMATION_OUT present=false ANIMATION_END fadeOut done MOUNT interrupted! MOUNT present=true UNMOUNT no animation ``` Notice the dashed arrow from `mounted` directly to `unmounted`. This is the fast path: if no exit animation is defined in CSS, we skip the suspended state entirely. No point waiting for an animation that doesn't exist. **State Transitions Visualized:** ```js file=index.html State Transitions Normal Close mounted fadeOut unmountSuspended ends unmounted Interrupted mounted fadeOut unmountSuspended user reopens! ``` ### Animation Detection: The Key Algorithm Here's the clever part. How does Presence know whether an exit animation exists? It doesn't read your CSS filesβ€”it observes what actually happens in the DOM. The algorithm compares animation names before and after the `present` prop changes: ```js const prevAnimationNameRef = useRef('none'); const currentAnimationName = getComputedStyle(node).animationName; const isAnimating = prevAnimationNameRef.current !== currentAnimationName; ``` This comparison is the heart of the detection logic: ```js if (present) { send('MOUNT'); } else if (currentAnimationName === 'none') { send('UNMOUNT'); // No animation defined at all } else if (isAnimating) { send('ANIMATION_OUT'); // Animation name changed β†’ exit animation started! } else { send('UNMOUNT'); // Animation name unchanged β†’ no exit animation } ``` ```js file=index.html Animation Detection Timeline With exit animation defined: State: mounted prevAnimationName: "fadeIn" User clicks "Close" currentAnimationName: "fadeOut" "fadeIn" !== "fadeOut" β†’ TRUE send('ANIMATION_OUT') Without exit animation: State: mounted prevAnimationName: "fadeIn" User clicks "Close" currentAnimationName: "fadeIn" (unchanged) "fadeIn" === "fadeIn" β†’ FALSE send('UNMOUNT') // skip animation ``` Why compare animation names instead of checking if `animation` is defined? Because CSS might define an animation for the open state only. By comparing what changed, Presence correctly distinguishes between: - "Has an enter animation only" β†’ unmount immediately on close - "Has both enter and exit animations" β†’ wait for exit animation - "Has no animations" β†’ unmount immediately ### Listening for Animation End Once Presence detects an exit animation, it needs to know when it finishes. This is straightforward: ```js const handleAnimationEnd = (event) => { const currentAnimationName = getComputedStyle(node).animationName; const isCurrentAnimation = currentAnimationName.includes(event.animationName); if (event.target === node && isCurrentAnimation) { send('ANIMATION_END'); } }; node.addEventListener('animationend', handleAnimationEnd); ``` The `animationend` event fires when a CSS animation completes. Presence validates that: 1. The event came from the correct element (not a child) 2. The animation that ended is the current one (handles animation changes mid-flight) #### Multiple Animations: "First Wins" What if you have multiple animations? ```css .modal { animation: fadeOut 500ms, slideDown 200ms; } ``` Which animation does Presence wait for? **Whichever finishes first.** ```mdx Timeline: β”œβ”€ 0ms: Both animations start β”œβ”€ 200ms: slideDown finishes first β”‚ β”œβ”€ animationend event fires β”‚ β”œβ”€ Validates: "slideDown" in "fadeOut, slideDown" βœ“ β”‚ └─ Unmounts immediately └─ 500ms: fadeOut never completes (component is gone!) ``` This is intentional. If you need both animations to complete, combine them into a single `@keyframes` rule or use the longer duration for both. ## Implementation Let's build a minimal `Presence` to see these concepts in action. The implementation uses `cloneElement` to inject a ref and `data-state` attribute into the child, matching how the real Radix implementation works. ```jsx file=App.js function App() { const [isOpen, setIsOpen] = useState(false); return (
Hello World!
); } ``` ```jsx file=presence.js // Use useLayoutEffect on client, useEffect on server const useLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; // State machine reducer function useStateMachine(initialState, machine) { return React.useReducer( (state, event) => machine[state]?.[event] ?? state, initialState ); } // Get animation name from computed styles function getAnimationName(styles) { return styles?.animationName || 'none'; } // Core presence hook function usePresence(present) { const [node, setNode] = React.useState(null); const stylesRef = React.useRef(null); const prevPresentRef = React.useRef(present); const prevAnimationNameRef = React.useRef('none'); const [state, send] = useStateMachine(present ? 'mounted' : 'unmounted', { mounted: { UNMOUNT: 'unmounted', ANIMATION_OUT: 'unmountSuspended', }, unmountSuspended: { MOUNT: 'mounted', ANIMATION_END: 'unmounted', }, unmounted: { MOUNT: 'mounted', }, }); // Update prevAnimationName when state changes React.useEffect(() => { const currentAnimationName = getAnimationName(stylesRef.current); prevAnimationNameRef.current = state === 'mounted' ? currentAnimationName : 'none'; }, [state]); // Handle present prop changes useLayoutEffect(() => { const styles = stylesRef.current; const wasPresent = prevPresentRef.current; const hasPresentChanged = wasPresent !== present; if (hasPresentChanged) { const prevAnimationName = prevAnimationNameRef.current; const currentAnimationName = getAnimationName(styles); if (present) { send('MOUNT'); } else if (currentAnimationName === 'none' || styles?.display === 'none') { // No animation or hidden, unmount immediately send('UNMOUNT'); } else { // Check if animation changed (exit animation started) const isAnimating = prevAnimationName !== currentAnimationName; if (wasPresent && isAnimating) { send('ANIMATION_OUT'); } else { send('UNMOUNT'); } } prevPresentRef.current = present; } }, [present, send]); // Listen for animation events useLayoutEffect(() => { if (node) { const handleAnimationEnd = (event) => { const currentAnimationName = getAnimationName(stylesRef.current); const isCurrentAnimation = currentAnimationName.includes(event.animationName); if (event.target === node && isCurrentAnimation) { send('ANIMATION_END'); } }; const handleAnimationStart = (event) => { if (event.target === node) { prevAnimationNameRef.current = getAnimationName(stylesRef.current); } }; node.addEventListener('animationstart', handleAnimationStart); node.addEventListener('animationcancel', handleAnimationEnd); node.addEventListener('animationend', handleAnimationEnd); return () => { node.removeEventListener('animationstart', handleAnimationStart); node.removeEventListener('animationcancel', handleAnimationEnd); node.removeEventListener('animationend', handleAnimationEnd); }; } else { // No node, complete any pending animation send('ANIMATION_END'); } }, [node, send]); const ref = React.useCallback((node) => { if (node) { stylesRef.current = getComputedStyle(node); setNode(node); } else { stylesRef.current = null; setNode(null); } }, []); return { isPresent: ['mounted', 'unmountSuspended'].includes(state), ref, }; } // Presence component const { isPresent, ref } = usePresence(present); const child = React.Children.only(children); // Don't render if not present if (!isPresent) { return null; } // Clone child with ref and data-state attribute return React.cloneElement(child, { ref, 'data-state': present ? 'open' : 'closed', }); } ``` ```css file=index.css html { height: 500px; } body { background: white; padding: 40px; font-family: system-ui, sans-serif; min-height: 100%; margin: 0; box-sizing: border-box; } button { padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; } button:hover { background: #f5f5f5; } .modal { margin-top: 16px; padding: 20px; background: #f0f0f0; border-radius: 8px; } /* Use data-state attribute for animations */ .modal[data-state='open'] { animation: fadeIn 300ms ease-out; } .modal[data-state='closed'] { animation: fadeOut 300ms ease-out forwards; } @keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } @keyframes fadeOut { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(-10px); } } ``` Key implementation details: 1. **`data-state` attribute** - Instead of using `className` that the child controls, Presence injects a `data-state` attribute. This ensures the CSS selector changes the moment `present` changes, before the state machine decides what to do. 2. **`cloneElement` pattern** - Presence clones the child to inject the ref and data-state. This is how the real Radix implementation works. 3. **`animationstart` listener** - Captures the animation name when it actually starts, ensuring we track the correct animation for comparison. 4. **`isPresent` includes both states** - The child stays in the DOM during both `mounted` AND `unmountSuspended` states, but `data-state` reflects the actual `present` prop. Now click "Open" and "Close"β€”the exit animation plays smoothly. The key differences from the broken version: 1. **`Presence` wraps the conditional render** – it controls when the element actually leaves the DOM 2. **`isPresent` drives the CSS class** – the child knows whether it's entering or exiting 3. **State machine coordinates timing** – the element stays in DOM during `unmountSuspended` ## Connection to Other Primitives `Presence` doesn't exist in isolation. It's a building block that other Radix primitives compose: - **Dialog** uses `Presence` to animate its overlay and content - **Popover** uses `Presence` for smooth open/close transitions - **Tooltip** uses `Presence` to fade in/out on hover - **Collapsible** uses `Presence` for expand/collapse animations These components combine `Presence` with other primitives like and . Each primitive handles one concern well, and composition creates sophisticated behavior. ## References - - - --- ## Radix UI - Composition - **URL:** https://www.jinghuangsu.com/til/radix-ui-composition - **Date:** 2026-01-25 - **Category:** radix-ui - **Tags:** React, JavaScript, UX ## Introduction Composition is the foundation for building modern UI components. Rather than cramming all functionality into a single component with numerous props, composition distributes responsibility across multiple collaborating components that work together seamlessly. This pattern mirrors how we write native HTML. Consider the native select element: ```html ``` The ` ) } ``` ```jsx file=select.js const [isOpen, setIsOpen] = useState(false) const [selectedValue, setSelectedValue] = useState(defaultValue) const selectedOption = options.find(opt => opt.value === selectedValue) return (
{isOpen && (
{options.map((option) => (
{ setSelectedValue(option.value) setIsOpen(false) }} > {option.label}
))}
)}
) } ``` ```css file=index.css html { height: 400px; } body { background: white; padding: 40px; font-family: system-ui, sans-serif; min-height: 100%; margin: 0; box-sizing: border-box; } .select-root { position: relative; width: 200px; } .select-trigger { width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer; text-align: left; } .select-content { position: absolute; top: 100%; left: 0; right: 0; border: 1px solid #ccc; border-radius: 4px; background: white; margin-top: 4px; } .select-item { padding: 8px 12px; cursor: pointer; } .select-item:hover { background-color: #f5f5f5; } .select-item.selected { background-color: #e8f4ff; } ``` This approach works for simple cases, but the component quickly becomes a monolith. All rendering logic, state management, and styling live in one place. Customizing individual items, say adding an icon to one option or disabling another, requires adding more configuration options or render props, leading to an ever-growing API surface. The component ends up controlling everything, making it difficult to extend without modifying the source. For example, the PM asks: "Can one option show a tooltip explaining why it's unavailable?" Now you need `renderOption` or a `tooltip` field. Then design says: "We need a divider between the fruit categories, and the seasonal section needs a 'Show more' button." A flat options array can't express thisβ€”you need `groups`, `renderGroupHeader`, `renderGroupFooter`. Then the team asks: "When users select 'Custom...', it should open a modal instead of selecting." Now you need `onItemClick` that can prevent default behavior, or a `customAction` field. ```jsx `) Building form components, UI primitives Don't use when: Simple internal state is enough Component never needs external control Over-engineering a simple problem Comparison: Mini vs Full FeatureMiniFullControlled modeβœ…βœ…Uncontrolled modeβœ…βœ…Function updatersβœ…βœ…onChange in uncontrolledβŒβœ…useCallback optimizationβŒβœ…Ref pattern for onChangeβŒβœ…Dev mode warningsβŒβœ…Effect timing optimizationβŒβœ…Production-readyβŒβœ… Real-World Usage Basic Checkbox ```tsx function Checkbox({ checked, defaultChecked, onChange }) { const [isChecked, setIsChecked] = useControllableState({ prop: checked, defaultProp: defaultChecked ?? false, onChange, caller: 'Checkbox', }); return ( ); } // Uncontrolled usage ; // Controlled usage function App() { const [checked, setChecked] = useState(false); return ; } ``` Input Component ```tsx function Input({ value, defaultValue, onChange }) { const [inputValue, setInputValue] = useControllableState({ prop: value, defaultProp: defaultValue ?? '', onChange, caller: 'Input', }); return setInputValue(e.target.value)} />; } ``` Accordion Component ```tsx function Accordion({ value, defaultValue, onValueChange }) { const [openItem, setOpenItem] = useControllableState({ prop: value, defaultProp: defaultValue ?? null, onChange: onValueChange, caller: 'Accordion', }); return (
setOpenItem((prev) => (prev === 'item-1' ? null : 'item-1'))} /> setOpenItem((prev) => (prev === 'item-2' ? null : 'item-2'))} />
); } ``` Further Reading Libraries Using This Pattern Radix UI: https://github.com/radix-ui/primitives React Aria: https://react-spectrum.adobe.com/react-aria/ Reach UI: https://reach.tech/ Chakra UI: https://chakra-ui.com/ Related Concepts Controlled Components: https://react.dev/learn/sharing-state-between-components Custom Hooks: https://react.dev/learn/reusing-logic-with-custom-hooks Component Design Patterns: https://www.patterns.dev/posts/react-patterns Conclusion The controllable state pattern is a fundamental building block for professional React component libraries. By understanding both the mini and full implementations, you now have: Conceptual Understanding: Why and when to use this pattern Implementation Skills: How to build it from scratch Architectural Insight: Design decisions and trade-offs Production Knowledge: Optimizations and edge cases This pattern appears throughout Radix UI, React Aria, and other professional libraries. Master it, and you'll write more flexible, reusable components that delight your users. --- ## Radix UI - DismissableLayer - **URL:** https://www.jinghuangsu.com/til/radix-ui-dismissable-layer - **Date:** 2026-01-25 - **Category:** radix-ui - **Tags:** React, JavaScript, UX ## Introduction **DismissableLayer** is a fundamental primitive in Radix UI that handles the complex logic of dismissing overlays, modals, dropdowns, and other floating UI elements. It manages: * **Escape key handling** with proper layer stacking * **Outside click detection** distinguishing DOM tree vs React tree * **Focus management** for keyboard accessibility * **Pointer events control** to prevent accidental interactions * **Portaled content support** via the Branch pattern This guide explains Radix's actual implementation, not a simplified version. --- ## The Problem Space ### Why Is This Hard? Building a dismissable modal seems simple, but has many edge cases: #### Challenge 1: Layer Stacking ``` // User has nested modals // User presses Escape - which should close? // Answer: Only Modal3 (topmost) ``` **The Problem:** Each modal needs to know its position in the stack. #### Challenge 2: React Tree vs DOM Tree ``` // React tree structure // User clicks button - how does DismissableLayer know // the click happened "inside" before the event bubbles to document? ``` **The Problem:** Need to detect if click is inside React component tree, not just DOM tree. #### Challenge 3: Portaled Content ``` {/* Calendar portals to document.body */} {/* Outside modal in DOM! */} // User clicks Calendar - should modal close? // Answer: NO! Calendar is logically "inside" even though DOM-wise "outside" ``` **The Problem:** Need to mark portaled content as "safe" from dismissal. #### Challenge 4: Mount Race Conditions ``` // Timeline: // t=0ms: User clicks button // t=1ms: pointerdown event fires // t=2ms: Button handler runs β†’ Modal mounts // t=3ms: Modal adds document listener // t=4ms: Same pointerdown event bubbles to document // t=5ms: Modal thinks click is "outside" β†’ closes immediately! 😱 ``` **The Problem:** Modal mounts during the same event that should open it. +++ [**Example**]() [β€ŽGemini - direct access to Google AI](https://gemini.google.com/share/1bbc7016fad4) ***The "Crash" Timeline (Without the Fix)*** Imagine the DOM as a vertical tree. An event (like a click) starts at the bottom and bubbles up to the top. Here is what happens in milliseconds without the fix: ``` πŸ›‘ THE TRAP (How it fails) [Document Root] <-- 5. Event arrives here. | The NEW listener is already waiting! | It fires: "Clicked outside? YES. Close Modal." | [ Body ] <-- 4. Event Bubbles Up | | <-- 3. React Effect Runs (Synchronously) | Modal Mounts & Adds Listener to [Document Root] | [ Button ] <-- 2. React Handler Runs (onClick) | State changes: setShowModal(true) | [ Mouse Click ] <-- 1. User clicks here (Target: Button) ``` **The problem:** React renders so fast that the Modal adds its "close on outside click" listener to the Document **before** the click event (which triggered the modal to open) finishes traveling up to the Document. The Modal sees the *very event that created it* as an "outside click." --- ***The "Safe" Timeline (With*** `setTimeout`***)*** This is what the code in `DismissableLayerFixed.jsx` (lines 112-114) does. It pushes the listener registration to the *end* of the queue. ``` βœ… THE FIX (How it works) [Document Root] <-- 5. Event arrives here. | Browser checks for listeners... | Found NONE! (Because we waited) | Event dies harmlessly. πŸ‘» | [ Body ] <-- 4. Event Bubbles Up | | <-- 3. React Effect Runs | Modal Mounts. | We say: "Wait! Don't add listener yet." | (setTimeout, 0) | [ Button ] <-- 2. React Handler Runs | State changes: setShowModal(true) | [ Mouse Click ] <-- 1. User clicks here | . . . [ LATER ] <-- 6. Tick Tock (Event Loop finishes) NOW we add the listener to [Document Root]. Safe for next click! ``` ***The Code Responsible*** This specific block in your file `DismissableLayerFixed.jsx` creates that "Safe" timeline: ``` // DismissableLayerFixed.jsx const timerId = window.setTimeout(() => { // This runs in Step 6 (LATER), after the event has finished bubbling ownerDocument.addEventListener('pointerdown', handlePointerDown); }, 0); ``` +++ #### Challenge 5: Touch Devices ``` // Touch timeline: // t=0ms: User touches screen // t=50ms: User lifts finger (touchend) // t=400ms: click event fires (if no scroll detected) // Without special handling: // - Modal closes at t=50ms // - pointer-events restored immediately // - click at t=400ms activates button behind modal! 😱 ``` **The Problem:** 350ms delay between touch and click on mobile. --- ## Core Architecture ### Radix's Design Philosophy Radix uses a **mutable state + custom events** pattern instead of React state management. Here's why: #### Approach A: React State (Naive) ``` // ❌ This causes performance issues function Provider() { const [layers, setLayers] = useState(new Set()); return ( {children} ); } // Every layer mount/unmount causes: // 1. setState call // 2. Context value changes // 3. ALL consumers re-render // 3 modals mounting = 3 full context re-render cycles ``` **Problems:** * Every layer change triggers re-renders of ALL layers * Provider itself re-renders unnecessarily * No control over when components update #### Approach B: Mutable Sets + Custom Events (Radix) ``` // βœ… Radix's actual approach const DismissableLayerContext = React.createContext({ layers: new Set(), layersWithOutsidePointerEventsDisabled: new Set(), branches: new Set(), }); // No Provider! Just uses default value // Each component: // 1. Mutates the Set directly // 2. Dispatches custom event to notify others // 3. Each layer decides independently when to re-render ``` **Advantages:** * No Context Provider re-renders * Each layer controls its own updates * Minimal re-renders (only when needed) * Simpler mental model (no state management) ### The Force Update Pattern Since Radix uses mutable Sets, components need a way to re-render when the Set changes: ``` // Force a re-render by updating dummy state const [, force] = React.useState({}); // Listen for changes from other layers React.useEffect(() => { const handleUpdate = () => force({}); // πŸ‘ˆ Force re-render document.addEventListener(CONTEXT_UPDATE, handleUpdate); return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate); }, []); // When this layer or any other changes: function dispatchUpdate() { const event = new CustomEvent(CONTEXT_UPDATE); document.dispatchEvent(event); // πŸ‘ˆ Broadcast } ``` **How it works:** 1. Modal A adds itself to `context.layers` (mutates Set) 2. Modal A calls `dispatchUpdate()` (broadcasts event) 3. All mounted `DismissableLayers` hear the event 4. Each calls `force({})` to re-render themselves 5. Each recalculates `isHighestLayer` with fresh Set contents --- ## The Context Pattern ### Context Structure ``` const DismissableLayerContext = React.createContext({ layers: new Set(), layersWithOutsidePointerEventsDisabled: new Set(), branches: new Set(), }); ``` **Three Sets:** 1. `layers` - All mounted `DismissableLayer` instances * Used for: Layer stacking, Escape key priority * Order: Insertion order (first mounted = bottom) 2. `layersWithOutsidePointerEventsDisabled` - Layers with `disableOutsidePointerEvents={true}` * Used for: Body pointer-events management * Controls which layers can receive pointer events 3. `branches` - All mounted Branch instances (portaled content markers) * Used for: Outside click detection * Prevents dismissal when clicking portaled content ### No Provider Needed! ``` // ❌ You might expect this: // βœ… But Radix just uses default context value: {/* Works immediately! */} ``` **Why no Provider?** The default context value contains empty Sets that are **shared across all consumers**. Since JavaScript objects are passed by reference: ``` // All components get THE SAME Set instances const context1 = useContext(DismissableLayerContext); const context2 = useContext(DismissableLayerContext); context1.layers === context2.layers // true! Same Set ``` When one component mutates the Set, all components see the change (because thγ„Žey share the same Set instance). +++ ***Why?*** The [`DismissableLayer`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fmenu%252Fsrc%252Fmenu.tsx#L7>) in Radix UI does not require an explicit [`Provider`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fmenu%252Fsrc%252Fmenu.tsx#L237>) because its [`DismissableLayerContext`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fdismissable-layer%252Fsrc%252Fdismissable-layer.tsx#L19>) is initialized with default values that are mutable objects ([`Set`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fform%252Fsrc%252Fform.tsx#L40>) instances). Since JavaScript objects are passed by reference, all consumers of this context will receive references to the *same* [`Set`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fform%252Fsrc%252Fform.tsx#L40>) instances. When any component modifies these [`Set`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fform%252Fsrc%252Fform.tsx#L40>) instances (e.g., adding or removing a layer), all other components consuming the context will see these changes immediately because they are referencing the same underlying data structures. This behavior is shown in the `dismissable-layer.tsx` file, where [`DismissableLayerContext`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fdismissable-layer%252Fsrc%252Fdismissable-layer.tsx#L19>) is initialized with: ```typescript const DismissableLayerContext = React.createContext({ layers: new Set(), layersWithOutsidePointerEventsDisabled: new Set(), branches: new Set(), }); ``` This approach allows the [`DismissableLayer`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fmenu%252Fsrc%252Fmenu.tsx#L7>) system to manage its global state (like active layers and branches) without needing a top-level [`Provider`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fmenu%252Fsrc%252Fmenu.tsx#L237>) component to explicitly pass down values. The system relies on direct mutation of these shared [`Set`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fform%252Fsrc%252Fform.tsx#L40>) objects, and then dispatches an update event ([`CONTEXT_UPDATE`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fdismissable-layer%252Fsrc%252Fdismissable-layer.tsx#L13>)) to force consumers to re-render, as seen in the [`useEffect`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fmenu%252Fsrc%252Fmenu.tsx#L95>) hooks within [`DismissableLayer`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fmenu%252Fsrc%252Fmenu.tsx#L7>) and the [`dispatchUpdate`](<%252Fradix-ui%252Fprimitives%252Fpackages%252Freact%252Fdismissable-layer%252Fsrc%252Fdismissable-layer.tsx#L328>) function in `dismissable-layer.tsx`. +++ --- +++ ## Hooks Deep Dive +++ ### Hook 1: useEscapeKeydown **Purpose:** Listen for Escape key and call handler. ``` function useEscapeKeydown( onEscapeKeyDown?: (event: KeyboardEvent) => void, ownerDocument: Document = globalThis?.document ) { const callbackRef = React.useRef(onEscapeKeyDown); // Keep ref in sync with latest callback React.useEffect(() => { callbackRef.current = onEscapeKeyDown; }, [onEscapeKeyDown]); React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' && callbackRef.current) { callbackRef.current(event); } }; ownerDocument.addEventListener('keydown', handleKeyDown); return () => ownerDocument.removeEventListener('keydown', handleKeyDown); }, [ownerDocument]); } ``` **Design Decisions:** **Q: Why** `keydown` instead of `keyup`? * `keydown` fires immediately when key is pressed (instant feedback) * `keyup` fires when released (feels laggy) * OS dialogs use `keydown` behavior **Q: Why** `useRef` for the callback? ``` // Problem: Callback changes frequently handleClose()} /> // Without ref: Effect re-runs every render useEffect(() => { document.addEventListener('keydown', callback); // New callback each render! return () => document.removeEventListener('keydown', callback); }, [callback]); // Re-runs constantly! // With ref: Effect runs once, always uses latest callback const callbackRef = useRef(callback); useEffect(() => { callbackRef.current = callback; }, [callback]); useEffect(() => { const handler = (e) => callbackRef.current?.(e); // Always latest! document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, []); // Runs once! ``` **Q: Why** `ownerDocument` parameter? * Supports iframes (each has its own `document`) * SSR safety (`document` might not exist) * Testability (can pass mock document) --- +++ +++ ### Hook 2: usePointerDownOutside [β€ŽGoogle Gemini](https://gemini.google.com/app/5ad815d66a4cb4ff) [β€ŽGemini - direct access to Google AI](https://gemini.google.com/share/0e373ccbf56d) **Purpose:** Detect clicks outside React component tree. ``` function usePointerDownOutside( onPointerDownOutside?: (event: PointerDownOutsideEvent) => void, ownerDocument: Document = globalThis?.document ) { const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener; const isPointerInsideReactTreeRef = React.useRef(false); const handleClickRef = React.useRef(() => {}); React.useEffect(() => { const handlePointerDown = (event: PointerEvent) => { if (event.target && !isPointerInsideReactTreeRef.current) { const eventDetail = { originalEvent: event }; function handleAndDispatchPointerDownOutsideEvent() { handleAndDispatchCustomEvent( POINTER_DOWN_OUTSIDE, handlePointerDownOutside, eventDetail, { discrete: true } ); } // Special handling for touch devices if (event.pointerType === 'touch') { ownerDocument.removeEventListener('click', handleClickRef.current); handleClickRef.current = handleAndDispatchPointerDownOutsideEvent; ownerDocument.addEventListener('click', handleClickRef.current, { once: true }); } else { handleAndDispatchPointerDownOutsideEvent(); } } else { // Click was inside, cleanup pending touch handlers ownerDocument.removeEventListener('click', handleClickRef.current); } isPointerInsideReactTreeRef.current = false; }; // Delay registration to avoid mount race condition const timerId = window.setTimeout(() => { ownerDocument.addEventListener('pointerdown', handlePointerDown); }, 0); return () => { window.clearTimeout(timerId); ownerDocument.removeEventListener('pointerdown', handlePointerDown); ownerDocument.removeEventListener('click', handleClickRef.current); }; }, [ownerDocument, handlePointerDownOutside]); return { // This sets the flag during React's capture phase onPointerDownCapture: () => (isPointerInsideReactTreeRef.current = true), }; } ``` **The Core Trick: Capture Phase Detection** ``` // Event flow: // 1. User clicks // Timeline: // t=0: Click button // t=1: pointerdown fires // t=2: Button handler runs β†’ Modal mounts // t=3: Modal registers document listener immediately // t=4: Same pointerdown event bubbles to document // t=5: Modal listener fires β†’ sees click "outside" β†’ closes! 😱 // With setTimeout(register, 0): // t=0: Click button // t=1: pointerdown fires // t=2: Button handler runs β†’ Modal mounts // t=3: Modal schedules registration for next tick // t=4: Same pointerdown bubbles to document // t=5: Modal listener NOT registered yet β†’ safe! // t=6: Next tick β†’ Modal registers listener βœ… ``` **Touch Device Handling** ``` // Touch timeline: // t=0ms: touchstart // t=50ms: touchend // t=400ms: click (if no scroll detected) if (event.pointerType === 'touch') { // Wait for click event to ensure no scroll happened ownerDocument.addEventListener('click', handler, { once: true }); } else { // Mouse/pen - dismiss immediately handler(); } ``` **Why wait for** `click` on touch? 1. Touch might become a scroll β†’ `click` never fires β†’ no dismissal βœ… 2. Ensures `pointer-events` stays disabled for full 350ms delay 3. Prevents accidental activation of buttons behind modal --- +++ +++ ### Hook 3: useFocusOutside [FE-28](https://linear.app/jinghuangsu/issue/FE-28/usefocusoutside) [β€ŽGemini - direct access to Google AI](https://gemini.google.com/share/7e460d440152) **Purpose:** Detect when focus moves outside React component tree. ``` function useFocusOutside( onFocusOutside?: (event: FocusOutsideEvent) => void, ownerDocument: Document = globalThis?.document ) { const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener; const isFocusInsideReactTreeRef = React.useRef(false); React.useEffect(() => { const handleFocus = (event: FocusEvent) => { if (event.target && !isFocusInsideReactTreeRef.current) { const eventDetail = { originalEvent: event }; handleAndDispatchCustomEvent(FOCUS_OUTSIDE, handleFocusOutside, eventDetail, { discrete: false, }); } }; // Use 'focusin' because 'focus' doesn't bubble! ownerDocument.addEventListener('focusin', handleFocus); return () => ownerDocument.removeEventListener('focusin', handleFocus); }, [ownerDocument, handleFocusOutside]); return { onFocusCapture: () => (isFocusInsideReactTreeRef.current = true), onBlurCapture: () => (isFocusInsideReactTreeRef.current = false), }; } ``` **Why** `focusin` instead of `focus`? ``` // ❌ 'focus' doesn't bubble! document.addEventListener('focus', handler); // NEVER FIRES! // βœ… 'focusin' bubbles document.addEventListener('focusin', handler); // Works! ``` | Event | Bubbles? | Use Case | | -- | -- | -- | | `focus` | ❌ No | Element-specific listeners | | `focusin` | βœ… Yes | Document-level listeners | | `blur` | ❌ No | Element-specific listeners | | `focusout` | βœ… Yes | Document-level listeners | **Why both** `onFocusCapture` AND `onBlurCapture`? ``` // Scenario: Focus moves from inside β†’ outside
flag = true} // Set when entering onBlurCapture={() => flag = false} // Clear when leaving >
// User tabs: A β†’ B β†’ C // Focus on A: // - onFocusCapture fires β†’ flag = true // - focusin on document β†’ flag is true β†’ inside βœ… // Focus on B (still inside): // - A blurs, B focuses // - But onBlurCapture doesn't fire (focus still in tree) // - flag stays true βœ… // Focus on C (outside): // - B loses focus // - onBlurCapture fires β†’ flag = false // - C gains focus // - focusin on document β†’ flag is false β†’ OUTSIDE! 🎯 ``` **No** `setTimeout` needed Unlike `usePointerDownOutside`, no race condition: ``` // Can't focus an element in same event that creates it // Timeline: // t=0: Click button // t=1: Modal mounts // t=2: Button still has focus (can't instantly focus modal) // βœ… No race condition! ``` --- +++ +++ +++ ## Main Component Implementation ### Complete DismissableLayer Component ``` const DismissableLayer = React.forwardRef( (props, forwardedRef) => { const { disableOutsidePointerEvents = false, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, onDismiss, ...layerProps } = props; const context = React.useContext(DismissableLayerContext); const [node, setNode] = React.useState(null); const ownerDocument = node?.ownerDocument ?? globalThis?.document; const [, force] = React.useState({}); const composedRefs = useComposedRefs(forwardedRef, (node) => setNode(node)); // Calculate layer positions const layers = Array.from(context.layers); const [highestLayerWithOutsidePointerEventsDisabled] = [...context.layersWithOutsidePointerEventsDisabled].slice(-1); const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf(highestLayerWithOutsidePointerEventsDisabled!); const index = node ? layers.indexOf(node) : -1; const isBodyPointerEventsDisabled = context.layersWithOutsidePointerEventsDisabled.size > 0; const isPointerEventsEnabled = index >= highestLayerWithOutsidePointerEventsDisabledIndex; // Hook: Pointer down outside (with branch checking) const pointerDownOutside = usePointerDownOutside((event) => { const target = event.target as HTMLElement; const isPointerDownOnBranch = [...context.branches].some((branch) => branch.contains(target) ); if (!isPointerEventsEnabled || isPointerDownOnBranch) return; onPointerDownOutside?.(event); onInteractOutside?.(event); if (!event.defaultPrevented) onDismiss?.(); }, ownerDocument); // Hook: Focus outside (with branch checking) const focusOutside = useFocusOutside((event) => { const target = event.target as HTMLElement; const isFocusInBranch = [...context.branches].some((branch) => branch.contains(target) ); if (isFocusInBranch) return; onFocusOutside?.(event); onInteractOutside?.(event); if (!event.defaultPrevented) onDismiss?.(); }, ownerDocument); // Hook: Escape key (only highest layer responds) useEscapeKeydown((event) => { const isHighestLayer = index === context.layers.size - 1; if (!isHighestLayer) return; onEscapeKeyDown?.(event); if (!event.defaultPrevented && onDismiss) { event.preventDefault(); onDismiss(); } }, ownerDocument); // Effect: Register layer and manage pointer events React.useEffect(() => { if (!node) return; if (disableOutsidePointerEvents) { if (context.layersWithOutsidePointerEventsDisabled.size === 0) { originalBodyPointerEvents = ownerDocument.body.style.pointerEvents; ownerDocument.body.style.pointerEvents = 'none'; } context.layersWithOutsidePointerEventsDisabled.add(node); } context.layers.add(node); dispatchUpdate(); return () => { if ( disableOutsidePointerEvents && context.layersWithOutsidePointerEventsDisabled.size === 1 ) { ownerDocument.body.style.pointerEvents = originalBodyPointerEvents; } }; }, [node, ownerDocument, disableOutsidePointerEvents, context]); // Effect: Cleanup on unmount (separate to preserve creation order) React.useEffect(() => { return () => { if (!node) return; context.layers.delete(node); context.layersWithOutsidePointerEventsDisabled.delete(node); dispatchUpdate(); }; }, [node, context]); // Effect: Listen for context updates React.useEffect(() => { const handleUpdate = () => force({}); document.addEventListener(CONTEXT_UPDATE, handleUpdate); return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate); }, []); return ( ); } ); ``` ### Key Implementation Details +++ #### 1\. Layer Stack Calculation ``` const layers = Array.from(context.layers); const index = node ? layers.indexOf(node) : -1; const isHighestLayer = index === context.layers.size - 1; // Example with 3 modals: // layers = [Modal1, Modal2, Modal3] // Modal1: index=0, size=3, isHighestLayer = 0 === 2 β†’ false // Modal2: index=1, size=3, isHighestLayer = 1 === 2 β†’ false // Modal3: index=2, size=3, isHighestLayer = 2 === 2 β†’ true βœ… ``` +++ +++ #### 2\. Pointer Events Management ``` const isBodyPointerEventsDisabled = context.layersWithOutsidePointerEventsDisabled.size > 0; const isPointerEventsEnabled = index >= highestLayerWithOutsidePointerEventsDisabledIndex; // Logic: // If ANY layer wants disableOutsidePointerEvents: // - Set body.style.pointerEvents = 'none' // - Find highest layer with this prop // - Layers >= that index get pointer-events: auto // - Layers < that index get pointer-events: none ``` **Example:** ``` {/* index=0, no special prop */} {/* index=1 */} {/* index=2, no special prop */} // highestLayerWithOutsidePointerEventsDisabledIndex = 1 // Layer1: index=0 < 1 β†’ pointer-events: none (disabled) // Layer2: index=1 >= 1 β†’ pointer-events: auto (enabled) // Layer3: index=2 >= 1 β†’ pointer-events: auto (enabled) ``` +++ +++ #### 3\. Two Separate Effects [β€ŽGemini - direct access to Google AI](https://gemini.google.com/share/6a155e09c683) **Why two effects instead of one?** ``` // Effect 1: Register + manage pointer events React.useEffect(() => { context.layers.add(node); if (disableOutsidePointerEvents) { context.layersWithOutsidePointerEventsDisabled.add(node); } // ... }, [node, disableOutsidePointerEvents]); // πŸ‘ˆ Runs when prop changes // Effect 2: Cleanup React.useEffect(() => { return () => { context.layers.delete(node); context.layersWithOutsidePointerEventsDisabled.delete(node); }; }, [node]); // πŸ‘ˆ Only runs on unmount ``` **The problem with combining them:** ``` // ❌ Bad: Combined effect React.useEffect(() => { context.layers.add(node); if (disableOutsidePointerEvents) { context.layersWithOutsidePointerEventsDisabled.add(node); } return () => { context.layers.delete(node); context.layersWithOutsidePointerEventsDisabled.delete(node); }; }, [node, disableOutsidePointerEvents]); // If disableOutsidePointerEvents changes: // 1. Cleanup runs β†’ removes from layers Set // 2. Setup runs β†’ adds back to layers Set // Result: Layer moves to END of Set (wrong position in stack!) ``` **With separate effects:** ``` // Effect 1 runs when prop changes: // - Updates pointer events membership // - Doesn't touch layers Set // Effect 2 ONLY runs on unmount: // - Preserves layer order in Set // - Only removes when component unmounts ``` +++ **Example** This is a fantastic question. It touches on a subtle behavior of React `useEffect` combined with how JavaScript `Set` (or Arrays) preserves order. Here is the core concept: **The** `DismissableLayer` system relies on the ORDER of the `layers` Set to know which modal is the "top" one. If you remove a layer and add it back, it moves to the **end** of the line. **The Visual Scenario** Imagine you have two modals open: 1. **Bottom Modal (A)**: A Settings form. 2. **Top Modal (B)**: A "Are you sure?" confirmation dialog on top of A. Correct Stack Order: \[Layer A, Layer B\] Result: Pressing Escape closes Layer B (the last one). Let's simulate what happens when **Layer A** updates (e.g., you toggle a switch inside the Settings modal), causing `disableOutsidePointerEvents` to change. ***1. The "Combined" Effect (BUGGY ❌)*** This effect manages the stack *and* the prop in one place. ``` // Inside Layer A useEffect(() => { // 1. SETUP console.log('Adding Layer A'); globalStack.add('Layer A'); // 2. PROP LOGIC if (props.disablePointer) { /* do something */ } // 3. CLEANUP return () => { console.log('Removing Layer A'); globalStack.delete('Layer A'); }; }, [props.disablePointer]); // πŸ‘ˆ Re-runs when prop changes! ``` **The Timeline of the Bug:** 1. **Mount A:** Stack is `['Layer A']`. 2. **Mount B:** Stack is `['Layer A', 'Layer B']`. (B is on top). 3. **Update A:** You toggle a setting in Layer A. `props.disablePointer` changes. * React runs **Cleanup** for A: Removes A. Stack is `['Layer B']`. * React runs **Setup** for A: Adds A. Stack is `['Layer B', 'Layer A']`. The Result: The stack is now \['Layer B', 'Layer A'\]. The system thinks Layer A is on top! The Crash: You press Escape. It closes the bottom modal (A), leaving the top modal (B) floating in the air. 😱 ***2. The "Separate" Effects (FIXED βœ…)*** We split the logic. One effect manages *existence* (the stack). The other manages *behavior* (the prop). ``` // Effect 1: Registration (Only runs ONCE) useEffect(() => { console.log('Adding Layer A'); globalStack.add('Layer A'); return () => { console.log('Removing Layer A'); globalStack.delete('Layer A'); }; }, []); // πŸ‘ˆ Empty dependency array. NEVER re-runs while mounted. // Effect 2: Prop Logic (Runs whenever needed) useEffect(() => { if (props.disablePointer) { // Just handle the pointer events style. // DO NOT touch the globalStack here. document.body.style.pointerEvents = 'none'; } }, [props.disablePointer]); // πŸ‘ˆ Can run as much as it wants ``` **The Timeline of the Fix:** 1. **Mount A:** Effect 1 runs. Stack is `['Layer A']`. 2. **Mount B:** Effect 1 runs. Stack is `['Layer A', 'Layer B']`. 3. **Update A:** You toggle a setting in Layer A. * **Effect 1:** Does nothing. (Dependencies didn't change). * **Effect 2:** Re-runs to handle the pointer styles. The Result: The stack remains \['Layer A', 'Layer B'\]. The Success: You press Escape. It closes Layer B (the top one). ***Summary*** By separating the effects, we ensure that **Layer A** maintains its "seniority" (its position at index 0) in the stack, even if its internal props change. +++ --- +++ +++ +++ ## Branch Component **Purpose:** Mark portaled content as "logically inside" the modal. ``` const DismissableLayerBranch = React.forwardRef DismissableLayerBranchElement, DismissableLayerBranchProps >((props, forwardedRef) => { const context = React.useContext(DismissableLayerContext); const ref = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); React.useEffect(() => { const node = ref.current; if (node) { context.branches.add(node); return () => { context.branches.delete(node); }; } }, [context.branches]); return ; }); ``` ### How Branch Works ``` // User's code: console.log('closed')}>

Modal

// DatePicker internally portals: function DatePicker() { return ( <> {/* Outside modal in DOM */} ); } ``` **DOM Structure:** ```

Modal

``` **Detection Logic:** ``` const pointerDownOutside = usePointerDownOutside((event) => { const target = event.target; // Where user clicked // Check: Is target inside any Branch? const isPointerDownOnBranch = [...context.branches].some((branch) => branch.contains(target) // DOM .contains() check ); if (isPointerDownOnBranch) return; // Don't dismiss! // Not in a branch β†’ dismiss onDismiss?.(); }, ownerDocument); ``` **The Magic:** ``` // User clicks Calendar const target = calendarElement; // Loop through all branches for (const branch of context.branches) { if (branch.contains(target)) { // branch is the div // target is the element (inside the branch) // branch.contains(target) returns true! βœ… // Don't dismiss! return; } } ``` --- +++ +++ ## Performance Considerations ### Why Mutable Sets? **Benchmark: 10 nested modals mounting** ``` // Approach A: React State (immutable) // Each mount: setState β†’ Context changes β†’ All consumers re-render // Modal 1: 1 re-render // Modal 2: 2 re-renders (Modal 1 re-renders again) // Modal 3: 3 re-renders (Modal 1 & 2 re-render again) // ... // Total: 1+2+3+4+5+6+7+8+9+10 = 55 re-renders // Approach B: Mutable Sets + Custom Events (Radix) // Each mount: Mutate Set β†’ Dispatch event β†’ Only that modal renders // Modal 1: 1 re-render // Modal 2: 1 re-render // Modal 3: 1 re-render // ... // Total: 10 re-renders // Performance: 5.5x fewer re-renders! ``` ### Force Update Cost **Is forcing updates expensive?** ``` const [, force] = React.useState({}); // force({}) does: // 1. Creates new object: {} (cheap) // 2. Triggers re-render of this component only // 3. No Context Provider re-renders // 4. No sibling component re-renders // Cost: ~same as normal setState // Benefit: Precise control over when to update ``` ### Custom Event Overhead ``` function dispatchUpdate() { const event = new CustomEvent(CONTEXT_UPDATE); document.dispatchEvent(event); } // Cost: ~0.1ms (negligible) // Benefit: Decoupled communication between layers ``` ### Trade-offs | Aspect | React State | Mutable Sets + Events | | -- | -- | -- | | **Re-renders** | All consumers | Only listeners | | **Provider overhead** | Re-renders on every change | No provider needed | | **Mental model** | Standard React | Requires understanding custom events | | **Debugging** | React DevTools | Need custom logging | | **Type safety** | Full TypeScript support | Same | **Radix's choice:** Performance > React conventions --- +++ ## Complete Implementation ### Full Production Code ``` /* ------------------------------------------------------------------------------------------------- * DismissableLayer * -----------------------------------------------------------------------------------------------*/ const DISMISSABLE_LAYER_NAME = 'DismissableLayer'; const CONTEXT_UPDATE = 'dismissableLayer.update'; const POINTER_DOWN_OUTSIDE = 'dismissableLayer.pointerDownOutside'; const FOCUS_OUTSIDE = 'dismissableLayer.focusOutside'; let originalBodyPointerEvents: string; const DismissableLayerContext = React.createContext({ layers: new Set(), layersWithOutsidePointerEventsDisabled: new Set(), branches: new Set(), }); type DismissableLayerElement = React.ComponentRef; type PrimitiveDivProps = React.ComponentPropsWithoutRef; interface DismissableLayerProps extends PrimitiveDivProps { disableOutsidePointerEvents?: boolean; onEscapeKeyDown?: (event: KeyboardEvent) => void; onPointerDownOutside?: (event: PointerDownOutsideEvent) => void; onFocusOutside?: (event: FocusOutsideEvent) => void; onInteractOutside?: (event: PointerDownOutsideEvent | FocusOutsideEvent) => void; onDismiss?: () => void; } const DismissableLayer = React.forwardRef( (props, forwardedRef) => { const { disableOutsidePointerEvents = false, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, onDismiss, ...layerProps } = props; const context = React.useContext(DismissableLayerContext); const [node, setNode] = React.useState(null); const ownerDocument = node?.ownerDocument ?? globalThis?.document; const [, force] = React.useState({}); const composedRefs = useComposedRefs(forwardedRef, (node) => setNode(node)); const layers = Array.from(context.layers); const [highestLayerWithOutsidePointerEventsDisabled] = [...context.layersWithOutsidePointerEventsDisabled].slice(-1); const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf(highestLayerWithOutsidePointerEventsDisabled!); const index = node ? layers.indexOf(node) : -1; const isBodyPointerEventsDisabled = context.layersWithOutsidePointerEventsDisabled.size > 0; const isPointerEventsEnabled = index >= highestLayerWithOutsidePointerEventsDisabledIndex; const pointerDownOutside = usePointerDownOutside((event) => { const target = event.target as HTMLElement; const isPointerDownOnBranch = [...context.branches].some((branch) => branch.contains(target) ); if (!isPointerEventsEnabled || isPointerDownOnBranch) return; onPointerDownOutside?.(event); onInteractOutside?.(event); if (!event.defaultPrevented) onDismiss?.(); }, ownerDocument); const focusOutside = useFocusOutside((event) => { const target = event.target as HTMLElement; const isFocusInBranch = [...context.branches].some((branch) => branch.contains(target) ); if (isFocusInBranch) return; onFocusOutside?.(event); onInteractOutside?.(event); if (!event.defaultPrevented) onDismiss?.(); }, ownerDocument); useEscapeKeydown((event) => { const isHighestLayer = index === context.layers.size - 1; if (!isHighestLayer) return; onEscapeKeyDown?.(event); if (!event.defaultPrevented && onDismiss) { event.preventDefault(); onDismiss(); } }, ownerDocument); React.useEffect(() => { if (!node) return; if (disableOutsidePointerEvents) { if (context.layersWithOutsidePointerEventsDisabled.size === 0) { originalBodyPointerEvents = ownerDocument.body.style.pointerEvents; ownerDocument.body.style.pointerEvents = 'none'; } context.layersWithOutsidePointerEventsDisabled.add(node); } context.layers.add(node); dispatchUpdate(); return () => { if ( disableOutsidePointerEvents && context.layersWithOutsidePointerEventsDisabled.size === 1 ) { ownerDocument.body.style.pointerEvents = originalBodyPointerEvents; } }; }, [node, ownerDocument, disableOutsidePointerEvents, context]); React.useEffect(() => { return () => { if (!node) return; context.layers.delete(node); context.layersWithOutsidePointerEventsDisabled.delete(node); dispatchUpdate(); }; }, [node, context]); React.useEffect(() => { const handleUpdate = () => force({}); document.addEventListener(CONTEXT_UPDATE, handleUpdate); return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate); }, []); return ( ); } ); DismissableLayer.displayName = DISMISSABLE_LAYER_NAME; /* ------------------------------------------------------------------------------------------------- * DismissableLayerBranch * -----------------------------------------------------------------------------------------------*/ const BRANCH_NAME = 'DismissableLayerBranch'; type DismissableLayerBranchElement = React.ComponentRef; interface DismissableLayerBranchProps extends PrimitiveDivProps {} const DismissableLayerBranch = React.forwardRef DismissableLayerBranchElement, DismissableLayerBranchProps >((props, forwardedRef) => { const context = React.useContext(DismissableLayerContext); const ref = React.useRef(null); const composedRefs = useComposedRefs(forwardedRef, ref); React.useEffect(() => { const node = ref.current; if (node) { context.branches.add(node); return () => { context.branches.delete(node); }; } }, [context.branches]); return ; }); DismissableLayerBranch.displayName = BRANCH_NAME; /* -----------------------------------------------------------------------------------------------*/ type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>; type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>; function usePointerDownOutside( onPointerDownOutside?: (event: PointerDownOutsideEvent) => void, ownerDocument: Document = globalThis?.document ) { const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener; const isPointerInsideReactTreeRef = React.useRef(false); const handleClickRef = React.useRef(() => {}); React.useEffect(() => { const handlePointerDown = (event: PointerEvent) => { if (event.target && !isPointerInsideReactTreeRef.current) { const eventDetail = { originalEvent: event }; function handleAndDispatchPointerDownOutsideEvent() { handleAndDispatchCustomEvent( POINTER_DOWN_OUTSIDE, handlePointerDownOutside, eventDetail, { discrete: true } ); } if (event.pointerType === 'touch') { ownerDocument.removeEventListener('click', handleClickRef.current); handleClickRef.current = handleAndDispatchPointerDownOutsideEvent; ownerDocument.addEventListener('click', handleClickRef.current, { once: true }); } else { handleAndDispatchPointerDownOutsideEvent(); } } else { ownerDocument.removeEventListener('click', handleClickRef.current); } isPointerInsideReactTreeRef.current = false; }; const timerId = window.setTimeout(() => { ownerDocument.addEventListener('pointerdown', handlePointerDown); }, 0); return () => { window.clearTimeout(timerId); ownerDocument.removeEventListener('pointerdown', handlePointerDown); ownerDocument.removeEventListener('click', handleClickRef.current); }; }, [ownerDocument, handlePointerDownOutside]); return { onPointerDownCapture: () => (isPointerInsideReactTreeRef.current = true), }; } function useFocusOutside( onFocusOutside?: (event: FocusOutsideEvent) => void, ownerDocument: Document = globalThis?.document ) { const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener; const isFocusInsideReactTreeRef = React.useRef(false); React.useEffect(() => { const handleFocus = (event: FocusEvent) => { if (event.target && !isFocusInsideReactTreeRef.current) { const eventDetail = { originalEvent: event }; handleAndDispatchCustomEvent(FOCUS_OUTSIDE, handleFocusOutside, eventDetail, { discrete: false, }); } }; ownerDocument.addEventListener('focusin', handleFocus); return () => ownerDocument.removeEventListener('focusin', handleFocus); }, [ownerDocument, handleFocusOutside]); return { onFocusCapture: () => (isFocusInsideReactTreeRef.current = true), onBlurCapture: () => (isFocusInsideReactTreeRef.current = false), }; } function dispatchUpdate() { const event = new CustomEvent(CONTEXT_UPDATE); document.dispatchEvent(event); } function handleAndDispatchCustomEvent( name: string, handler: ((event: E) => void) | undefined, detail: { originalEvent: OriginalEvent } & (E extends CustomEvent ? D : never), { discrete }: { discrete: boolean } ) { const target = detail.originalEvent.target; const event = new CustomEvent(name, { bubbles: false, cancelable: true, detail }); if (handler) { target.addEventListener(name, handler as EventListener, { once: true }); } if (discrete) { dispatchDiscreteCustomEvent(target, event); } else { target.dispatchEvent(event); } } const Root = DismissableLayer; const Branch = DismissableLayerBranch; DismissableLayer, DismissableLayerBranch, Root, Branch, }; ``` --- +++ ## Usage Examples ### Example 1: Basic Modal ``` function BasicModal() { const [open, setOpen] = useState(false); return ( <> {open && ( setOpen(false)} style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', }} >
e.stopPropagation()} >

Modal Title

Press Escape, click outside, or tab outside to dismiss

)} ); } ``` ### Example 2: Nested Modals ``` function NestedModals() { const [modal1, setModal1] = useState(false); const [modal2, setModal2] = useState(false); return ( <> {modal1 && ( setModal1(false)} style={{ position: 'fixed', top: 100, left: 100, background: 'lightblue', padding: 20, border: '2px solid blue', }} >

Modal 1

{modal2 && ( setModal2(false)} style={{ position: 'fixed', top: 200, left: 200, background: 'lightgreen', padding: 20, border: '2px solid green', }} >

Modal 2

Press Escape - only this closes!

)}
)} ); } ``` ### Example 3: Modal with Portaled Dropdown ``` function ModalWithDropdown() { const [modal, setModal] = useState(false); const [dropdown, setDropdown] = useState(false); return ( <> {modal && ( setModal(false)} style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', }} >
e.stopPropagation()} >

Modal with Dropdown

)} {/* Dropdown portaled to body */} {dropdown && createPortal(

I'm portaled! Click me - modal stays open! βœ…

, document.body )} ); } ``` ### Example 4: Dangerous Action Modal ``` function DangerousActionModal() { const [open, setOpen] = useState(false); return ( <> {open && ( setOpen(false)} style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.8)', }} >
e.stopPropagation()} >

⚠️ Warning

This will delete ALL your data. This action cannot be undone.

)} ); } ``` **Try it:** 1. Open modal 2. Click "Delete All Data" button β†’ Modal closes, nothing happens 3. Click "Delete All Data" again β†’ Alert fires βœ… --- +++ ## Key Takeaways ### When to Use DismissableLayer βœ… **Use for:** * Modal dialogs * Dropdown menus * Popovers * Side panels * Any overlay that should close on outside interaction ❌ **Don't use for:** * Tooltips (use `onPointerLeave` instead) * Inline dropdowns that shouldn't dismiss on click * Non-dismissable modals (loading screens) ### Critical Design Patterns 1. **Mutable Sets + Custom Events** - Performance over React conventions 2. **Capture Phase Detection** - React tree vs DOM tree distinction 3. **Force Update Pattern** - Precise control over re-renders 4. **Branch Pattern** - Safe bubbles for portaled content 5. **Two Effect Pattern** - Preserve creation order in layer stack 6. **Touch Handling** - Wait for click event to handle scroll cancellation 7. **Mount Race Prevention** - Delay listener registration by one tick ### Performance Characteristics * **Re-renders:** O(1) per layer mount (not O(n)) * **Memory:** O(n) where n = number of open layers * **Event overhead:** \~0.1ms per dispatchUpdate * **Scales to:** 100+ nested layers without issues --- ## Conclusion **DismissableLayer** is a masterclass in solving complex UI problems with elegant patterns. Radix chose performance and precision over React conventions, resulting in a utility that handles edge cases most developers never consider. Key innovations: * Mutable Sets avoid Context re-render cascades * Custom events enable decoupled communication * Capture phase distinguishes React tree from DOM tree * Branch pattern solves the portaled content problem * Separate effects preserve layer creation order This implementation is production-ready and handles all real-world scenarios including nested modals, touch devices, keyboard navigation, and portaled content. **Next Steps:** * Study other Radix primitives (Portal, FocusScope, Presence) * Build your own component library using these patterns * Contribute to Radix UI on GitHub --- **Resources:** * [Radix UI GitHub]() * [DismissableLayer Source]() * [Web API: EventTarget.addEventListener]() * [React: useRef Hook]() --- ## Radix UI - Roving Focus - **URL:** https://www.jinghuangsu.com/til/radix-ui-roving-focus - **Date:** 2026-01-25 - **Category:** radix-ui - **Tags:** React, JavaScript, UX ## What is Roving Focus? Roving Focus (also called "Roving TabIndex") is an accessibility pattern for managing keyboard navigation in collections of interactive elements. Real-World Examples: - Toolbar: Bold, Italic, Underline buttons - Tab List: Home, Profile, Settings tabs - Radio Group: Payment method options - Menu Bar: File, Edit, View menus (when horizontal) The User Experience: ```mdx Without RovingFocus: [Search] β†’ Tab β†’ [B1] β†’ Tab β†’ [B2] β†’ Tab β†’ [B3] β†’ Tab β†’ [B4] β†’ Tab β†’ [B5] β†’ Tab β†’ [Content] (User must press Tab 5 times to get through toolbar!) With RovingFocus: [Search] β†’ Tab β†’ [Toolbar] β†’ Tab β†’ [Content] (Inside toolbar: Arrow keys navigate between B1-B5) (User presses Tab once, toolbar acts as single tab stop!) ``` ## Why Does It Exist? ### Problem 1: Tab Key Efficiency Without roving focus, users must tab through every single item in a collection. A toolbar with 20 buttons requires 20 tab presses! ### Problem 2: Semantic Grouping Related items should be treated as a single "unit" in the page's tab order. A toolbar is conceptually ONE thing, not 20 separate things. ### Problem 3: Accessibility Standards ARIA Authoring Practices Guide (APG) recommends this pattern for: - Toolbars - Tab lists - Radio groups - Grid navigation - Menu bars > The WCAG Principle: > > "Keyboard users should be able to navigate efficiently without getting lost in collections of similar items." ## Core Concepts ### Focus Management vs Roving Focus ### The Single Tab Stop Rule Only ONE item in the group is part of the page's tab order at any time. ```ts // At any given moment:
{/* Wrapper: tabindex may be 0 or -1 */} {/* The "chosen one" */} {/* Skipped by Tab */} {/* Skipped by Tab */}
``` ### Two Navigation Modes #### Tab Navigation (Between Groups): - Browser's native behavior - Jumps between major page sections - Respects tab order #### Navigation (Within Group): - Implemented by RovingFocus - Moves within the collection - Optional loop behavior ### The TabIndex Strategy #### Understanding TabIndex Values: | Value | Tab Key Behavior | JS .focus() | Mouse Click | | :--- | :--- | :--- | :--- | | `0` | Included in tab order | Focusable | Focusable | | `-1` | Skipped by tab order | Focusable | Focusable | | `> 0` | Custom order (anti-pattern) | Focusable | Focusable | ### The Key Insight: **tabindex={-1} is NOT unfocusable!** - It's skipped by Tab key - But `.focus()` still works - And clicks still work This is what makes RovingFocus possible: ```js // User presses Arrow Right on B1 (which has tabindex={0}) onKeyDown={(event) => { if (event.key === 'ArrowRight') { // B2 has tabindex={-1}, but we can still focus it! b2Element.focus(); // Update state so B2 becomes the new "chosen one" setCurrentTabStopId('b2'); } } ``` ### The Roving Mechanism: ```mdx State Change Flow: 1. currentTabStopId: 'b1' β†’ ); } const styles = { container: { fontFamily: "'Figtree', -apple-system, sans-serif", padding: '20px', background: '#f9fafb', minHeight: '100vh', margin: 'auto 0', }, controlPanel: { display: 'flex', justifyContent: 'center', gap: '12px', marginBottom: '20px', }, button: { padding: '10px 24px', fontSize: '14px', fontWeight: '600', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', transition: 'transform 0.1s', }, grid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '20px', }, card: { background: '#ffffff', borderRadius: '12px', padding: '16px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)', }, cardHeader: { fontSize: '14px', fontWeight: '700', marginBottom: '12px', padding: '8px 12px', borderRadius: '6px', textAlign: 'center', }, viewport: { position: 'relative', height: '140px', background: '#f8fafc', borderRadius: '8px', border: '2px dashed #e5e7eb', overflow: 'hidden', }, reference: { position: 'absolute', top: '30px', transform: 'translateX(-50%)', padding: '6px 12px', background: 'rgba(99, 102, 241, 0.1)', border: '2px solid #001858', borderRadius: '6px', fontSize: '11px', fontWeight: '600', color: '#001858', whiteSpace: 'nowrap', transition: 'none', }, tooltip: { position: 'absolute', top: '85px', transform: 'translateX(-50%)', padding: '6px 12px', border: '2px solid #33272a', borderRadius: '6px', fontSize: '11px', fontWeight: '600', color: '#33272a', whiteSpace: 'nowrap', transition: 'none', }, lagLine: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', }, stats: { marginTop: '12px', padding: '10px', background: '#f8fafc', borderRadius: '6px', fontSize: '12px', color: '#6b7280', }, statRow: { display: 'flex', justifyContent: 'space-between', marginBottom: '4px', }, explanation: { marginTop: '8px', fontSize: '11px', color: '#9ca3af', textAlign: 'center', lineHeight: '1.4', }, }; ``` **Key insight**: `flushSync` renders MORE often (see render count), but each render shows the CURRENT position. Batching renders less often, but skips intermediate positions causing visual lag. ### Safety Guards in update() The update function has three layers of protection: ```js // Guard 1: Elements must exist if (!referenceRef.current || !floatingRef.current) { return; } // Guard 2: Component must still be mounted (checked after async) if (isMountedRef.current && ...) { // Safe to update } // Guard 3: Data must have changed (prevents infinite loops) if (!deepEqual(dataRef.current, fullData)) { // Worth updating } ``` The mounted check is crucial for async operations: ```mdx Component mounts β”‚ β–Ό update() called β”‚ β–Ό computePosition() starts (ASYNC!) β”‚ β”‚ ──── User navigates away ──── β”‚ β”‚ β”‚ β–Ό β”‚ Component unmounts β”‚ isMountedRef.current = false β”‚ β–Ό Promise resolves β”‚ β–Ό Check: isMountedRef.current? β”‚ β”œβ”€β”€ false: Return early (avoid setState on unmounted) β”‚ └── true: Safe to setData() ``` ## The isPositioned Flag `isPositioned` answers: "Has the floating element been placed at least once?" This is critical for animations: ```js // Common pattern: Fade in AFTER positioned
Tooltip content
``` **Without isPositioned:** ```mdx Initial render: x=0, y=0 (default) β”‚ β–Ό Tooltip appears at (0,0) - TOP LEFT CORNER! β”‚ β–Ό computePosition resolves β”‚ β–Ό Tooltip JUMPS to correct position ``` **With isPositioned:** ```mdx Initial render: x=0, y=0, isPositioned=false β”‚ β–Ό Tooltip invisible (opacity: 0) β”‚ β–Ό computePosition resolves, isPositioned=true β”‚ β–Ό Tooltip fades in at correct position ``` ### Connection to the open Prop ```js // When computing position isPositioned: openRef.current !== false, // When open changes to false useLayoutEffect(() => { if (open === false && dataRef.current.isPositioned) { dataRef.current.isPositioned = false; setData((data) => ({...data, isPositioned: false})); } }, [open]); ``` This resets `isPositioned` when the floating element closes, so it can animate in again on the next open. ## whileElementsMounted: The Subscription Lifecycle ### The Problem `computePosition` gives you position at ONE moment. But elements move when: - User scrolls - Window resizes - Content changes size - Layout shifts occur ### The Solution `whileElementsMounted` is a callback that sets up continuous updates: ```js const { refs } = useFloating({ whileElementsMounted: autoUpdate, // or with options: whileElementsMounted: (reference, floating, update) => { return autoUpdate(reference, floating, update, { ancestorScroll: true, ancestorResize: true, elementResize: true, layoutShift: true, animationFrame: false, }); }, }); ``` ### Lifecycle Visualization ```mdx β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Phase 1: Elements Mount β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ setReference(btn) ───── setFloating(tooltip) β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Phase 2: Effect Runs β”‚ β”‚ β”‚ β”‚ if (referenceEl && floatingEl) { β”‚ β”‚ return whileElementsMounted(ref, float, update); β”‚ β”‚ } β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Phase 3: autoUpdate Sets Up Listeners β”‚ β”‚ β”‚ β”‚ - Scroll listeners on ancestor elements β”‚ β”‚ - Resize listeners on window β”‚ β”‚ - ResizeObserver on both elements β”‚ β”‚ - IntersectionObserver for layout shift detection β”‚ β”‚ β”‚ β”‚ Returns cleanup function β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ User scrolls ────────┼───► update() called Window resizes ──────┼───► update() called Content changes ─────┼───► update() called β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Phase 4: Cleanup (unmount or elements change) β”‚ β”‚ β”‚ β”‚ Effect cleanup runs: β”‚ β”‚ - Remove scroll listeners β”‚ β”‚ - Remove resize listeners β”‚ β”‚ - Disconnect observers β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### Why Use a Ref for whileElementsMounted? ```js const whileElementsMountedRef = useLatestRef(whileElementsMounted); ``` If we used the prop directly in the effect dependencies: ```js // Problem: Effect re-runs on every render if user passes inline function useLayoutEffect(() => { if (referenceEl && floatingEl && whileElementsMounted) { return whileElementsMounted(referenceEl, floatingEl, update); } }, [referenceEl, floatingEl, whileElementsMounted]); // Unstable! // User's code: useFloating({ whileElementsMounted: (ref, float, update) => autoUpdate(ref, float, update), // ↑ New function every render = effect re-runs constantly! }); ``` With the ref pattern: ```js const whileElementsMountedRef = useLatestRef(whileElementsMounted); useLayoutEffect(() => { if (referenceEl && floatingEl && whileElementsMountedRef.current) { return whileElementsMountedRef.current(referenceEl, floatingEl, update); } }, [referenceEl, floatingEl]); // Stable deps! Ref not in dependency array. ``` ## floatingStyles: The CSS Output ```js const floatingStyles = useMemo(() => { const initialStyles = { position: strategy, left: 0, top: 0, }; if (!elements.floating) { return initialStyles; } const x = roundByDPR(elements.floating, data.x); const y = roundByDPR(elements.floating, data.y); if (transform) { return { ...initialStyles, transform: `translate(${x}px, ${y}px)`, ...(getDPR(elements.floating) >= 1.5 && {willChange: 'transform'}), }; } return { position: strategy, left: x, top: y, }; }, [strategy, transform, elements.floating, data.x, data.y]); ``` ### Transform vs Layout Positioning | Aspect | `transform` | Layout (`left`/`top`) | | -------------------- | ------------------------ | ------------------------- | | **Performance** | GPU accelerated | Triggers layout recalc | | **Subpixel render** | Smooth | Can be blurry | | **Animation** | Smooth | Can be janky | | **Stacking context** | Creates new one | Doesn't | **Transform (default):** ```css .floating { position: absolute; left: 0; top: 0; transform: translate(150px, 200px); will-change: transform; /* Only on high DPR screens */ } ``` **Layout positioning (transform: false):** ```css .floating { position: absolute; left: 150px; top: 200px; } ``` ```jsx file=App.js const colors = { bgPage: '#f0f2f5', sidebar: '#ffffff', border: '#d1d5db', layoutFill: '#fee2e2', // Light Red layoutStroke: '#ef4444', // Red transformFill: '#dcfce7', // Light Green transformStroke: '#22c55e',// Green pushedFill: '#f3f4f6', // Light Gray pushedStroke: '#6b7280', // Gray textMuted: '#4b5563' }; const styles = { app: { display: 'flex', backgroundColor: colors.bgPage, color: '#1f2937' }, sidebar: { width: '320px', backgroundColor: colors.sidebar, borderRight: `1px solid ${colors.border}`, padding: '24px', display: 'flex', flexDirection: 'column', boxShadow: '4px 0 24px rgba(0,0,0,0.02)', zIndex: 10, overflowY: 'auto' }, sidebarHeader: { fontSize: '1.25em', fontWeight: '700', borderBottom: `1px solid ${colors.border}`, paddingBottom: '16px', marginBottom: '24px', color: '#111827' }, canvas: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '40px', backgroundColor: '#fafafa' }, containerWrapper: { width: '100%', maxWidth: '600px', background: 'white', padding: '40px', borderRadius: '12px', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', border: `1px solid ${colors.border}` }, container: { width: '100%', height: '120px', border: `2px dashed ${colors.border}`, display: 'flex', alignItems: 'center', padding: '0 20px', position: 'relative', borderRadius: '8px', backgroundColor: '#fdfdfd', overflow: 'hidden' }, box: { width: '80px', height: '80px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', borderRadius: '8px', fontSize: '14px', flexShrink: 0, boxShadow: '0 4px 6px rgba(0,0,0,0.05)', transition: 'background-color 0.2s, border-color 0.2s' }, layoutBox: { backgroundColor: colors.layoutFill, border: `2px solid ${colors.layoutStroke}`, color: colors.layoutStroke }, transformBox: { position: 'absolute', top: '18px', left: '20px', backgroundColor: colors.transformFill, border: `2px solid ${colors.transformStroke}`, color: colors.transformStroke, zIndex: 10 }, pushedBox: { width: '80px', height: '80px', backgroundColor: colors.pushedFill, border: `2px solid ${colors.pushedStroke}`, color: colors.pushedStroke, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', borderRadius: '8px', fontSize: '14px', flexShrink: 0, marginLeft: '10px' }, explanation: { fontSize: '13px', color: colors.textMuted, marginTop: 'auto', lineHeight: '1.5', backgroundColor: '#f3f4f6', padding: '16px', borderRadius: '8px', border: `1px solid ${colors.border}` }, controlGroup: { marginBottom: '24px' }, label: { display: 'block', marginBottom: '8px', fontWeight: '600', fontSize: '14px', color: '#374151' }, select: { width: '100%', padding: '10px', borderRadius: '6px', border: `1px solid ${colors.border}`, backgroundColor: 'white', fontSize: '14px', cursor: 'pointer' }, range: { width: '100%', cursor: 'pointer', accentColor: '#4f46e5' }, checkboxWrapper: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '8px', fontSize: '14px', cursor: 'pointer' }, infoTag: { display: 'inline-block', padding: '2px 6px', borderRadius: '4px', fontSize: '11px', fontWeight: 'bold', marginLeft: '8px', backgroundColor: '#e0e7ff', color: '#4338ca' } }; const App = () => { const [x, setX] = useState(10.5); // Start with fractional value to demonstrate const [mode, setMode] = useState('layout'); const [snapToDPR, setSnapToDPR] = useState(false); const [dpr, setDpr] = useState(1); useEffect(() => { if (typeof window !== 'undefined') { setDpr(window.devicePixelRatio || 1); } }, []); // DPR Handling Logic const roundByDPR = (value) => { return Math.round(value * dpr) / dpr; }; const effectiveX = snapToDPR ? roundByDPR(x) : x; return (

DPR: {dpr}

setX(parseFloat(e.target.value))} style={styles.range} />
{mode === 'layout' ? (

⚠️ Layout Positioning

  • Performance: Triggers expensive layout recalculations (CPU).
  • Pixel Grid: Layout engines often force integer snapping automatically.
) : (

⚑ Transform (Composite)

  • Sub-pixels: Allows placing elements between pixels.
  • Blur Risk: Without snapping, static elements at .5 coords may blur.
  • Optimization: {dpr >= 1.5 ? 'High DPR detected; `will-change` applied.' : 'Standard DPR.'}
)} {snapToDPR && (
Active: Coordinates are rounding to the nearest 1/{dpr} pixel to ensure sharp edges.
)}

{mode === 'layout' ? 'Layout Shift' : 'Hardware Accelerated'}

{mode === 'layout' ? (
Layout
Sibling
) : (
= 1.5 ? 'transform' : 'auto' }}> Transform
Sibling
)}

Current Input: {x}px
Rendered at: {effectiveX}px

); }; ``` ### DPR (Device Pixel Ratio) Handling ```js function getDPR(element: Element): number { if (typeof window === 'undefined') return 1; const win = element.ownerDocument.defaultView || window; return win.devicePixelRatio || 1; } function roundByDPR(element: Element, value: number) { const dpr = getDPR(element); return Math.round(value * dpr) / dpr; } ``` **Why round by DPR?** On high-DPI screens, CSS pixels map to multiple physical pixels. Positioning at fractional CSS pixels can cause the element to straddle physical pixel boundaries, causing blurriness. ```mdx DPR = 2 (each CSS pixel = 2x2 physical pixels) Without rounding (value = 150.3): β”Œβ”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β” β”‚ β”‚ β”‚β–’β–’β”‚ β”‚ β”‚ ← Partial pixel coverage = BLUR β””β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”˜ With rounding (snaps to 150.5): β”Œβ”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β”¬β”€β”€β” β”‚ β”‚ β”‚β–ˆβ–ˆβ”‚ β”‚ β”‚ ← Full pixel coverage = SHARP β””β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”΄β”€β”€β”˜ ``` **Why willChange only on high DPR?** ```js ...(getDPR(elements.floating) >= 1.5 && {willChange: 'transform'}), ``` - High-DPR screens benefit most from GPU acceleration - `will-change` has memory overhead - Low-DPR screens don't need the performance hint ## Complete Data Flow ```mdx β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 1. INITIALIZATION β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ const [data, setData] = useState({ β”‚ β”‚ x: 0, y: 0, placement, strategy, β”‚ β”‚ middlewareData: {}, isPositioned: false β”‚ β”‚ }); β”‚ β”‚ β”‚ β”‚ referenceRef = useRef(null) β”‚ β”‚ floatingRef = useRef(null) β”‚ β”‚ isMountedRef = useRef(false) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 2. USER ATTACHES REFS β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ β”‚ β”‚
Tooltip
β”‚ β”‚ β”‚ β”‚ setReference(buttonEl): β”‚ β”‚ referenceRef.current = buttonEl (sync) β”‚ β”‚ _setReference(buttonEl) (triggers re-render) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 3. LAYOUT EFFECT RUNS β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ useLayoutEffect(() => { β”‚ β”‚ if (referenceEl && floatingEl) { β”‚ β”‚ if (whileElementsMounted) { β”‚ β”‚ return whileElementsMounted(ref, float, update); β”‚ β”‚ } β”‚ β”‚ update(); β”‚ β”‚ } β”‚ β”‚ }, [referenceEl, floatingEl, ...]); β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 4. UPDATE FUNCTION β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ update(): β”‚ β”‚ if (!referenceRef.current || !floatingRef.current) return β”‚ β”‚ β”‚ β”‚ computePosition(ref, float, config) β”‚ β”‚ .then(data => { β”‚ β”‚ if (!isMountedRef.current) return // Unmounted guard β”‚ β”‚ if (deepEqual(dataRef.current, data)) return // No-op β”‚ β”‚ β”‚ β”‚ dataRef.current = data β”‚ β”‚ flushSync(() => setData(data)) // Sync update! β”‚ β”‚ }) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 5. RE-RENDER WITH NEW DATA β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ floatingStyles = { β”‚ β”‚ position: 'absolute', β”‚ β”‚ left: 0, β”‚ β”‚ top: 0, β”‚ β”‚ transform: 'translate(150px, 200px)', β”‚ β”‚ } β”‚ β”‚ β”‚ β”‚ return { x, y, placement, isPositioned, floatingStyles, ... } β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 6. USER APPLIES STYLES β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚
β”‚ β”‚ Tooltip at correct position β”‚ β”‚
β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 7. ONGOING UPDATES (via autoUpdate) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ User scrolls ──► autoUpdate calls update() ──► Position fixed β”‚ β”‚ Window resizes β–Ί autoUpdate calls update() ──► Position fixed β”‚ β”‚ Layout shift ──► autoUpdate calls update() ──► Position fixed β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ 8. CLEANUP (unmount) β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ isMountedRef.current = false β”‚ β”‚ autoUpdate cleanup runs (removes listeners) β”‚ β”‚ Any pending computePosition results ignored β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## Full Implementation Here's the complete source code with the patterns we discussed in action: ```js height="600px" ComputePositionConfig, ReferenceType, UseFloatingData, UseFloatingOptions, UseFloatingReturn, } from './types'; options: UseFloatingOptions = {}, ): UseFloatingReturn { const { placement = 'bottom', strategy = 'absolute', middleware = [], platform, elements: {reference: externalReference, floating: externalFloating} = {}, transform = true, whileElementsMounted, open, } = options; // Position data state - drives the UI const [data, setData] = React.useState({ x: 0, y: 0, strategy, placement, middlewareData: {}, isPositioned: false, }); // Middleware comparison (deep equality check) const [latestMiddleware, setLatestMiddleware] = React.useState(middleware); if (!deepEqual(latestMiddleware, middleware)) { setLatestMiddleware(middleware); } // DUAL STATE + REF PATTERN: State triggers effects, refs give current values const [_reference, _setReference] = React.useState(null); const [_floating, _setFloating] = React.useState(null); const setReference = React.useCallback((node: RT | null) => { if (node !== referenceRef.current) { referenceRef.current = node; // Immediate for async callbacks _setReference(node); // Triggers effects } }, []); const setFloating = React.useCallback((node: HTMLElement | null) => { if (node !== floatingRef.current) { floatingRef.current = node; _setFloating(node); } }, []); const referenceEl = (externalReference || _reference) as RT | null; const floatingEl = externalFloating || _floating; // Refs for always-current values const referenceRef = React.useRef(null); const floatingRef = React.useRef(null); const dataRef = React.useRef(data); // LATEST REF PATTERN: Avoid stale closures without adding dependencies const hasWhileElementsMounted = whileElementsMounted != null; const whileElementsMountedRef = useLatestRef(whileElementsMounted); const platformRef = useLatestRef(platform); const openRef = useLatestRef(open); // THE UPDATE FUNCTION: Bridge between async computePosition and React const update = React.useCallback(() => { if (!referenceRef.current || !floatingRef.current) { return; } const config: ComputePositionConfig = { placement, strategy, middleware: latestMiddleware, }; if (platformRef.current) { config.platform = platformRef.current; } computePosition(referenceRef.current, floatingRef.current, config).then( (data) => { const fullData = { ...data, isPositioned: openRef.current !== false, }; // Guards: mounted check + deep equality to prevent loops if (isMountedRef.current && !deepEqual(dataRef.current, fullData)) { dataRef.current = fullData; // flushSync for immediate updates during scroll/resize ReactDOM.flushSync(() => { setData(fullData); }); } }, ); }, [latestMiddleware, placement, strategy, platformRef, openRef]); // Reset isPositioned when closing useModernLayoutEffect(() => { if (open === false && dataRef.current.isPositioned) { dataRef.current.isPositioned = false; setData((data) => ({...data, isPositioned: false})); } }, [open]); // Track mounted state for async safety const isMountedRef = React.useRef(false); useModernLayoutEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); // MAIN EFFECT: Connect elements and set up subscriptions useModernLayoutEffect(() => { if (referenceEl) referenceRef.current = referenceEl; if (floatingEl) floatingRef.current = floatingEl; if (referenceEl && floatingEl) { if (whileElementsMountedRef.current) { // Return cleanup function from whileElementsMounted (e.g., autoUpdate) return whileElementsMountedRef.current(referenceEl, floatingEl, update); } update(); } }, [ referenceEl, floatingEl, update, whileElementsMountedRef, hasWhileElementsMounted, ]); // Memoized return values const refs = React.useMemo( () => ({ reference: referenceRef, floating: floatingRef, setReference, setFloating, }), [setReference, setFloating], ); const elements = React.useMemo( () => ({reference: referenceEl, floating: floatingEl}), [referenceEl, floatingEl], ); // CSS OUTPUT: Transform-based positioning with DPR handling const floatingStyles = React.useMemo(() => { const initialStyles = { position: strategy, left: 0, top: 0, }; if (!elements.floating) { return initialStyles; } const x = roundByDPR(elements.floating, data.x); const y = roundByDPR(elements.floating, data.y); if (transform) { return { ...initialStyles, transform: `translate(${x}px, ${y}px)`, ...(getDPR(elements.floating) >= 1.5 && {willChange: 'transform'}), }; } return { position: strategy, left: x, top: y, }; }, [strategy, transform, elements.floating, data.x, data.y]); return React.useMemo( () => ({ ...data, update, refs, elements, floatingStyles, }), [data, update, refs, elements, floatingStyles], ); } ``` ## Key Takeaways 1. **Dual state + ref pattern** solves the tension between triggering effects and accessing current values in async callbacks 2. **useLatestRef** keeps values fresh in callbacks without adding unstable dependencies 3. **flushSync** ensures immediate rendering during rapid updates like scrolling 4. **isMountedRef** guards against setState on unmounted components after async operations 5. **isPositioned** enables smooth animations by distinguishing initial placement from subsequent updates 6. **Transform positioning** with DPR rounding provides crisp, performant rendering 7. **whileElementsMounted** abstracts the subscription lifecycle for continuous position updates These patterns aren't unique to Floating UI - they're broadly applicable solutions for bridging imperative/async APIs with React's declarative model. ## Series Navigation This article is part of a deep dive series on Floating UI: | Article | Topic | Key Concepts | |---------|-------|--------------| | | How positions are calculated | Browser coordinates, placements, axes, `computeCoordsFromPlacement` | | | How positions are adjusted | `offset`, `detectOverflow`, `shift`, `flip`, pipeline architecture | | **Part 3: useFloating** (this article) | How it works in React | Dual state/ref, `flushSync`, `whileElementsMounted`, `floatingStyles` |
--- ## Floating UI - Middleware Deep Dive - **URL:** https://www.jinghuangsu.com/til/floating-ui-middleware-in-deep - **Date:** 2026-01-18 - **Category:** floating-ui - **Tags:** React, JavaScript, UI Library In , we explored the pipeline architecture, which includes the loop, reset mechanism, and data accumulation. Today let's dive into the `offset`, `flip` and `shift` middleware. | Middleware | Purpose | Overflow Check | Reset Behavior | |------------|---------|----------------|----------------| | `offset` | Create gap | No overflow check | No reset | | `shift` | Slide to fit | Uses `detectOverflow` | No reset | | `flip` | Change side | Uses `detectOverflow` | TRIGGERS RESET | ## offset Let's look at our first middleware, the `offset` middleware creates visual breathing room between reference and floating elements. It's the simplest because it **doesn't check overflow** and **never triggers reset**. ```ts title="floating-ui/.../middleware/offset.ts" name: 'offset', options, async fn(state) { const {x, y, placement, middlewareData} = state; const diffCoords = await convertValueToCoords(state, options); // This enables the early bail-out on subsequent passes: // When `arrow` middleware triggers a reset due to alignment issues, `offset` checks if the placement is unchanged. // If so, it skips recalculation to avoid infinite loops. if ( placement === middlewareData.offset?.placement && middlewareData.arrow?.alignmentOffset ) { return {}; } return { x: x + diffCoords.x, y: y + diffCoords.y, data: { ...diffCoords, placement, }, }; }, }); ``` The heavy lifting happens in `convertValueToCoords`: ```ts state: MiddlewareState, options: OffsetOptions, ): Promise { const {placement, platform, elements} = state; const rtl = await platform.isRTL?.(elements.floating); const side = getSide(placement); // 'top' | 'right' | 'bottom' | 'left' const alignment = getAlignment(placement); // 'start' | 'end' | null const isVertical = getSideAxis(placement) === 'y'; // Determine push direction based on placement const mainAxisMulti = ['top', 'left'].includes(side) ? -1 : 1; const crossAxisMulti = rtl && isVertical ? -1 : 1; const rawValue = evaluate(options, state); let {mainAxis, crossAxis, alignmentAxis} = typeof rawValue === 'number' ? {mainAxis: rawValue, crossAxis: 0, alignmentAxis: null} : { mainAxis: rawValue.mainAxis || 0, crossAxis: rawValue.crossAxis || 0, alignmentAxis: rawValue.alignmentAxis, }; // alignmentAxis overrides crossAxis for aligned placements if (alignment && typeof alignmentAxis === 'number') { crossAxis = alignment === 'end' ? alignmentAxis * -1 : alignmentAxis; } // Convert to x,y based on whether placement is vertical return isVertical ? {x: crossAxis * crossAxisMulti, y: mainAxis * mainAxisMulti} : {x: mainAxis * mainAxisMulti, y: crossAxis * crossAxisMulti}; } ``` ### The Three Axes `offset` accepts three different axis values: | Axis | Description | Use Case | |------|-------------|----------| | `mainAxis` | Distance away from reference | Gap between button and tooltip | | `crossAxis` | Shift along the reference edge | Horizontal nudge for `bottom` placement | | `alignmentAxis` | Like crossAxis, but inverts for `end` | Consistent behavior for `-start`/`-end` | ```js file=index.html convertValueToCoords - Deep Dive
isVertical = true (placement: 'top' or 'bottom') Reference Floating mainAxis = Y crossAxis = X isVertical = false (placement: 'left' or 'right') Reference Floating mainAxis = X crossAxis = Y
``` ### Understanding mainAxisMulti The `mainAxisMulti` determines which direction "away from reference" means: ```ts const mainAxisMulti = ['top', 'left'].includes(side) ? -1 : 1; ``` ```js file=index.html convertValueToCoords - Deep Dive
placement: 'top' mainAxisMulti = -1 Reference Origin Floating y -= 10 (Push UP) placement: 'bottom' mainAxisMulti = 1 Reference Origin Floating y += 10 (Push DOWN) placement: 'left' mainAxisMulti = -1 Ref Origin Float x -= 10 (Push LEFT) placement: 'right' mainAxisMulti = 1 Ref Origin Float x += 10 (Push RIGHT)
``` ### Practical Examples ```ts // Simple number: applies to mainAxis only offset(10) // β†’ { mainAxis: 10, crossAxis: 0 } // Object form: full control offset({ mainAxis: 10, crossAxis: 5 }) // With alignmentAxis for aligned placements offset({ mainAxis: 10, alignmentAxis: 5 }) // For 'bottom-start': shifts right // For 'bottom-end': shifts left (inverted!) ``` Before diving into `shift` and `flip` middleware, we need to understand their shared foundation: `detectOverflow`. ## The Shared Foundation: detectOverflow Both `shift` and `flip` need to answer the same question: **"How much does the floating element overflow each edge of the viewport?"** ### The Core Formula **The convention:** Positive values mean overflow, negative values mean available space. ```ts type SideObject = { top: number; right: number; bottom: number; left: number; }; function detectOverflow(floatingRect: Rect, boundaryRect: Rect): SideObject { return { top: boundaryRect.y - floatingRect.y, right: (floatingRect.x + floatingRect.width) - (boundaryRect.x + boundaryRect.width), bottom: (floatingRect.y + floatingRect.height) - (boundaryRect.y + boundaryRect.height), left: boundaryRect.x - floatingRect.x, }; } ``` For **right** and **bottom**, the floating edge exceeds the boundary when it's **greater**: ```js floatingRight (680) > boundaryRight (600) // overflow! formula: floatingRight - boundaryRight = 680 - (600) = 80 ``` ```js file=index.html detectOverflow - Right Side Illustration
x y (0,0) Viewport boundary viewportRight 600 Floating Element floatingRight 80px Overflow
``` For **top** and **left**, the floating edge exceeds the boundary when it's **less** (goes negative): We flip the subtraction order so positive always means overflow. ```js floatingTop (80) < boundaryTop (140) // overflow! formula: boundaryTop - floatingTop = 140 - (80) = 60 ``` ```js file=index.html detectOverflow - Right Side Illustration
x y (0,0) Viewport Boundary viewportTop 140 Floating Element floatingTop 80 60px Overflow
``` ## shift The `shift` middleware keeps the floating element within viewport bounds by **sliding it along edges** without changing placement. Unlike `flip` which switches to a different side when there's overflow, `shift` maintains the same placement direction and simply adjusts the position to fit within the boundary. ### Key Characteristics - **Uses** `detectOverflow` to measure overflow on each edge - **Modifies** x/y coordinates directly by sliding the floating element - **Never triggers reset**, placement stays the same throughout - **Operates on mainAxis by default**, crossAxis disabled (must opt-in) - **Works with limiters** like `limitShift` to constrain the shifting behavior ```ts title="floating-ui/.../middleware/shift.ts" height="300px" options: ShiftOptions | Derivable = {}, ): Middleware => ({ name: 'shift', options, async fn(state) { const {x, y, placement, platform} = state; const { mainAxis: checkMainAxis = true, crossAxis: checkCrossAxis = false, // ← Disabled by default! limiter = {fn: ({x, y}: Coords) => ({x, y})}, ...detectOverflowOptions } = evaluate(options, state); const coords = {x, y}; const overflow = await platform.detectOverflow(state, detectOverflowOptions); // Determine which axis is which based on placement const crossAxis = getSideAxis(getSide(placement)); // 'x' or 'y' const mainAxis = getOppositeAxis(crossAxis); // opposite let mainAxisCoord = coords[mainAxis]; let crossAxisCoord = coords[crossAxis]; // Shift on mainAxis (parallel to reference edge) if (checkMainAxis) { const minSide = mainAxis === 'y' ? 'top' : 'left'; const maxSide = mainAxis === 'y' ? 'bottom' : 'right'; const min = mainAxisCoord + overflow[minSide]; const max = mainAxisCoord - overflow[maxSide]; mainAxisCoord = clamp(min, mainAxisCoord, max); } // Shift on crossAxis (toward/away from reference) if (checkCrossAxis) { const minSide = crossAxis === 'y' ? 'top' : 'left'; const maxSide = crossAxis === 'y' ? 'bottom' : 'right'; const min = crossAxisCoord + overflow[minSide]; const max = crossAxisCoord - overflow[maxSide]; crossAxisCoord = clamp(min, crossAxisCoord, max); } // Apply limiter (e.g., limitShift) const limitedCoords = limiter.fn({ ...state, [mainAxis]: mainAxisCoord, [crossAxis]: crossAxisCoord, }); return { ...limitedCoords, data: { x: limitedCoords.x - x, y: limitedCoords.y - y, enabled: { [mainAxis]: checkMainAxis, [crossAxis]: checkCrossAxis, }, }, }; }, }); ``` ### How shift Uses mainAxis and crossAxis The `shift` middleware uses the terms "mainAxis" and "crossAxis", but **the axis assignment is swapped compared to `offset`**. Look at the code: ```ts const crossAxis = getSideAxis(getSide(placement)); // For 'bottom' β†’ 'y' const mainAxis = getOppositeAxis(crossAxis); // opposite of 'y' β†’ 'x' ``` For `placement: 'bottom'`, `getSideAxis` returns `'y'` (the axis the floating element sits along β€” perpendicular to the reference edge). Then `shift` calls this the **crossAxis** and takes the opposite (`'x'`) as the **mainAxis**. This is the reverse of `offset`, where `mainAxis` is Y for bottom placement. The reason: each middleware defines "main" based on its primary job: | Middleware | Primary job | mainAxis for `'bottom'` | crossAxis for `'bottom'` | |-----------|------------|------------------------|-------------------------| | **offset** | Push away from reference | **Y** (away direction) | **X** (along edge) | | **shift** | Slide along reference edge | **X** (slide direction) | **Y** (toward/away) | So when the `shift` code says `checkMainAxis = true`, it means "slide along the reference edge" (X for bottom placement). And `checkCrossAxis = false` means "don't adjust the gap distance" (Y for bottom placement). By default, `shift` only operates on the **mainAxis** (`checkMainAxis = true`), sliding the floating element along the reference edge to keep it in view. The **crossAxis** is disabled by default (`checkCrossAxis = false`) because adjusting it would change the gap between the floating element and reference, which is typically controlled by the `offset` middleware. ### The Clamping Logic The core algorithm uses `clamp(min, value, max)` to constrain coordinates within valid bounds: ```ts // For mainAxis shifting (e.g., horizontal for 'bottom' placement) const min = mainAxisCoord + overflow[minSide]; // How far can we go left? const max = mainAxisCoord - overflow[maxSide]; // How far can we go right? mainAxisCoord = clamp(min, mainAxisCoord, max); ``` **Understanding the formula:** - `overflow[minSide]` is **negative** when there's space available, **positive** when overflowing - `overflow[maxSide]` is **positive** when overflowing, **negative** when there's space - Adding negative overflow expands the range, adding positive overflow shrinks it - The clamp ensures the coordinate stays within valid bounds **Example trace for `placement: 'bottom'`:** ```markdown Initial position: - x = 320 (current floating element's x coordinate) - viewport boundary: left = 0, right = 600 - floating element width = 300 Detected overflow: - overflow.left = 0 - 320 = -320 (negative = has 320px of space on the left) - overflow.right = (320 + 300) - 600 = 20 (positive = overflowing 20px on the right) Calculate valid range: - mainAxis = 'x' (horizontal for bottom placement) - minSide = 'left', maxSide = 'right' - min = 320 + (-320) = 0 // Can go as far left as x=0 - max = 320 - 20 = 300 // Must stop at x=300 to avoid right overflow Apply clamp: - clamp(0, 320, 300) = 300 // Shifted left to x=300! - Result: Floating element slides 20px to the left to fit in viewport ``` ```js file=index.html Shift Middleware - Clamping Visualization
x y (0,0) Viewport x = 0 x = 600 Reference Original 20px Shifted 20px x=320 x=300
``` ### Why crossAxis is Disabled by Default The `crossAxis` controls the distance between the floating element and the reference. Enabling it would allow the floating element to move closer to or further from the reference to avoid overflow, which could: 1. **Conflict with `offset` middleware** - The gap is usually intentionally set via `offset` 2. **Cause unexpected visual jumps** - The floating element might suddenly appear much closer or further away 3. **Break alignment expectations** - For tooltip-like UIs, users expect consistent spacing If you need crossAxis shifting, you must explicitly enable it: ```ts shift({ crossAxis: true }) ``` ### The limitShift Limiter `limitShift` is a **limiter** (not a middleware) that constrains how far `shift` can move the floating element, keeping it tethered to the **reference element**. Without limits, a wide tooltip might shift so far that it **visually detaches** from its reference. Looking at the visualization above, if the floating element were allowed to shift all the way to x=0, it would be completely separated from the reference element, breaking the visual connection between them. **How they work together:** - **`shift`**: "How far do I need to move to fit in the viewport?" - **`limitShift`**: "You can't move beyond the reference element's edges" ```ts shift({ limiter: limitShift({ offset: 10 }) }) ``` The limiter ensures the floating element stays aligned with at least one edge of the reference, even if that means it overflows the viewport slightly. This maintains the visual relationship between the two elements. ```ts title="floating-ui/.../middleware/shift.ts" height="300px" options: LimitShiftOptions | Derivable = {}, ): { options: any; fn: (state: MiddlewareState) => Coords; } => ({ options, fn(state) { const {x, y, placement, rects, middlewareData} = state; const { offset = 0, mainAxis: checkMainAxis = true, crossAxis: checkCrossAxis = true, } = evaluate(options, state); const coords = {x, y}; const crossAxis = getSideAxis(placement); const mainAxis = getOppositeAxis(crossAxis); let mainAxisCoord = coords[mainAxis]; let crossAxisCoord = coords[crossAxis]; const rawOffset = evaluate(offset, state); const computedOffset = typeof rawOffset === 'number' ? {mainAxis: rawOffset, crossAxis: 0} : {mainAxis: 0, crossAxis: 0, ...rawOffset}; if (checkMainAxis) { const len = mainAxis === 'y' ? 'height' : 'width'; const limitMin = rects.reference[mainAxis] - rects.floating[len] + computedOffset.mainAxis; const limitMax = rects.reference[mainAxis] + rects.reference[len] - computedOffset.mainAxis; if (mainAxisCoord < limitMin) { mainAxisCoord = limitMin; } else if (mainAxisCoord > limitMax) { mainAxisCoord = limitMax; } } if (checkCrossAxis) { const len = mainAxis === 'y' ? 'width' : 'height'; const isOriginSide = originSides.has(getSide(placement)); const limitMin = rects.reference[crossAxis] - rects.floating[len] + (isOriginSide ? middlewareData.offset?.[crossAxis] || 0 : 0) + (isOriginSide ? 0 : computedOffset.crossAxis); const limitMax = rects.reference[crossAxis] + rects.reference[len] + (isOriginSide ? 0 : middlewareData.offset?.[crossAxis] || 0) - (isOriginSide ? computedOffset.crossAxis : 0); if (crossAxisCoord < limitMin) { crossAxisCoord = limitMin; } else if (crossAxisCoord > limitMax) { crossAxisCoord = limitMax; } } return { [mainAxis]: mainAxisCoord, [crossAxis]: crossAxisCoord, } as Coords; }, }); ``` **The constraint:** Floating element must always overlap with reference on the mainAxis: ```js file=index.html limitShift - Constraint Visualization
x y (0,0) Reference x=200 x=300 Floating (width=300) Must overlap horizontally limitMin x=-100 limitMax x=300 x=50
``` **Example trace for `placement: 'bottom'`:** ```markdown Setup: - Reference: x = 200, width = 100 (ends at 300) - Floating: width = 300 - shift wants to move floating to x = 50 (to fit viewport) Calculate limitMin and limitMax: - mainAxis = 'x' (horizontal for bottom placement) - len = 'width' (floating width = 300) limitMin = reference.x - floating.width = 200 - 300 = -100 (Floating's left edge can go as far left as x = -100) limitMax = reference.x + reference.width = 200 + 100 = 300 (Floating's left edge can go as far right as x = 300) Apply constraints: - shift calculated: x = 50 - Check: is 50 < -100? No - Check: is 50 > 300? No - Result: x = 50 βœ“ (within limits, allow the shift) If shift calculated x = -200: - Check: is -200 < -100? Yes! - Clamp to limitMin: x = -100 - Result: Floating stops at x = -100 to maintain overlap with reference ``` ## flip The `flip` middleware changes the floating element's placement when it overflows. Unlike `shift` which slides the element along edges, `flip` **moves the element to the opposite side** of the reference. This is a more dramatic change but often necessary when there's simply no room on the original side. ### The Core Concept Imagine a tooltip positioned below a button near the bottom edge of the viewport. There's not enough space below, but plenty above. `flip` detects this and switches the placement from `'bottom'` to `'top'`. ```js file=index.html Floating UI - Offset + Flip Steps
(0,0) x y Reference Failed Flip() Reset
``` ### Key Characteristics - **Uses** `detectOverflow` to measure overflow - **Tracks state** across multiple passes via `middlewareData` - **Triggers reset** with new placement - **Tries multiple fallback placements** before settling ```ts title="floating-ui/.../middleware/flip.ts" height="300px" name: 'flip', options, async fn(state) { const { placement, middlewareData, rects, initialPlacement, platform, elements, } = state; const { mainAxis: checkMainAxis = true, crossAxis: checkCrossAxis = true, fallbackPlacements: specifiedFallbackPlacements, fallbackStrategy = 'bestFit', fallbackAxisSideDirection = 'none', flipAlignment = true, ...detectOverflowOptions } = evaluate(options, state); // EARLY BAIL-OUT: Arrow caused alignment offset, flip already did its job if (middlewareData.arrow?.alignmentOffset) { return {}; } const side = getSide(placement); const initialSideAxis = getSideAxis(initialPlacement); const isBasePlacement = getSide(initialPlacement) === initialPlacement; const rtl = await platform.isRTL?.(elements.floating); // Build the list of placements to try const fallbackPlacements = specifiedFallbackPlacements || (isBasePlacement || !flipAlignment ? [getOppositePlacement(initialPlacement)] // e.g., 'bottom' β†’ 'top' : getExpandedPlacements(initialPlacement)); // e.g., 'bottom-start' β†’ ['bottom-end', 'top-start', 'top-end'] // Add perpendicular axis placements if configured if (fallbackAxisSideDirection !== 'none') { fallbackPlacements.push( ...getOppositeAxisPlacements(initialPlacement, flipAlignment, fallbackAxisSideDirection, rtl) ); } const placements = [initialPlacement, ...fallbackPlacements]; // Detect overflow for current placement const overflow = await platform.detectOverflow(state, detectOverflowOptions); const overflows = []; let overflowsData = middlewareData.flip?.overflows || []; if (checkMainAxis) { overflows.push(overflow[side]); } if (checkCrossAxis) { const sides = getAlignmentSides(placement, rects, rtl); overflows.push(overflow[sides[0]], overflow[sides[1]]); } // Accumulate overflow data overflowsData = [...overflowsData, {placement, overflows}]; // Check if current placement overflows if (!overflows.every((side) => side <= 0)) { const nextIndex = (middlewareData.flip?.index || 0) + 1; const nextPlacement = placements[nextIndex]; if (nextPlacement) { // Try next placement β€” TRIGGER RESET return { data: { index: nextIndex, overflows: overflowsData, }, reset: { placement: nextPlacement, }, }; } // No more placements to try β€” use fallback strategy let resetPlacement = overflowsData .filter((d) => d.overflows[0] <= 0) .sort((a, b) => a.overflows[1] - b.overflows[1])[0]?.placement; if (!resetPlacement) { switch (fallbackStrategy) { case 'bestFit': { // Find placement with least total overflow const placement = overflowsData .map((d) => [ d.placement, d.overflows .filter((overflow) => overflow > 0) .reduce((acc, overflow) => acc + overflow, 0), ]) .sort((a, b) => a[1] - b[1])[0]?.[0]; if (placement) { resetPlacement = placement; } break; } case 'initialPlacement': resetPlacement = initialPlacement; break; } } if (placement !== resetPlacement) { return { reset: { placement: resetPlacement, }, }; } } return {}; }, }); ``` ### The Placement Iteration Strategy The `flip` middleware doesn't just try the opposite side β€” it builds a **priority list of fallback placements** and tries them one by one. The placements array depends on whether you're using a base placement (`'bottom'`) or an aligned placement (`'bottom-start'`). ```ts // For initialPlacement: 'bottom' (base placement) placements = ['bottom', 'top'] // For initialPlacement: 'bottom-start' with flipAlignment: true placements = ['bottom-start', 'bottom-end', 'top-start', 'top-end'] ``` **Why the difference?** For aligned placements, `flip` also considers **flipping the alignment** (start ↔ end) on the same side before jumping to the opposite side. This often produces a better result than immediately flipping to the opposite side. ```html file=index.html Flip - Placement Iteration Strategy
Aligned Placement: 'bottom-start' Iteration placements = ['bottom-start', 'bottom-end', 'top-start', 'top-end'] 1 'bottom-start' overflow: 50px RESET 2 'bottom-end' overflow: 50px RESET 3 'top-start' overflow: 20px RESET 4 'top-end' overflow: -30px FITS!
``` 1. Build placements array from initialPlacement 2. For each placement: detect overflow β†’ if overflows, increment index and RESET 3. Store overflow data in `middlewareData.flip.overflows` 4. When a placement fits (all overflows ≀ 0), stop and return {} **Pass-by-pass trace with real values:** ```markdown Initial state: placement = 'bottom-start' placements = ['bottom-start', 'bottom-end', 'top-start', 'top-end'] ═══════════════════════════════════════════════════════════════ PASS 1: placement = 'bottom-start' ═══════════════════════════════════════════════════════════════ middlewareData.flip = undefined (first pass) overflow = detectOverflow() β†’ { top: -100, right: 10, bottom: 50, left: -20 } checkMainAxis: overflows.push(overflow['bottom']) β†’ [50] checkCrossAxis: overflows.push(overflow['left'], overflow['right']) β†’ [50, -20, 10] Check: overflows.every(v => v <= 0)? β†’ [50, -20, 10].every(v => v <= 0) β†’ false! nextIndex = 0 + 1 = 1 nextPlacement = placements[1] = 'bottom-end' return { data: { index: 1, overflows: [{ placement: 'bottom-start', overflows: [50, -20, 10] }] }, reset: { placement: 'bottom-end' } // ← TRIGGERS PIPELINE RESTART } ═══════════════════════════════════════════════════════════════ PASS 2: placement = 'bottom-end' (after reset) ═══════════════════════════════════════════════════════════════ middlewareData.flip = { index: 1, overflows: [...] } overflow = detectOverflow() β†’ { top: -100, right: -20, bottom: 50, left: 10 } overflows = [50, 10, -20] // still overflows on bottom! Check: [50, 10, -20].every(v => v <= 0)? β†’ false! nextIndex = 1 + 1 = 2 nextPlacement = placements[2] = 'top-start' return { reset: { placement: 'top-start' } } ═══════════════════════════════════════════════════════════════ PASS 3: placement = 'top-start' (after reset) ═══════════════════════════════════════════════════════════════ overflow = detectOverflow() β†’ { top: 20, right: 10, bottom: -150, left: -20 } overflows = [20, -20, 10] // overflows on top! Check: [20, -20, 10].every(v => v <= 0)? β†’ false! nextIndex = 2 + 1 = 3 nextPlacement = placements[3] = 'top-end' return { reset: { placement: 'top-end' } } ═══════════════════════════════════════════════════════════════ PASS 4: placement = 'top-end' (after reset) ═══════════════════════════════════════════════════════════════ overflow = detectOverflow() β†’ { top: -30, right: -20, bottom: -150, left: 10 } overflows = [-30, 10, -20] Wait... overflow['left'] = 10 is positive! But this is crossAxis... Actually let me recalculate with better values: overflow = detectOverflow() β†’ { top: -30, right: -20, bottom: -150, left: -10 } overflows = [-30, -10, -20] Check: [-30, -10, -20].every(v => v <= 0)? β†’ true! βœ“ return {} // No reset needed, we found a placement that fits! ═══════════════════════════════════════════════════════════════ FINAL RESULT: placement = 'top-end' ═══════════════════════════════════════════════════════════════ ``` ### The overflowsData Accumulator Here's the key insight: **each pipeline pass is independent**. When `flip` triggers a reset, the entire middleware pipeline restarts from scratch. So how does `flip` remember which placements it already tried? The answer is `middlewareData` β€” a shared object that **persists across resets**. Each pass, `flip` reads its previous data, adds the current placement's overflow info, and stores it back. ```ts // On each pass, flip reads previous data and adds current placement let overflowsData = middlewareData.flip?.overflows || []; // Read from previous passes overflowsData = [...overflowsData, {placement, overflows}]; // Add current pass data // Then stores it in the return value return { data: { index: nextIndex, overflows: overflowsData }, // Persists to middlewareData.flip reset: { placement: nextPlacement } }; ``` **The data structure after all passes (when no placement fits perfectly):** ```ts // If we had to exhaust all placements, overflowsData would contain: overflowsData = [ { placement: 'bottom-start', overflows: [50, -20, 10] }, // mainAxis: 50, crossAxis: -20, 10 { placement: 'bottom-end', overflows: [50, 10, -20] }, // mainAxis: 50, crossAxis: 10, -20 { placement: 'top-start', overflows: [20, -20, 10] }, // mainAxis: 20, crossAxis: -20, 10 { placement: 'top-end', overflows: [15, 5, -10] } // mainAxis: 15, crossAxis: 5, -10 ] // This data is then used by the fallback strategy to pick the "best" placement ``` ### Fallback Strategies When **no placement fits perfectly** (all placements overflow), `flip` must choose the "least bad" option. This is where the `fallbackStrategy` option comes into play. | Strategy | Behavior | When to use | |----------|----------|-------------| | `'bestFit'` (default) | Choose placement with least total overflow | Most cases β€” minimizes visual clipping | | `'initialPlacement'` | Give up and use original placement | When consistency is more important than fit | ```js file=index.html Flip - Fallback Strategies
fallbackStrategy: 'bestFit' β€” Finding the Least Bad Placement When all placements overflow, sum positive overflows and pick the smallest Placement Overflows Array Positive Only Total Visual 'bottom-start' [50, -20, 10] [50, 10].filter(v > 0) = 60 60px 'bottom-end' [50, 10, -20] [50, 10].filter(v > 0) = 60 60px 'top-start' [20, -20, 10] [20, 10].filter(v > 0) = 30 30px βœ“ 'top-end' [15, 25, -10] [15, 25].filter(v > 0) = 40 40px Result: 'top-start' wins with only 30px total overflow
``` **The algorithm step by step:** ```ts case 'bestFit': { const placement = overflowsData // Step 1: Transform each placement into [placement, totalPositiveOverflow] .map((d) => [ d.placement, d.overflows .filter((overflow) => overflow > 0) // Only count positive (actual overflow) .reduce((acc, overflow) => acc + overflow, 0), // Sum them up ]) // Step 2: Sort by total overflow (ascending) .sort((a, b) => a[1] - b[1]) // Step 3: Take the first one (least overflow) [0]?.[0]; if (placement) { resetPlacement = placement; } break; } ``` **Why filter only positive overflows?** Negative values mean there's **extra space** on that side β€” that's not a problem. We only care about sides where the element actually sticks out of the boundary. ### The arrow?.alignmentOffset Bail-out This is one of the most subtle parts of the `flip` middleware β€” a safeguard against **infinite loops** caused by the interaction between `flip` and `arrow` middleware. ```ts // At the very start of flip's fn() if (middlewareData.arrow?.alignmentOffset) { return {}; // Bail out immediately, don't try to flip again } ``` **The problem scenario:** Imagine a tiny button (40px wide) with a tooltip that has an arrow. The tooltip is positioned at `'bottom-start'`, but the arrow can't center itself over the button because the button is too small. ```js file=index.html Flip - Arrow Alignment Bail-out
The Infinite Loop Problem (and How arrow?.alignmentOffset Prevents It) Without the bail-out check 1. flip: placement = 'bottom-start' Tooltip overflows right edge β†’ reset: { placement: 'bottom-end' } 2. arrow: placement = 'bottom-end' Reference too small for arrow centering β†’ reset + alignmentOffset: 15 3. flip: sees new placement Tries to flip again! β†’ reset: { placement: 'top-end' } INFINITE LOOP! With the bail-out check 1. flip: placement = 'bottom-start' Tooltip overflows right edge β†’ reset: { placement: 'bottom-end' } 2. arrow: placement = 'bottom-end' Reference too small for arrow centering β†’ reset + alignmentOffset: 15 3. flip: checks alignmentOffset alignmentOffset exists β†’ BAIL OUT! β†’ return {} (no more flipping) The Scenario: Tiny Reference, Large Tooltip with Arrow 40px button Wide tooltip (200px) Arrow can't center
``` **The sequence without the bail-out:** 1. **flip** detects overflow β†’ changes placement 2. **arrow** can't center itself β†’ triggers reset with `alignmentOffset` 3. **flip** runs again on the new placement β†’ tries to flip again 4. **arrow** still can't center β†’ triggers another reset 5. **Repeat forever** (until Floating UI's built-in 50-iteration limit kicks in) **The fix:** By checking `middlewareData.arrow?.alignmentOffset` at the very start, `flip` knows that: - Arrow middleware already ran and had to offset itself - The current placement is the "final answer" from arrow's perspective - Flipping again would just restart the cycle This is a great example of how middlewares need to **coordinate** through `middlewareData` to avoid stepping on each other's toes. ### Why flip Comes After shift in Radix ```tsx middleware: [ offset(...), shift(...), // Try sliding first flip(...), // Only flip if shift couldn't fix it ] ``` **Philosophy:** Flipping is jarring (tooltip jumps to opposite side). Shifting is subtle (tooltip slides). Try the subtle fix first. ```mdx User experience with shift β†’ flip: 1. Tooltip appears at 'bottom' 2. Near edge? Slides left/right (barely noticeable) 3. Still overflows? THEN flip to 'top' (only when necessary) ``` ## Playgound ```jsx file=App.js // ============================================================================ // CONFIGURATION // ============================================================================ const VIEWPORT = { x: 60, y: 60, width: 680, height: 480 }; const REF_DEFAULT = { x: 340, y: 280 }; const colors = { bgPage: '#f9fafb', sidebar: '#ffffff', border: '#e5e7eb', textMain: '#111827', textMuted: '#6b7280', // Elements refFill: 'rgba(99, 102, 241, 0.08)', refStroke: '#4f46e5', floatFill: 'rgba(139, 92, 246, 0.08)', floatStroke: '#7c3aed', // Coordinates input: '#dc2626', derived: '#1d4ed8', // Axes side: '#10b981', align: '#f59e0b', // Middleware offset: '#8b5cf6', flip: '#ec4899', shift: '#06b6d4', // Overflow overflow: 'rgba(239, 68, 68, 0.15)', overflowStroke: '#ef4444', viewport: '#94a3b8', // Pipeline pipelineActive: '#10b981', pipelineInactive: '#d1d5db', pipelineReset: '#f59e0b', }; // ============================================================================ // STYLES // ============================================================================ const styles = { app: { display: 'flex', backgroundColor: colors.bgPage, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', }, sidebar: { width: '320px', backgroundColor: colors.sidebar, borderRight: `1px solid ${colors.border}`, overflowY: 'auto', flexShrink: 0, }, sidebarHeader: { padding: '16px', borderBottom: `1px solid ${colors.border}`, fontSize: '11px', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em', color: colors.textMuted, }, section: { padding: '16px', borderBottom: `1px solid ${colors.border}`, }, sectionTitle: { fontWeight: '600', fontSize: '12px', color: colors.textMain, marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px', }, row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px', fontSize: '11px', }, label: { color: colors.textMuted, width: '70px', }, inputGroup: { display: 'flex', alignItems: 'center', gap: '8px', flex: 1, }, value: { width: '40px', textAlign: 'right', fontWeight: '500', fontFamily: 'monospace', fontSize: '11px', }, badge: (color) => ({ backgroundColor: color, color: 'white', padding: '2px 8px', borderRadius: '4px', fontSize: '9px', fontWeight: 'bold', }), toggle: (active) => ({ width: '36px', height: '20px', borderRadius: '10px', backgroundColor: active ? colors.pipelineActive : '#d1d5db', position: 'relative', cursor: 'pointer', transition: 'background-color 0.2s', border: 'none', }), toggleKnob: (active) => ({ width: '16px', height: '16px', borderRadius: '50%', backgroundColor: 'white', position: 'absolute', top: '2px', left: active ? '18px' : '2px', transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)', }), placementGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '3px', }, placementBtn: (active) => ({ padding: '6px 4px', fontSize: '9px', border: 'none', borderRadius: '4px', backgroundColor: active ? colors.refStroke : 'transparent', color: active ? 'white' : colors.textMuted, cursor: 'pointer', fontWeight: active ? '600' : '400', }), pipelineStep: (status) => ({ padding: '10px 12px', marginBottom: '8px', borderRadius: '6px', backgroundColor: status === 'active' ? 'rgba(16, 185, 129, 0.1)' : status === 'reset' ? 'rgba(245, 158, 11, 0.1)' : status === 'skipped' ? 'rgba(156, 163, 175, 0.1)' : 'rgba(229, 231, 235, 0.5)', border: `1px solid ${status === 'active' ? colors.pipelineActive : status === 'reset' ? colors.pipelineReset : status === 'skipped' ? '#9ca3af' : colors.border}`, fontSize: '10px', }), stepHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px', }, stepName: { fontWeight: '600', display: 'flex', alignItems: 'center', gap: '6px', }, stepCoords: { fontFamily: 'monospace', fontSize: '10px', color: colors.textMuted, }, canvas: { flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', }, legend: { position: 'absolute', bottom: '20px', left: '20px', backgroundColor: 'white', padding: '12px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', fontSize: '10px', }, legendItem: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px', }, legendDot: (color) => ({ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: color, }), }; // ============================================================================ // TOGGLE COMPONENT // ============================================================================ const Toggle = ({ active, onChange }) => ( ); // ============================================================================ // MAIN APP // ============================================================================ const App = () => { // Placement & Dimensions const [placement, setPlacement] = useState('bottom'); const [refWidth, setRefWidth] = useState(160); const [refHeight, setRefHeight] = useState(60); const [floatWidth, setFloatWidth] = useState(180); const [floatHeight, setFloatHeight] = useState(70); // Middleware settings const [offsetEnabled, setOffsetEnabled] = useState(true); const [offsetAmount, setOffsetAmount] = useState(10); const [flipEnabled, setFlipEnabled] = useState(true); const [shiftEnabled, setShiftEnabled] = useState(true); const [shiftPadding, setShiftPadding] = useState(8); // Reference position (draggable) const [refX, setRefX] = useState(REF_DEFAULT.x); const [refY, setRefY] = useState(REF_DEFAULT.y); // ============================================================================ // MIDDLEWARE PIPELINE COMPUTATION // ============================================================================ const pipeline = useMemo(() => { const steps = []; const rects = { reference: { x: refX, y: refY, width: refWidth, height: refHeight }, floating: { width: floatWidth, height: floatHeight }, }; // Helper: compute coords from placement const computeCoords = (p, rects) => { const [side, align] = p.split('-'); const { reference: ref, floating: float } = rects; let x, y; // Side positioning if (side === 'top') y = ref.y - float.height; else if (side === 'bottom') y = ref.y + ref.height; else if (side === 'left') x = ref.x - float.width; else if (side === 'right') x = ref.x + ref.width; // Alignment if (side === 'top' || side === 'bottom') { if (!align) x = ref.x + ref.width / 2 - float.width / 2; else if (align === 'start') x = ref.x; else if (align === 'end') x = ref.x + ref.width - float.width; } else { if (!align) y = ref.y + ref.height / 2 - float.height / 2; else if (align === 'start') y = ref.y; else if (align === 'end') y = ref.y + ref.height - float.height; } return { x: Math.round(x), y: Math.round(y) }; }; // Helper: detect overflow const detectOverflow = (x, y, width, height) => ({ top: VIEWPORT.y - y, right: (x + width) - (VIEWPORT.x + VIEWPORT.width), bottom: (y + height) - (VIEWPORT.y + VIEWPORT.height), left: VIEWPORT.x - x, }); // Helper: get opposite placement const getOpposite = (p) => { const map = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }; return p.replace(/top|bottom|left|right/, s => map[s]); }; // STEP 0: Initial position let currentPlacement = placement; let { x, y } = computeCoords(currentPlacement, rects); let overflow = detectOverflow(x, y, floatWidth, floatHeight); steps.push({ name: 'Initial', description: `computeCoordsFromPlacement('${currentPlacement}')`, x, y, placement: currentPlacement, overflow: { ...overflow }, status: 'active', }); // STEP 1: Offset middleware if (offsetEnabled) { const [side] = currentPlacement.split('-'); const isVertical = side === 'top' || side === 'bottom'; const isOriginSide = side === 'top' || side === 'left'; const multiplier = isOriginSide ? -1 : 1; if (isVertical) { y += offsetAmount * multiplier; } else { x += offsetAmount * multiplier; } overflow = detectOverflow(x, y, floatWidth, floatHeight); steps.push({ name: 'offset', description: `Add ${offsetAmount}px gap on ${isVertical ? 'Y' : 'X'} axis`, x, y, placement: currentPlacement, overflow: { ...overflow }, status: 'active', color: colors.offset, delta: { [isVertical ? 'y' : 'x']: offsetAmount * multiplier }, }); } // STEP 2: Shift middleware (before flip - Radix pattern: try sliding first) if (shiftEnabled) { const [side] = currentPlacement.split('-'); const isVertical = side === 'top' || side === 'bottom'; const shiftAxis = isVertical ? 'x' : 'y'; let shifted = false; let shiftAmount = 0; if (shiftAxis === 'x') { // Shift horizontally if (overflow.left > 0) { shiftAmount = overflow.left + shiftPadding; x += shiftAmount; shifted = true; } else if (overflow.right > 0) { shiftAmount = -(overflow.right + shiftPadding); x += shiftAmount; shifted = true; } } else { // Shift vertically if (overflow.top > 0) { shiftAmount = overflow.top + shiftPadding; y += shiftAmount; shifted = true; } else if (overflow.bottom > 0) { shiftAmount = -(overflow.bottom + shiftPadding); y += shiftAmount; shifted = true; } } overflow = detectOverflow(x, y, floatWidth, floatHeight); steps.push({ name: 'shift', description: shifted ? `Shifted ${Math.abs(shiftAmount).toFixed(0)}px on ${shiftAxis.toUpperCase()} axis` : `No shift needed, within bounds`, x, y, placement: currentPlacement, overflow: { ...overflow }, status: shifted ? 'active' : 'skipped', color: colors.shift, delta: shifted ? { [shiftAxis]: shiftAmount } : null, }); } // STEP 3: Flip middleware (after shift - only flip if shift couldn't fix it) let didFlip = false; if (flipEnabled) { const [side] = currentPlacement.split('-'); const isOverflowing = overflow[side] > 0; if (isOverflowing) { const newPlacement = getOpposite(currentPlacement); currentPlacement = newPlacement; didFlip = true; // Recompute with new placement ({ x, y } = computeCoords(currentPlacement, rects)); // Re-apply offset if enabled if (offsetEnabled) { const [newSide] = currentPlacement.split('-'); const isVertical = newSide === 'top' || newSide === 'bottom'; const isOriginSide = newSide === 'top' || newSide === 'left'; const multiplier = isOriginSide ? -1 : 1; if (isVertical) y += offsetAmount * multiplier; else x += offsetAmount * multiplier; } overflow = detectOverflow(x, y, floatWidth, floatHeight); steps.push({ name: 'flip', description: `Overflow detected! Reset to '${currentPlacement}'`, x, y, placement: currentPlacement, overflow: { ...overflow }, status: 'reset', color: colors.flip, didReset: true, }); } else { steps.push({ name: 'flip', description: `No overflow on '${side}' side, no flip needed`, x, y, placement: currentPlacement, overflow: { ...overflow }, status: 'skipped', color: colors.flip, }); } } // FINAL result steps.push({ name: 'Final', description: `placement: '${currentPlacement}'`, x, y, placement: currentPlacement, overflow: { ...overflow }, status: 'active', isFinal: true, }); return { steps, final: { x, y, placement: currentPlacement }, didFlip, }; }, [ placement, refX, refY, refWidth, refHeight, floatWidth, floatHeight, offsetEnabled, offsetAmount, flipEnabled, shiftEnabled, shiftPadding ]); // Axis info const axisInfo = useMemo(() => { const [side] = pipeline.final.placement.split('-'); const isVertical = side === 'top' || side === 'bottom'; return { side, isVertical, sideAxis: isVertical ? 'Y' : 'X', alignAxis: isVertical ? 'X' : 'Y' }; }, [pipeline.final.placement]); // ============================================================================ // RENDER // ============================================================================ return (
{/* SIDEBAR */} {/* CANVAS */}
{/* Grid */} {/* Viewport boundary */} Viewport Boundary {/* Overflow regions */} {(() => { const finalStep = pipeline.steps[pipeline.steps.length - 1]; const { x, y } = finalStep; const regions = []; // Top overflow if (y < VIEWPORT.y) { regions.push( ); } // Bottom overflow if (y + floatHeight > VIEWPORT.y + VIEWPORT.height) { const overflowY = VIEWPORT.y + VIEWPORT.height; regions.push( ); } // Left overflow if (x < VIEWPORT.x) { regions.push( ); } // Right overflow if (x + floatWidth > VIEWPORT.x + VIEWPORT.width) { const overflowX = VIEWPORT.x + VIEWPORT.width; regions.push( ); } return regions; })()} {/* Initial position ghost (if different from final) */} {(pipeline.steps[0].x !== pipeline.final.x || pipeline.steps[0].y !== pipeline.final.y) && ( Initial )} {/* Reference element */} Reference {/* Reference point marker */} ({refX}, {refY}) {/* Floating element (final position) */} Floating {/* Floating point marker */} ({pipeline.final.x}, {pipeline.final.y}) {/* Side axis indicator */} {axisInfo.isVertical ? ( SIDE (Y) ) : ( SIDE (X) )} {/* Align axis indicator */} {axisInfo.isVertical ? ( ) : ( )} {/* Legend */}
Reference Element
Floating Element
Side Axis
Align Axis
Overflow Region
); }; ``` --- ## Floating UI - Middleware Overview - **URL:** https://www.jinghuangsu.com/til/floating-ui-middleware - **Date:** 2026-01-17 - **Category:** floating-ui - **Tags:** React, JavaScript, UI Library In the , we explored how `computeCoordsFromPlacement` calculates the initial `x` and `y` coordinates. But that's just the starting point. The real power of Floating UI lies in its **middleware pipeline** - a sequential, resettable, data-sharing loop that transforms positioning through composable functions. ## Overview ### Part 1: The Pipeline Architecture The middleware pipeline is the heart of Floating UI's extensibility. It transforms positioning through composable functions that run in sequence.
```jsx [key: string]: any; arrow?: Partial & { centerOffset: number; alignmentOffset?: number; }; autoPlacement?: { index?: number; overflows: Array<{ placement: Placement; overflows: Array; }>; }; flip?: { index?: number; overflows: Array<{ placement: Placement; overflows: Array; }>; }; hide?: { referenceHidden?: boolean; escaped?: boolean; referenceHiddenOffsets?: SideObject; escapedOffsets?: SideObject; }; offset?: Coords & {placement: Placement}; shift?: Coords & { enabled: {[key in Axis]: boolean}; }; } ```
#### Step 1. Initial positioning ```js let { x, y } = computeCoordsFromPlacement(rects, placement) ``` The process begins with an initial position. The `computeCoordsFromPlacement` looks at the reference element and the requested placement to calculate a basic starting point. #### Step 2. The Middleware Loop The initial coordinates are passed through a sequence of middleware functions. Each middleware has a single responsibility (e.g. `offset` adds a gap, `flip` handles viewport collisions). Why a for loop instead of forEach?} content={<>The for loop enables the reset mechanism. When a middleware returns reset: true, setting i = -1 causes the loop to restart from the beginning after i++ executes.} /> ```js for (let i = 0; i < validMiddleware.length; i++) { const {name, fn} = validMiddleware[i]; // ... call fn, process result if (reset && resetCount <= 50) { // ... handle reset i = -1; // After i++, becomes 0 - restart from first middleware } } ``` ```mardown Normal flow: i=0 β†’ process β†’ i++ β†’ i=1 β†’ process β†’ i++ β†’ done Reset at i=1: i=0 β†’ process β†’ i++ β†’ i=1 β†’ RESET β†’ i=-1 β†’ i++ β†’ i=0 β†’ restart ``` #### Step 3. Building the Middleware State Each middleware receives a comprehensive state object and returns modifications: ```js const { x: nextX, y: nextY, data, reset, } = await fn({ x, // Current x position y, // Current y position initialPlacement: placement, // Original placement (never changes) placement: statefulPlacement, // Current placement (may have changed) strategy, // 'absolute' | 'fixed' middlewareData, // Data from previous middleware rects, // Element rectangles platform, // Platform methods elements: {reference, floating}, // Actual elements }); ``` **Why both `initialPlacement` and `placement`?** The `initialPlacement` preserves the user's original intent (e.g., `'bottom'`), while `placement` reflects the current working state (may become `'top'` after flip). This lets `flip` know what to fall back to while `offset` applies the gap in the correct direction. #### Step 4. Processing Middleware Return After each middleware runs, we apply its modifications: ```js // Apply position changes (if any) x = nextX ?? x; y = nextY ?? y; // Accumulate data under middleware's namespace middlewareData = { ...middlewareData, // Keep existing data [name]: { // Namespace by middleware name ...middlewareData[name], // Keep existing data for this middleware ...data, // Merge new data }, }; ``` For example, if we have `offset` and `flip` in our middleware, after executing it, the middleware data will look like this: ```js middlewareData = { offset: { mainAxis: 10 }, flip: { index: 1, overflows: [...] } } ``` #### Step 5. The Reset Mechanism When a middleware needs to change placement (like `flip`), it triggers a reset: ```js if (reset && resetCount <= 50) { resetCount++; if (typeof reset === 'object') { if (reset.placement) { statefulPlacement = reset.placement; } if (reset.rects) { rects = reset.rects === true ? await platform.getElementRects({reference, floating, strategy}) : reset.rects; } ({x, y} = computeCoordsFromPlacement(rects, statefulPlacement, rtl)); } i = -1; } ``` **Reset types:** | Return Value | Effect | |--------------|--------| | `{ reset: true }` | Restart loop with current state | | `{ reset: { placement: 'top' } }` | Change placement, recompute coords, restart | | `{ reset: { rects: true } }` | Re-measure elements, recompute coords, restart |
### Part 2: The Complete Data Flow Here's how all the pieces fit together in a single visual flow: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ INITIAL STATE β”‚ β”‚ { x, y } = computeCoordsFromPlacement(rects, placement) β”‚ β”‚ middlewareData = {} β”‚ β”‚ statefulPlacement = placement (e.g., 'bottom') β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ MIDDLEWARE LOOP β”‚ β”‚ for (let i = 0; i < middleware.length; i++) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ EXECUTE MIDDLEWARE[i] β”‚ β”‚ β”‚ β”‚ Input (MiddlewareState): β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ { β”‚ β”‚ β”‚ β”‚ x, y, // Current coordinates β”‚ β”‚ β”‚ β”‚ initialPlacement, // Original (never changes) β”‚ β”‚ β”‚ β”‚ placement, // Current (may have flipped) β”‚ β”‚ β”‚ β”‚ middlewareData, // Accumulated data from previous runs β”‚ β”‚ β”‚ β”‚ rects, elements, platform, strategy β”‚ β”‚ β”‚ β”‚ } β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ Output (MiddlewareReturn): β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ { β”‚ β”‚ β”‚ β”‚ x?, // New x (optional) β”‚ β”‚ β”‚ β”‚ y?, // New y (optional) β”‚ β”‚ β”‚ β”‚ data?, // Data to store under middlewareData[name] β”‚ β”‚ β”‚ β”‚ reset? // true | { placement?, rects? } β”‚ β”‚ β”‚ β”‚ } β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ APPLY CHANGES β”‚ β”‚ β”‚ β”‚ x = nextX ?? x β”‚ β”‚ y = nextY ?? y β”‚ β”‚ middlewareData[name] = { ...middlewareData[name], ...data } β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ reset returned? β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ YES NO β”‚ β”‚ β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ RESET HANDLER β”‚ β”‚ NEXT MIDDLEWARE β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ if (reset.placement) β”‚ β”‚ i++ β†’ continue loop β”‚ β”‚ statefulPlacement = ... β”‚ β”‚ β”‚ β”‚ if (reset.rects) β”‚ β”‚ When i >= middleware.length: β”‚ β”‚ rects = await getElementRectsβ”‚ β”‚ β†’ EXIT LOOP β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ { x, y } = computeCoords(...) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ i = -1 (restart loop) β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ FINAL OUTPUT β”‚ β”‚ return { β”‚ β”‚ x, // Final x coordinate β”‚ β”‚ y, // Final y coordinate β”‚ β”‚ placement: statefulPlacement,// Final placement (may differ from input) β”‚ β”‚ middlewareData // All accumulated middleware data β”‚ β”‚ } β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ```
## Complete Example Let's trace through a real scenario where a tooltip needs to flip: ```ts computePosition(buttonEl, tooltipEl, { placement: 'bottom', middleware: [offset(30), flip()], }); ``` **The Scenario** Imagine a button near the bottom of your screen at `y = 500`. It is 40px tall. We want to place an 80px tall tooltip below it, but the viewport is only 600px tall. Initial Placement: With placement: 'bottom', the tooltip starts directly under the button at `y = 540 (500 + 40)`. ```js { x: 250, // Centered under reference y: 540, // reference.y + reference.height = 500 + 40 initialPlacement: 'bottom', // User's original intent (never changes) statefulPlacement: 'bottom', // Current working placement middlewareData: {}, // Empty - no middleware has run yet resetCount: 0 } ``` The Problem: The tooltip's bottom edge would be at 620 (540 + 80). Since the viewport ends at 600, we already have an overflow! **Pass 1, i=0: offset(30)** The middleware pipeline runs. The offset middleware's job is to push the floating element away from the reference. Since we are at the bottom, it adds 30 px to the y-coordinate. ```js { x: 250, y: 570, // Updated: 540 + 30 initialPlacement: 'bottom', statefulPlacement: 'bottom', middlewareData: { offset: { mainAxis: 30, crossAxis: 0, placement: 'bottom' } }, resetCount: 0 } ``` ```js file=index.html Floating UI - Offset + Flip Steps
(0,0) x y Viewport boundary (y=600) Reference y=500 Floating offset(30) bottom=650 ⚠ Overflow by 50px
``` **Pass 1, i=1: flip()** The `flip()` middleware sees this overflow and effectively says, "There's no room down here!" It resets the placement to 'top' and recalculates everything to fit perfectly safely above the button. ```js { x: 250, y: 420, // Recomputed: reference.y - floating.height = 500 - 80 initialPlacement: 'bottom', // Still 'bottom' - preserves user intent statefulPlacement: 'top', // Changed! flip triggered this middlewareData: { offset: { mainAxis: 30, crossAxis: 0, placement: 'bottom' }, flip: { index: 0, overflows: [{ placement: 'bottom', overflows: [50] }] // Recorded the overflow } }, resetCount: 1 // Incremented to prevent infinite loops } // Loop restarts: i = -1 β†’ i++ β†’ i = 0 ``` ```js file=index.html Floating UI - Offset + Flip Steps
(0,0) x y Reference Failed Flip() Reset
``` **Pass 2, i=0: offset(30) again** Now that placement is `'top'`, offset pushes the floating element **upward** (away from the reference). ```js // STATE AFTER offset(30) - Pass 2 { x: 250, y: 390, // Updated: 420 - 30 (negative direction for 'top') initialPlacement: 'bottom', statefulPlacement: 'top', middlewareData: { offset: { mainAxis: 30, crossAxis: 0, placement: 'top' }, // Updated with new placement flip: { index: 0, overflows: [...] } }, resetCount: 1 } ``` **Pass 2, i=1: flip() again** ```js { x: 250, y: 390, // Unchanged initialPlacement: 'bottom', statefulPlacement: 'top', middlewareData: { offset: { mainAxis: 30, crossAxis: 0, placement: 'top' }, flip: { index: 0, overflows: [...] } // Unchanged }, resetCount: 1 } ``` ```js file=index.html Floating UI - Offset + Flip Steps
(0,0) x y Reference Floating offset(30) y=390
``` **Final Result:** ```js // FINAL OUTPUT returned by computePosition() { x: 250, // Final x coordinate y: 390, // Final y coordinate placement: 'top', // Changed from 'bottom'! middlewareData: { offset: { mainAxis: 30, crossAxis: 0, placement: 'top' }, flip: { index: 0, overflows: [{ placement: 'bottom', overflows: [50] }] } } } ``` **Complete Flow Summary:** | Step | i | Middleware | x | y | placement | Action | |------|---|------------|---|---|-----------|--------| | Initial | - | - | 250 | 540 | bottom | computeCoordsFromPlacement | | Pass 1 | 0 | offset(30) | 250 | 570 | bottom | y += 30 | | Pass 1 | 1 | flip() | 250 | 420 | **top** | RESET! Overflow detected | | Pass 2 | 0 | offset(30) | 250 | **390** | top | y -= 30 | | Pass 2 | 1 | flip() | 250 | 390 | top | No overflow, done | Notice how `offset` runs twice - once for each placement direction. This is why the reset mechanism exists: when placement changes, all middleware must reprocess with the new context. The `initialPlacement` (`'bottom'`) is preserved so middleware can always reference the user's original intent, while `placement` reflects the actual working state. ## Summary The Floating UI middleware pipeline is a powerful pattern that transforms simple coordinate calculations into a flexible, extensible positioning system. Here are the key takeaways: **The Pipeline Pattern** - Middleware functions run sequentially in a `for` loop, each receiving the current state and returning modifications - Each middleware has a single responsibility: `offset` adds gaps, `flip` handles collisions, `shift` keeps elements in view - Data accumulates in `middlewareData` under each middleware's namespace, allowing later middleware to access earlier results **The Reset Mechanism** - When a middleware returns `{ reset: true }` or `{ reset: { placement: '...' } }`, the loop restarts from `i = 0` - This allows middleware like `flip` to change placement and have all previous middleware (like `offset`) reprocess with the new context - A `resetCount` limit (50) prevents infinite loops from misconfigured middleware **State Management** - `initialPlacement`: The user's original intent - never changes during processing - `placement` (statefulPlacement): The current working placement - may change via reset - `middlewareData`: Accumulated data from all middleware runs, namespaced by middleware name **Why This Design Works** - **Composable**: Add or remove middleware without changing others - **Order-independent results**: The reset mechanism ensures correct final positioning regardless of when collisions are detected - **Debuggable**: The `middlewareData` object provides full visibility into what each middleware contributed This architecture enables Floating UI to handle complex positioning scenarios - from simple tooltips to elaborate dropdown menus with arrows, boundaries, and dynamic placement - all through the same unified pipeline. --- ## Floating UI - Coordinate System - **URL:** https://www.jinghuangsu.com/til/floating-ui-coordinate-system - **Date:** 2026-01-13 - **Category:** floating-ui - **Tags:** React, JavaScript, UI Library ## Introduction Ever wondered how libraries like Floating UI know exactly where to place a tooltip? It all comes down to understanding the browser's coordinate system and some clever math. In this article, we'll break down the positioning logic step by step, from basic rectangles to the complete `computeCoordsFromPlacement` function. Floating UI uses the browser's coordinate system, where the origin (0, 0) is at the top-left corner of the viewport. Let's start by understanding how a single rectangle is defined: ```typescript interface Rect { x: number; y: number; width: number; height: number; } ``` - `x/y` – X/Y-coordinates of the rectangle origin relative to window, - `width/height` – width/height of the rectangle (can be negative). As long as we know the XY coordinates and the element's height and width, we can derive the properties below. - `top/bottom` – Y-coordinate for the top/bottom rectangle edge, - `left/right` – X-coordinate for the left/right rectangle edge. ```js file=index.html Floating UI Coordinate System
x y (0,0) x y width height left top right bottom
``` The diagram above illustrates these relationships visually. With this foundation, the following calculations should be straightforward: ```markdown left = x top = y right = x + width bottom = y + height centerX = x + width / 2 centerY = y + height / 2` ``` ## The Two Elements There are two elements in the floating UI world: **reference**, which is the anchor element (button, link, etc.), and **floating**, which is the positioned element (tooltip, dropdown, etc.). ```js file=index.html Floating UI - Vertical Tooltip Layout
(0,0) Reference ref.x ref.y ref.width ref.height Floating float.x float.y float.width float.height
``` Once we know how to identify the positions of the two elements, we can start calculating the relationship between them. ## Placement Types ```js file=index.html Floating UI - Placements and Axes
REFERENCE top-start top top-end bottom-start bottom bottom-end left-start left left-end right-start right right-end
``` ### Sides There are 4 possible sides where the floating element can be positioned: ```typescript type Side = 'top' | 'right' | 'bottom' | 'left'; ``` ### Alignments There are 2 alignment options that determine how the floating element aligns along the reference edge: ```typescript type Alignment = 'start' | 'end'; ``` ### Combined Placements When you combine a side with an alignment (or leave it centered by default), you get the 12 possible placements: ```typescript type Placement = | 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end'; ``` ## Axes Explained ### The Side Axis (The "Attachment" Axis) This axis is perpendicular to the edge of the reference element. It determines how far away the floating element is from the anchor. - For top / bottom: The Side Axis is y. You change the y coordinate to move the tooltip further up or down from the button. - For left / right: The Side Axis is x. You change the x coordinate to move the tooltip further left or right. ### The Alignment Axis (The "Sliding" Axis) This axis is parallel to the edge of the reference element. It determines where the element "slides" along that edge to satisfy start, center, or end. - For top / bottom: The Alignment Axis is x. The tooltip slides left or right to align its corner or center with the button. - For left / right: The Alignment Axis is y. The tooltip slides up or down to align with the button's height. ```jsx function getSide(placement: Placement): Side { return placement.split('-')[0]; } function getSideAxis(placement: Placement): Axis { const side = getSide(placement); // 'top' | 'right' | 'bottom' | 'left' return (side === 'top' || side === 'bottom') ? 'y' : 'x'; } function getAlignmentAxis(placement: Placement): Axis { return getOppositeAxis(getSideAxis(placement)); } function getOppositeAxis(axis: Axis): Axis { return axis === 'x' ? 'y' : 'x'; } function getAxisLength(axis: Axis): 'width' | 'height' { return axis === 'x' ? 'width' : 'height'; } ``` ## The Math - computeCoordsFromPlacement When you say placement: 'bottom', you're telling Floating UI: "Put the floating element below the reference." But what does "below" actually mean in terms of `x` and `y` coordinates? This is what `computeCoordsFromPlacement` figures out. Let's break it down step by step. For any placement, we need to answer: - Where does the floating element's LEFT edge go? (the `x` coordinate) - Where does the floating element's TOP edge go? (the `y` coordinate) ### Calculate Center Points Before we position by side, we calculate where the floating element would be if it were perfectly centered on the reference: ```js // Center horizontally relative to reference const commonX = reference.x + reference.width / 2 - floating.width / 2; // Center vertically relative to reference const commonY = reference.y + reference.height / 2 - floating.height / 2; ``` So what does this formula actually mean? ### Step 1: Start at the Left Edge As mentioned above, every positioned element in the browser has an `x` property that represents its left edge. When we write `ref.x`, we're starting at the button's left edge. Think of this as our starting line. We know where the button begins, and that's our anchor point. ```js file=index.html Floating UI - Centering Formula
Reference ref.x

Begin at ref.x (the reference element's left edge)

``` #### Step 2: Move to the Center Line Now we need to find the center of the button. To do this, we add half of the button's `width: + (ref.width / 2)`. If a button starts at 300px and is 200px wide, its center is at 400px (300 + 100). This gives us the vertical center line where both elements should align. ```js file=index.html Floating UI - Centering Formula
Reference center + ref.width / 2

Add ref.width / 2 to reach the center line

``` #### Step 3: Back Up by Half the Tooltip Width Here's the crucial step that trips people up. If we placed our tooltip starting at the center line, it would be off-centerβ€”the tooltip's left edge would be at the center, pushing the whole tooltip too far right. We need to back up by half the tooltip's `width: - (float.width / 2)`. This "backs up" the tooltip so that its center (not its left edge) sits on the center line. ```js file=index.html Floating UI - Centering Formula
Reference Floating - float.width / 2

Subtract float.width / 2 to align the floating element's center

``` The formula essentially says: "Find the reference center, then offset backwards by half the floating element's width." This ensures both centers align perfectly. #### Position by Side Once we know the starting point for the floating element in x, we place the element above the reference and center it horizontally. ```js case 'top': coords = { x: commonX, // Use centered x y: reference.y - floating.height // Position above }; ``` **The `y` calculation explained:** We want floating's BOTTOM edge to touch reference's TOP edge. But we set the TOP edge (y coordinate), not the bottom. So: `floating.y = reference.y - floating.height` ```markdown β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” ◄── floating.y = ref.y - float.height β”‚ β”‚ = 100 - 60 = 40 β”‚ FLOATING β”‚ β”‚ (height: 60) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ◄── floating bottom = 40 + 60 = 100 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” ◄── reference.y = 100 β”‚ REFERENCE β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` The floating element's bottom (100) meets reference's top (100) ### Calculate Alignment Offset This step we will need to know how far to shift for start/end alignment ```js const alignLength = getAxisLength(alignmentAxis); // If it's top/bottom placements, it returns "width" otherwise returns "height" const commonAlign = reference[alignLength] / 2 - floating[alignLength] / 2; ``` #### What is commonAlign? For horizontal alignment (top/bottom placements): ```js reference.width / 2 - floating.width / 2 ``` - If the reference wider than floating, the common align will be positive value. - If the floating wider than reference, the common align will be negative value. ### Apply Alignment ```js const alignment = getAlignment(placement); // 'start' | 'end' | undefined const alignmentAxis = getAlignmentAxis(placement); const isVertical = sideAxis === 'y'; // true for top/bottom switch (alignment) { case 'start': coords[alignmentAxis] -= commonAlign; break; case 'end': coords[alignmentAxis] += commonAlign; break; // default (center): no adjustment needed } ``` - 'start' alignment: Align floating's start edge with reference's start edge - 'end' alignment: Align floating's end edge with reference's end edge If the placement is top, for start alignment, subtract `commonAlign` from x, `coords.x -= commonAlign`, and for end, add `commonAlign` from x, `coords.x -= commonAlign`. ## Final Formula Table Here's the complete reference for all placement calculations:
| Placement | x | y | |-----------|---|---| | top | `ref.x + ref.w/2 - float.w/2` | `ref.y - float.h` | | top-start | `ref.x` | `ref.y - float.h` | | top-end | `ref.x + ref.w - float.w` | `ref.y - float.h` | | bottom | `ref.x + ref.w/2 - float.w/2` | `ref.y + ref.h` | | bottom-start | `ref.x` | `ref.y + ref.h` | | bottom-end | `ref.x + ref.w - float.w` | `ref.y + ref.h` | | left | `ref.x - float.w` | `ref.y + ref.h/2 - float.h/2` | | left-start | `ref.x - float.w` | `ref.y` | | left-end | `ref.x - float.w` | `ref.y + ref.h - float.h` | | right | `ref.x + ref.w` | `ref.y + ref.h/2 - float.h/2` | | right-start | `ref.x + ref.w` | `ref.y` | | right-end | `ref.x + ref.w` | `ref.y + ref.h - float.h` |
## Conclusion Understanding Floating UI's coordinate system is fundamental to mastering tooltip and popup positioning. Let's recap the key concepts: 1. **The Coordinate System**: Browser coordinates start at the top-left corner (0, 0), with X increasing rightward and Y increasing downward. 2. **Two Elements**: Every floating interaction involves a **reference** element (the anchor) and a **floating** element (the positioned content). 3. **Placements**: There are 12 placements combining 4 sides (`top`, `right`, `bottom`, `left`) with alignments (`start`, `end`, or centered by default). 4. **Two Axes**: - The **Side Axis** determines the distance between elements - The **Alignment Axis** determines where the floating element slides along the reference edge 5. **The Centering Formula**: `ref.x + ref.width/2 - float.width/2` ensures perfect center alignment by finding the reference center and then backing up by half the floating element's size. With this foundation, you're now equipped to debug positioning issues, customize Floating UI's behavior, or even build your own positioning logic. The next time a tooltip appears in the wrong place, you'll know exactly which axis and calculation to investigate! ## Playground Now that you understand the math behind Floating UI's positioning system, it's time to put that knowledge into practice! Use the interactive playground below to experiment with different placements and dimensions. Try adjusting the reference and floating element sizes to see how the coordinates change in real-time. Pay attention to how the Side Axis and Align Axis indicators update as you switch between placements, this will help solidify your understanding of how the two axes work together. ```jsx file=App.js const refX = 320; const refY = 280; const colors = { bgPage: '#f9fafb', sidebar: '#ffffff', border: '#e5e7eb', textMain: '#111827', textMuted: '#6b7280', axis: '#374151', input: '#ef4444', derived: '#172c66', guide: '#e5e7eb', side: '#10b981', align: '#f59e0b', refFill: 'rgba(99, 102, 241, 0.05)', refStroke: '#001858', floatFill: 'rgba(139, 92, 246, 0.05)', floatStroke: '#33272a', }; const styles = { app: { display: 'flex', height: '100vh', backgroundColor: colors.bgPage, color: colors.textMain, fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', }, sidebar: { width: '300px', backgroundColor: colors.sidebar, borderRight: `1px solid ${colors.border}`, display: 'flex', flexDirection: 'column', flexShrink: 0, height: '100%', padding: '0 0 0 10px' }, sidebarHeader: { padding: '16px', borderBottom: `1px solid ${colors.border}`, fontSize: '12px', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em', color: colors.textMuted, }, sidebarSection: { padding: '16px', borderBottom: `1px solid ${colors.border}`, marginBottom: '18px' }, propertyRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px', fontSize: '11px', }, propertyLabel: { color: colors.textMuted, width: '80px', }, propertyInputGroup: { display: 'flex', alignItems: 'center', gap: '8px', flex: 1, }, propertyValue: { width: '35px', textAlign: 'right', fontWeight: '500', color: colors.textMain, }, canvas: { flex: 1, position: 'relative', padding: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center', }, placementGrid: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4px', backgroundColor: colors.bgApp, padding: '4px', borderRadius: '6px', }, placementBtn: (active) => ({ padding: '6px 2px', fontSize: '10px', border: 'none', borderRadius: '4px', backgroundColor: active ? '#FFFFFF' : 'transparent', boxShadow: active ? '0 1px 3px rgba(0,0,0,0.1)' : 'none', color: active ? colors.textMain : colors.textMuted, cursor: 'pointer', fontWeight: active ? '600' : '400', }), badge: (bgColor) => ({ backgroundColor: bgColor, color: 'white', padding: '2px 6px', borderRadius: '4px', fontSize: '9px', fontWeight: 'bold', marginLeft: '6px', }) }; const App = () => { // State for parameters const [placement, setPlacement] = useState('top'); const [refWidth, setRefWidth] = useState(200); const [refHeight, setRefHeight] = useState(80); const [floatWidth, setFloatWidth] = useState(160); const [floatHeight, setFloatHeight] = useState(60); const [offset, setOffset] = useState(0); // Computation Logic const floatPos = useMemo(() => { const [side, align] = placement.split('-'); let x, y; if (side === 'top') y = refY - floatHeight - offset; else if (side === 'bottom') y = refY + refHeight + offset; else if (side === 'left') x = refX - floatWidth - offset; else if (side === 'right') x = refX + refWidth + offset; if (side === 'top' || side === 'bottom') { if (!align) x = refX + (refWidth / 2) - (floatWidth / 2); else if (align === 'start') x = refX; else if (align === 'end') x = refX + refWidth - floatWidth; } else { if (!align) y = refY + (refHeight / 2) - (floatHeight / 2); else if (align === 'start') y = refY; else if (align === 'end') y = refY + refHeight - floatHeight; } return { x: Math.round(x), y: Math.round(y) }; }, [placement, refWidth, refHeight, floatWidth, floatHeight, offset]); const axisInfo = useMemo(() => { const [side, align] = placement.split('-'); const isVertical = side === 'top' || side === 'bottom'; return { side, align: align || 'center', isVertical, sideAxis: isVertical ? 'Y' : 'X', alignAxis: isVertical ? 'X' : 'Y' }; }, [placement]); // Overlap Fix Logic const labels = useMemo(() => { const xDist = Math.abs(floatPos.x - refX); const yDist = Math.abs(floatPos.y - refY); // Check if float point is very close to ref point to flip label orientation const flipY = yDist < 40 && floatPos.y < refY; const flipX = xDist < 60; return { floatY: flipY ? 20 : -10, floatX: flipX ? -15 : 15, floatXAnchor: flipX ? 'end' : 'start' }; }, [floatPos, refX, refY]); return (
{/* Figma Sidebar */} {/* Main Canvas Area */}
{/* Main Coordinate Axes */} X Y {/* Reference Block */} Reference {/* Ref Markers */} ref.y = {refY} ref.x = {refX} {/* Floating Block */} Floating {/* Float Markers with Anti-Collision */} float.y = {floatPos.y} float.x = {floatPos.x} {/* DYNAMIC AXIS LABELS: Side Axis and Align Axis */} {axisInfo.isVertical ? ( <> {/* Vertical Side Axis Label (Y) - shows the gap between ref and float */} {(() => { const lineY1 = axisInfo.side === 'top' ? refY : refY + refHeight; const lineY2 = axisInfo.side === 'top' ? floatPos.y + floatHeight : floatPos.y; const midY = (lineY1 + lineY2) / 2; return ( <> SIDE (Y) ); })()} {/* Horizontal Align Axis Indicator (X) */} ALIGN (X) ) : ( <> {/* Horizontal Side Axis Label (X) - shows the gap between ref and float */} {(() => { const lineX1 = axisInfo.side === 'left' ? refX : refX + refWidth; const lineX2 = axisInfo.side === 'left' ? floatPos.x + floatWidth : floatPos.x; const midX = (lineX1 + lineX2) / 2; return ( <> SIDE (X) ); })()} {/* Vertical Align Axis Indicator (Y) */} ALIGN (Y) )}
); }; ``` --- ## Radix UI - Slot - **URL:** https://www.jinghuangsu.com/til/radix-ui-slot - **Date:** 2026-01-10 - **Category:** radix-ui - **Tags:** React, JavaScript, UX ## The Problem Think about a scenario where we need a Button component that can also act as a link (`
`). ### Conditional Rendering The first approach that comes to mind is conditional rendering. ```jsx function Button({ href, children, ...props }) { if (href) { return {children}; } return ; } ``` The benefit of this approach is its simplicity. The downside is that it can’t be composed with other components like Next.js’s Link, and the component must know about every possible prop. ### The "as" Prop Pattern > The `as` prop allows you to override the default element type of a component. Instead of being locked into a specific HTML element, you can adapt the component to render as any valid HTML tag or even another React component. Another way to think about it is as a prop that lets developers customise their elements. ```tsx type ButtonWithAsProps = { as?: E; children?: React.ReactNode; } & React.ComponentPropsWithoutRef; function ButtonWithAs({ as, children, ...props }: ButtonWithAsProps) { const Element = as || 'button'; return {children}; } ``` #### Usage ```tsx // As a button (default) console.log('clicked')}> Click me // As a link Go Home // As a Next.js Link About ``` #### Limitations It's a relatively good approach, since it's more flexible compared with the first one. However, it still has critical limitations: 1. **Can't merge behaviors**: If you pass an `onClick` to `ButtonWithAs` and the component itself also defines `onClick`, only one will take effect. This is replacement, not composition. 2. **TypeScript complexity**: TypeScript support for polymorphic components is notoriously difficult to implement correctly. ***That's why the slot pattern is widely used in headless UI libraries: it keeps simple cases simple while making complex cases possible.*** ### The Slot Pattern Radix UI provides a `Slot` component that offers a more powerful alternative to the "as" prop pattern. Instead of just changing the element type, `Slot` **merges props** with the child component, enabling true composition patterns. The `asChild` pattern uses a boolean prop instead of specifying the element type. When `asChild` is true, the component's props are merged with its child element. #### Implementation ```tsx const buttonVariants = cva( "inline-flex items-center justify-center rounded-md font-medium transition-colors", { variants: { variant: { default: "bg-slate-900 text-white hover:bg-slate-700", primary: "bg-blue-500 text-white hover:bg-blue-600", outline: "border border-slate-300 bg-transparent hover:bg-slate-100", }, size: { default: "h-10 px-4 py-2", sm: "h-8 px-3 text-sm", lg: "h-12 px-6 text-lg", }, }, defaultVariants: { variant: "default", size: "default", }, } ) function Button({ className, variant = "default", size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( ) } ``` #### Usage ```tsx ``` ```js title="With Slot" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚