একটা ডিজাইন সিস্টেম ল্যাটিন স্ক্রিপ্টের জন্য বানানো হলে সেটা বাংলায় ভাঙে — কিন্তু এমনভাবে ভাঙে যে প্রথম দেখায় বোঝা যায় না। পেজ লোড হয়, লেখা পড়া যায়, কিছু একটা "ঠিক লাগছে না" — এইটুকুই।
এই সাইটে বাংলা ট্র্যাক যোগ করতে গিয়ে যে পাঁচটা জিনিস আলাদা করে ঠিক করতে হয়েছিল, সেগুলোই এখানে। প্রতিটার কারণ স্ক্রিপ্টের গঠনে, রুচিতে নয়।
১. ফন্টে গ্লিফই নেই
সবচেয়ে বড় সমস্যাটা সবচেয়ে সহজ। 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 অ্যাট্রিবিউটটা রুট থেকে সেট হয়, প্রতি পেজে নয়, আর একটা নতুন রুট সেটা ভুলে যেতে পারে না।
সম্পর্কিত লেখা
মানুষ কেন অচেনা কারো কাছ থেকে কিনতে দ্বিধা করে — Trust Signal যেভাবে সেল বাড়ায়
একই ল্যান্ডিং পেজ, একই দাম, একই প্রোডাক্ট — শুধু নিচে তিনটা রিভিউ যোগ করলেই কনভার্শন বদলে যায়। এটা কাকতালীয় না, মানুষ সিদ্ধান্ত নেওয়ার সময় অন্যদের আচরণ দেখেই ভরসা করে।
প্রোডাক্ট বেচবেন না, সমস্যার সমাধান বেচুন — Customer আসলে কী 'hire' করছে
একজন কাস্টমার মিল্কশেক কেনে না — সকালের বোরিং গাড়ি-চালানোর সময়টাকে সহনীয় করার একটা উপায় কেনে। প্রোডাক্টের ফিচার লিস্ট না, এই "job"-টা বুঝলেই সেল বাড়ে।
কাস্টমার দাম দেখে কেনে না, তুলনা দেখে কেনে — Price Anchoring যেভাবে কাজ করে
একই প্রোডাক্ট, একই দাম — শুধু পাশে আরেকটা অপশন বসিয়ে দিলেই সেল বেড়ে যায়। ম্যাজিক না, এটা মানুষের মস্তিষ্কের একটা পরিচিত প্যাটার্ন — Anchoring।
মাসে একটা কাজের ইমেইল
ডিজাইন সিস্টেম প্যাটার্ন, ফ্রন্ট-এন্ড টেকনিক আর লার্ন প্ল্যাটফর্মের নোট। কোনো প্রচার নেই, অন্যের লিংকের সংকলনও নেই।