Menu
(function() {
// Smallest → largest
const STEP_ORDER = [
'step--3',
'step--2',
'step--1',
'step-0',
'step-1',
'step-2',
'step-3',
'step-4',
'step-5',
'step-6',
'step-7',
'step-8',
'step-9',
'step-10'
];
const el = document.getElementById('menu-title');
if (!el) return;
const MAX_STEP_NAME = el.dataset.step || 'step-9';
const MIN_STEP_NAME = el.dataset.minStep || 'step-3';
let maxIndex = STEP_ORDER.indexOf(MAX_STEP_NAME);
let minIndex = STEP_ORDER.indexOf(MIN_STEP_NAME);
if (maxIndex === -1) maxIndex = STEP_ORDER.length - 1;
if (minIndex === -1) minIndex = 0;
const MARGIN = 2; // px breathing room
let currentIndex = maxIndex;
let lastViewportWidth = 0;
let lastDPR = window.devicePixelRatio || 1;
let hasInitialAdjusted = false;
// Hidden probe for accurate width measurement
let probe = null;
function ensureProbe() {
if (probe) return probe;
probe = document.createElement('span');
probe.style.position = 'absolute';
probe.style.left = '-9999px';
probe.style.top = '0';
probe.style.visibility = 'hidden';
probe.style.whiteSpace = 'nowrap';
probe.style.pointerEvents = 'none';
document.body.appendChild(probe);
const cs = getComputedStyle(el);
probe.style.fontFamily = cs.fontFamily;
probe.style.fontWeight = cs.fontWeight;
probe.style.letterSpacing = cs.letterSpacing;
probe.style.textTransform = cs.textTransform;
probe.style.fontStyle = cs.fontStyle;
probe.style.padding = cs.padding;
probe.style.border = 'none';
probe.style.margin = '0';
probe.textContent = el.textContent;
return probe;
}
// IMPORTANT: layout viewport only (no visualViewport)
function getViewportWidth() {
return (
document.documentElement.clientWidth ||
window.innerWidth ||
el.getBoundingClientRect().width
);
}
function measureWidthForStep(stepName) {
const p = ensureProbe();
p.style.fontSize = `var(--${stepName})`;
return p.getBoundingClientRect().width;
}
function adjustForViewport(viewportWidth) {
if (!el.offsetParent) return; // hidden element
const maxAllowed = viewportWidth - 2 * MARGIN;
let targetIndex = minIndex; // fallback
// Find largest step that fits
for (let idx = maxIndex; idx >= minIndex; idx--) {
const stepName = STEP_ORDER[idx];
const w = measureWidthForStep(stepName);
if (w <= maxAllowed) {
targetIndex = idx;
break;
}
}
if (hasInitialAdjusted && targetIndex === currentIndex) return;
currentIndex = targetIndex;
const stepName = STEP_ORDER[targetIndex];
el.style.fontSize = `var(--${stepName})`;
el.dataset.currentStep = stepName;
}
function loop() {
const vw = getViewportWidth();
const dpr = window.devicePixelRatio || 1;
const widthChanged = Math.abs(vw - lastViewportWidth) > 0.5;
const dprChanged = Math.abs(dpr - lastDPR) > 0.01;
// First run: size immediately, no animation
if (!hasInitialAdjusted) {
lastViewportWidth = vw;
lastDPR = dpr;
adjustForViewport(vw);
hasInitialAdjusted = true;
// Enable smooth animation AFTER initial sizing
setTimeout(function() {
el.classList.add('tt-font-animate');
}, 0);
} else if (widthChanged || dprChanged) {
lastViewportWidth = vw;
lastDPR = dpr;
adjustForViewport(vw);
}
requestAnimationFrame(loop);
}
function start() {
lastViewportWidth = getViewportWidth();
lastDPR = window.devicePixelRatio || 1;
loop();
}
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(start);
} else {
window.addEventListener('load', start);
}
})();
(function() {
const el = document.getElementById('menu-title');
if (!el) return;
// ----- 1) Split heading text into first word + rest -----
const raw = (el.textContent || '').trim();
const words = raw.split(/\s+/);
// Only do this for titles with 3+ words, as you requested
if (words.length < 3) return;
const firstWordRaw = words.shift(); // first word
const restText = words.join(' '); // remaining words
// Capitalize first letter, rest lowercase (preserve accents)
const firstWord =
firstWordRaw.charAt(0).toUpperCase() +
firstWordRaw.slice(1).toLowerCase();
// Replace content with two spans
el.innerHTML =
'<span class="tt-title-first">' + firstWord + '</span>' +
'<span class="tt-title-rest">' + restText + '</span>';
const firstSpan = el.querySelector('.tt-title-first');
if (!firstSpan) return;
// ----- 2) Step scale (same ordering as other scripts) -----
const STEP_ORDER = [
'step--3',
'step--2',
'step--1',
'step-0',
'step-1',
'step-2',
'step-3',
'step-4',
'step-5',
'step-6',
'step-7',
'step-8',
'step-9',
'step-10'
];
function applyFirstWordSize(fromStepName) {
if (!fromStepName) return;
const idx = STEP_ORDER.indexOf(fromStepName);
if (idx === -1) return;
// 2 steps smaller, but never below smallest
const smallerIdx = Math.max(idx - 2, 0);
const smallerStep = STEP_ORDER[smallerIdx];
firstSpan.style.fontSize = 'var(--' + smallerStep + ')';
}
// Initial sizing: use currentStep if present, else data-step
const initialStep =
el.dataset.currentStep || el.dataset.step || 'step-9';
applyFirstWordSize(initialStep);
// ----- 3) React whenever your sizing script changes the step -----
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
if (m.type === 'attributes' && m.attributeName === 'data-current-step') {
applyFirstWordSize(el.dataset.currentStep);
}
});
});
observer.observe(el, { attributes: true });
})();
Trà sữa
Thé au lait classique
Trà sữa nguyên bản của Tư Trà được ủ bằng phương pháp French press, giúp giữ vị trà rõ nét, kết cấu sữa mượt và hậu vị sâu.
Đậm vị

Le Fumé
Ô Long Sữa Rang khói
Mang hương hoa trắng, vị tươi mát và hậu béo sữa dịu dàng
M 45 • G 55
Đậm vị

L'Original
Ô long Sữa nguyên vị
Mang hương hoa trắng, vị tươi mát và hậu béo sữa dịu dàng
M 45 • G 55

La Fleur
Ô long Sữa Hoa nhài
Mang hương nhài trắng, vị tươi nhẹ và hậu béo sữa dịu dàng.
M 45 • G 55
Mới

Le Bruni
Ô long Sữa Gạo rang
Vị nồng ấm của gạo rang kết hợp với vị trà ô long đặc sản đậm đà
M 45 • G 55
Trà sữa tươi
Thé au lait frais
Trà hoa quả
Thé aux fruits
Matcha
Thé matcha