# 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
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!
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.
>
---
## 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
```
### 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. **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
```
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 (
);
}
);
```
```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
```
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
```
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
```
### 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
```
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 `