একটা ডিজাইন সিস্টেম ল্যাটিন স্ক্রিপ্টের জন্য বানানো হলে সেটা বাংলায় ভাঙে — কিন্তু এমনভাবে ভাঙে যে প্রথম দেখায় বোঝা যায় না। পেজ লোড হয়, লেখা পড়া যায়, কিছু একটা "ঠিক লাগছে না" — এইটুকুই।
এই সাইটে বাংলা ট্র্যাক যোগ করতে গিয়ে যে পাঁচটা জিনিস আলাদা করে ঠিক করতে হয়েছিল, সেগুলোই এখানে। প্রতিটার কারণ স্ক্রিপ্টের গঠনে, রুচিতে নয়।
১. ফন্টে গ্লিফই নেই
সবচেয়ে বড় সমস্যাটা সবচেয়ে সহজ। Inter, Geist, Satoshi — এদের কারও মধ্যে বাংলা গ্লিফ নেই। বাংলা লেখা এই ফন্টে রেন্ডার করতে দিলে ব্রাউজার fallback খোঁজে, আর সেটা যা পায় তা-ই দেখায়: সিস্টেম ফন্ট, বা টোফু বক্স।
/* যথেষ্ট নয় */
body {
font-family: 'Inter', sans-serif;
}
বাংলার জন্য আলাদা ফন্ট লাগবেই, আর সেটা subset ধরে আনতে হবে:
fonts: {
defaults: { subsets: ['latin'] },
families: [
{ name: 'Inter', provider: 'google' },
{
name: 'Noto Sans Bengali',
provider: 'google',
subsets: ['bengali'], // গ্লোবাল ডিফল্ট latin — এখানে override না করলে
}, // যে ফাইলটা আসবে তাতে একটাও দরকারি গ্লিফ নেই
],
}
শেষ লাইনটা আসল ফাঁদ। গ্লোবাল subsets: ['latin'] থাকলে Noto Sans Bengali-ও latin subset নিয়ে আসবে — ফাইলটা ডাউনলোড হবে, ওজন যোগ হবে, আর একটা বাংলা অক্ষরও দেখাবে না।
fallback-এর ক্রমটাও ভাবার বিষয়
বাংলা পেজে ইংরেজিও থাকে — কোড, প্রোডাক্টের নাম, সংখ্যা। ব্রাউজার প্রতি-গ্লিফ ধরে fallback করে, তাই ক্রম ঠিক করে দিলে দুটোই ঠিক ফন্ট পায়:
:lang(bn) {
font-family: 'Noto Sans Bengali', 'Inter', ui-sans-serif, sans-serif;
}
Noto আগে, তাই বাংলা Noto পায়। ইংরেজি অক্ষর Noto-র ল্যাটিন সেটে আছে বটে, কিন্তু সেটা দেখতে অন্যরকম — পাতায় আলাদা রঙের মতো লাগে। Inter দ্বিতীয়তে থাকায় ল্যাটিন গ্লিফগুলো Inter-এই পড়ে।
কোড কিন্তু দুই ট্র্যাকেই ল্যাটিন থাকা উচিত:
:lang(bn) :is(code, pre, kbd, samp) {
font-family: var(--font-mono);
}
একটা shell কমান্ড বাংলা ফন্টে রেন্ডার করার কোনো কারণ নেই, আর কোড ব্লকের লেআউট মনোস্পেসের গ্লিফ-প্রস্থ ধরে বানানো।
২. মাত্রা উপরে-নিচে জায়গা চায়
এইটা সবচেয়ে কম আলোচিত, আর সবচেয়ে বেশি চোখে লাগে।
বাংলায় কার-চিহ্ন বেসলাইনের উপরে আর নিচে বসে — ি, ী, ু, ূ, ৃ, ে, ৈ, ো, ৌ, রেফ, হসন্ত, যুক্তাক্ষরের নিচের অংশ। ল্যাটিন প্রোজে যে line-height: 1.6 স্বাভাবিক, বাংলায় সেই মাপে পরপর দুই লাইনের চিহ্নগুলো একে অপরকে ছুঁয়ে ফেলে।
:lang(bn) .ux-prose :is(p, li, blockquote) {
line-height: 1.9;
}
কিন্তু সব সাইজে এক মান বসানো ভুল — আর এই ভুলটা আমরা করেছিলাম।
প্রথমে লেখা ছিল :lang(bn) :is(h1, h2, h3) { line-height: 1.45 }। বডি টেক্সটের মাপে এটা ঠিক। কিন্তু পেজ টাইটেল ক্ল্যাম্প হয়ে ৬৪px-এ যায় — সেখানে ১.৪৫ মানে দুই লাইনের মাঝে ৯৩px ফাঁক। দুই শব্দের একটা হেডিং দেখাচ্ছিল যেন ডাবল-স্পেসড।
টাইপ যত বড়, আনুপাতিক লিডিং তত কম দরকার। একটা fixed multiplier ঠিক উল্টোটা করে। তাই প্রতিটা ধাপে আলাদা:
:lang(bn) {
--text-hero--line-height: 1.16; /* ল্যাটিনের চেয়ে সামান্যই বেশি */
--text-section--line-height: 1.22;
--text-title--line-height: 1.35;
--text-lg--line-height: 1.75;
--text-base--line-height: 1.8; /* পড়ার মাপে উদার */
--text-sm--line-height: 1.65;
}
টোকেনটা রিডিফাইন করাই যথেষ্ট, কারণ ইউটিলিটিগুলো line-height: var(--text-<size>--line-height)-এ কম্পাইল হয়। কোনো সিলেক্টর লিখতে হয় না, আর ওই ধাপ ব্যবহার করা প্রতিটা এলিমেন্ট আপনাআপনি ঠিক হয়ে যায়।
৩. uppercase বাংলায় একটা no-op
বাংলায় বড় হাতের অক্ষর নেই। text-transform: uppercase কিছুই বদলায় না।
সমস্যা হলো, eyebrow বা badge-এর স্টাইলে uppercase একা আসে না — সাথে letter-spacing আসে। ল্যাটিনে বড় হাতের অক্ষর ফাঁক ফাঁক করে বসালে ভালো লাগে। বাংলায় ট্র্যাকিং বাড়ালে যুক্তাক্ষর টেনে আলাদা হয়ে যায়, আর লেবেলটা ভাঙা দেখায়।
:lang(bn) :is(.ux-eyebrow, .uppercase, [class*='tracking-']) {
text-transform: none;
letter-spacing: normal;
}
দুটোই বাতিল করা হয়েছে, নতুন করে স্টাইল করা হয়নি — ইংরেজি ট্র্যাক তার নিজের ট্রিটমেন্ট অক্ষত রাখে।
৪. একই px-এ বাংলা ছোট দেখায়
একই ফন্ট-সাইজে বাংলার x-height ল্যাটিনের তুলনায় কম, তাই পাশাপাশি রাখলে বাংলাটা ছোট লাগে। খুব সামান্য একটা বাম্প দুই ট্র্যাককে একই ডিজাইন সিস্টেমের মতো দেখায়:
:lang(bn) .ux-prose {
font-size: 1.03em;
}
em, px নয় — যাতে ব্যবহারকারীর নিজের ফন্ট-সাইজ সেটিং কাজ করতে থাকে।
৫. সংখ্যার হাইড্রেশন মিসম্যাচ
এইটা টাইপোগ্রাফির সমস্যা নয়, কিন্তু একই কাজ করতে গিয়ে ধরা পড়ে।
বাংলা সংখ্যা ফরম্যাট করতে স্বাভাবিক পছন্দ:
new Intl.NumberFormat('bn-BD').format(12480)
কিন্তু ICU বিল্ডগুলো একমত নয় — কেউ হাজার ধরে গ্রুপ করে (১২,৪৮০), কেউ লাখ ধরে (১২,৪৮০ বনাম ১,২৪৮০ ধরনের পার্থক্য)। Node-এর ICU আর ব্রাউজারের ICU আলাদা হলে সার্ভার একরকম লেখে, ক্লায়েন্ট আরেকরকম — আর prerendered পেজে সেটা সরাসরি hydration mismatch।
তাই গ্রুপিংটা en-US থেকে নিয়ে শুধু অঙ্কগুলো বদলানো হয়:
const BN_DIGITS = ['০', '১', '২', '৩', '৪', '৫', '৬', '৭', '৮', '৯']
export function formatBnNumber(value: number): string {
return new Intl.NumberFormat('en-US')
.format(value)
.replace(/\d/g, (d) => BN_DIGITS[Number(d)]!)
}
দেখতে হ্যাক লাগে। কিন্তু এটা deterministic, আর দুই রানটাইমে এক আউটপুট দেয় — যেটাই আসল দাবি।
সব মিলিয়ে
লক্ষ্য করার মতো ব্যাপার: এর কোনোটাই "বাংলার জন্য আলাদা ডিজাইন" নয়। সবগুলো :lang(bn)-এর ভেতরে, একটা আলাদা ফাইলে, সবার শেষে ইমপোর্ট করা — যাতে ল্যাটিন ডিফল্টের উপরে জেতে।
@import './prose.css';
/* সবার শেষে, যাতে :lang(bn) আগেরগুলোকে override করে */
@import './bangla.css';
ইংরেজি ট্র্যাক এই ফাইলটার একটা লাইনও ছোঁয় না। আর <html lang> ভুল হলে পুরো স্টাইলশিটটা চুপচাপ নিষ্ক্রিয় হয়ে যায় — উপসর্গ দেখায় "বাংলা ভুল ফন্টে রেন্ডার হচ্ছে", যেটা আসল কারণ থেকে অনেক দূরে। তাই lang অ্যাট্রিবিউটটা রুট থেকে সেট হয়, প্রতি পেজে নয়, আর একটা নতুন রুট সেটা ভুলে যেতে পারে না।
সম্পর্কিত লেখা
ডিজাইন সিস্টেম বানানোর আগে যে ভুলগুলো এড়ানো দরকার
বেশিরভাগ টোকেন সিস্টেম প্রথম রিব্র্যান্ডেই ভেঙে পড়ে। কারণটা প্রায় সবসময় এক — দুইটা লেয়ার আছে, দরকার ছিল তিনটা।
setTimeout(fn, 0) সাথে সাথে চলে না কেন
ম্যাক্রোটাস্ক, মাইক্রোটাস্ক আর রেন্ডারিং — ইভেন্ট লুপের ধাপগুলো জানলে অ্যাসিঙ্ক কোডের ক্রম আর অনুমান করতে হয় না।
ফ্রিল্যান্সিংয়ে রেট ঠিক করা — ঘণ্টার হিসাব থেকে বেরোনোর উপায়
ঘণ্টায় দাম দিলে আপনি দ্রুত কাজ শেখার জন্য শাস্তি পান। স্কোপ, বাফার আর কীভাবে একটা সংখ্যা বলার আগে প্রশ্নগুলো করতে হয়।
One useful email a month
Design system patterns, front-end techniques and case study breakdowns. No promotions, no digest of other people's links.


