Building a Japanese passage block for the blog
I'm currently studying Japanese full-time and have been thinking of ways to incorporate my language learning with engineering. I think it's a great way to get more relevant experience with the language outside of what I encounter in textbooks.
I'll be incorporating Japanese on site, and wanted Japanese passages on the blog to feel like readable, interactive snippets—not static text. I've been working on a reference article that is still being drafted, but in the process of writing it I ended up building this neat little component that improves the UX of reading Japanese beyond a stylistic approach.
That led to a custom block component: sentence-level translations, toggleable furigana, and word-level popovers for definitions and readings. This post walks through the problem, the constraints, how I solved it, and how I might evolve it as time passes.
Some background
I was struggling to think of where would be a great place to start, until I recently came across a video by Kei Fujikawa, where he brought up the concept of learning "Engineering Japanese combined with JLPT (Japanese Language Proficiency Test) N3 level fluency being a great market entrypoint. This resonated with me so I started searching for references and resources to aggregate into an article that would become a public living document I can always refer to.
With the reality sinking in that my site will now have both English and Japanese content, I wanted to find a way to display the Japanese content in a way that:
-
Feels familiar to engineers — Visually distinct, like a code block or GitHub Gist, so they’re easy to scan.
-
Is interactive and accessible — Not knowing kanji or a word's meaning shouldn't be a barrier to getting the most out of the content. This means showing readings (furigana) above kanji, and letting readers tap a word to see definition, reading, and part of speech, all without needing a special browser extension or navigating off the page.
-
Supports learning — Comprehension is a key factor in learning, so we need to provide information in context and without clutter so it can be digested and remembered without friction.
I was tempted to go down the KISS route, and use an inline <Furigana/> component in MDX, but when I tested it against some of the aspirations above, it didn't quite fit the bill.
So the rest of this article walks through the journey of what I ended up with.
The first pass: dialog-style blocks
In the spirit of KISS, I quickly spun up v0 and threw a zero-shot prompt at it to see what it would come up with.
I wasn't expecting much and wanted to get a prototype together to better inform design decisions further down the line.
With that in mind, the results weren't too bad in terms of capturing the overall look and feel of what I was hoping to execute on.
Here's a preview of it in action:
すみません、この電車は渋谷に行きますか?
Excuse me, does this train go to Shibuya?
はい、行きますよ。次の駅です。
Yes, it does. It's the next station.
That <DialogBlock/> component works, but it’s speaker-centric. This first pass was useful to validate “clickable words + furigana + translation,” but the UX and data model didn’t scale the way I wanted for mixed content.
If the primary purpose of my site was around language learning, then our story probably would've just ended here and focus would have shifted to the next item to tackle...
...buuuuttt we're in my special corner of the internet, my personal playground, so with that I say ー !
Constraints going in
Before rebuilding, I locked in a few non-negotiables:
- MDX — The blog is Next.js + MDX. The block had to be a component I could drop into an article with ease. No separate app or iframe needed.
- Accessibility — Word-level controls had to be keyboard-usable and clearly associated with their content (e.g. popover trigger with
aria-expanded/aria-controls). Furigana is supplementary; the main text must still be readable when readings are hidden. - Data source — I didn’t want the same sentence defined in three places (MDX, a global glossary, and a json file ). In practice we ended up with two data paths: a shared module for some content and article-local data for others. I’d consolidate that in a future pass.
The building blocks
With those constraints in mind, I took learnings from the prototype and shaped the data model for the key primitives that would drive everything. There's a fair amount of metadata and context around individual characters and words that compose to make up a sentence, which brings us to the following type signature.
type PartOfSpeech = 'particle' | 'word' | 'phrase' | 'punctuation' | ...;
interface Token {
text: string
reading?: string
definition?: string
partOfSpeech?: PartOfSpeech
romaji?: string
highlight?: boolean
}
The Token is the key primitive we have in place that will drive all the features we're looking to implement.
A Token can be...
- A single character like the particle は
- A set of characters like my previous usage of 遊びましょう, which gives us a word.
- An expression like どうも ありがとう ございます which is a set of words and characters that form a phrase.
A Sentence is a collection of Tokens that form a complete sentence, and with that we can construct an entire Passage with targeted control for interactivity.
The translation for the sentence is also included in the Sentence type so we can provide context inline.
The next piece of the puzzle was the component that would render the Tokens and their associated metadata.
I decided to decouple interactivity from the rendering of the Tokens to keep my options open for future features and enhancements.
All interactivity is handled by the Kana component, which is a wrapper around the Token component that provides the necessary context for the interaction.
This also creates a clean layer to introduce any data-driven features or enhancements in the future.
Note: I currently manually provide the reading and definition for the
Tokens, so making this less manual would be a nice improvement.
<Kana
token={{
text: '日本語',
reading: 'にほんご',
definition: 'Japanese',
partOfSpeech: 'word',
romaji: 'nihongo'
}}
showFurigana
/>
Bringing it all together
With Token and Kana in place, I needed to package the sentences into a neat container with controls to toggle furigana and translations. I took inspiration from the embedded GitHub Gist design and used a similar approach.
That lives in a <Passage/> component that renders the sentences and wires up the interactivity.
<Passage sentences={[
{
words: [
{ text: '日本語', reading: 'にほんご', definition: 'Japanese', partOfSpeech: 'word' },
{ text: 'の', definition: 'of (possessive)', partOfSpeech: 'particle' },
{ text: '勉強', reading: 'べんきょう', definition: 'study', partOfSpeech: 'verb' },
{ text: 'は', definition: 'is', partOfSpeech: 'particle' },
{ text: 'とても', reading: 'とても', definition: 'very', partOfSpeech: 'adverb' },
{ text: '楽しい', reading: 'たのしい', definition: 'fun', partOfSpeech: 'adjective' },
{ text: 'です', reading: 'です', definition: 'is', partOfSpeech: 'verb' },
],
translation: 'Japanese studies are very fun.'
},
... // more sentences
]} />
And here it is in action, feel free to click on the words to see the popover with the definition and reading:
Click dotted words for definitions. Click a sentence number to highlight it.
Japanese language studies are very fun.
Now this definitely felt good enough to ship and continue with writing my first Japanese blog post, but there was a minor defect that was bothering me.
It was that the furigana was being displayed for the full reading of the word and not sticking to just the kanji.
This wasn't too big of a deal, but it was something that I wanted to fix as it wasn't consistent with how furigana is typically displayed in Japanese.
Luckily, the modular component design of the <Passage/> component allowed for a simple fix.
In order to solve this, I updated the Token component to support rendering a word as composite, where the reading is applied only on the kanji characters and the rest of the word is rendered as is.
Click dotted words for definitions. Click a sentence number to highlight it.
After the review yesterday, I wrote tests.
Throughout the course of writing this blog post, I realized that introducing a headless mode would be a valuable addition in enabling a minimal embedding experience for cases where a "code" block might be jarring in context.
So I added a headless prop to the <Passage/> component to enable this.
<Passage sentences={sentenceData} headless />
It was a simple addition and the demonstration below helps the content look like a natural extension of the content readers will consume.
After the review yesterday, I wrote tests.
I can definitely appreciate the end results and am looking forward to using it in upcoming articles.
Ways to evolve and improve down the line
I said at the top I'd touch on how I might evolve this—here's where that goes. Now that the rendering and interactivity problems are solved, I'm thinking about composition and data-enrichment.
It's still pretty early in my Japanese writing journey; however, improving the editing process will help drastically reduce the friction of writing the rich content I'm aiming to create.
MDX will stay my primary medium. One direction I want to try: evolving the <Passage/> component so I can pass raw Japanese text and let the component handle tokenization and lookup. That would look like:
<Passage headless >
日本語の勉強はとても楽しいです。
毎日少しずつ新しい単語や文法を覚えることで、少しずつ上達していることを感じます。
好きなアニメや漫画を原文で読めるようになるのが目標です。
</Passage>
This looks deceptively simple and abstracts a ton of complexity, but it will be a fun process to explore.
A few other directions I'm considering:
Optional syntax / grammar highlighting — We have word-level metadata (part of speech, etc.). I’d like to experiment with subtle styling or labels for e.g. verb vs particle, or JLPT level, without turning the block into clutter.
Embedded dictionary — Right now definitions are inline in the word entry. For reuse across many passages, a small embedded “dictionary” (e.g. term ID → definition/reading) would keep one source of truth and make it easier to add new passages without duplicating glossary entries.
Better lexical boundaries — Splitting Japanese text into “words” for highlighting and popovers is nontrivial (e.g. compound verbs, particles). I currently write the segment boundaries by hand. A small lexical pass (e.g. using a tokenizer like MeCab or WanaKana) along with grammar data I've already collected could reduce authoring load and keep segments consistent.
I'll be actively using this component for all Japanese content on the site, so I'll definitely be iterating on it over time.
👋🏿