Haptic Feedback & Dynamic Theming in Telegram Mini Apps
Building a Telegram Mini App that feels truly native means going beyond basic functionality. Two features that make the biggest difference in perceived quality: haptic feedback and automatic theme adaptation. Together they turn a generic WebView into something that feels like it belongs in Telegram.
This guide covers the practical implementation using React + Vite, with reusable hooks you can drop into any project.
Understanding tg.HapticFeedback
The Telegram WebApp SDK exposes three haptic methods via window.Telegram.WebApp.HapticFeedback:
// Physical button presses, confirmations
impactOccurred(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft')
// Operation outcomes
notificationOccurred(type: 'error' | 'success' | 'warning')
// Selection changes in lists or pickers
selectionChanged()
Haptic only fires on real mobile devices — iOS and Android. On desktop the calls are silently ignored, so no platform checks needed.
A minimal useHaptic hook
// src/hooks/useHaptic.ts
import { useCallback } from 'react';
export function useHaptic() {
const tg = window.Telegram?.WebApp;
const impact = useCallback(
(style: 'light' | 'medium' | 'heavy' = 'medium') => {
tg?.HapticFeedback?.impactOccurred(style);
},
[tg]
);
const notify = useCallback(
(type: 'error' | 'success' | 'warning') => {
tg?.HapticFeedback?.notificationOccurred(type);
},
[tg]
);
const selection = useCallback(() => {
tg?.HapticFeedback?.selectionChanged();
}, [tg]);
return { impact, notify, selection };
}
When to use which method:
impact('light')— toggles, checkboxes, minor interactionsimpact('medium')— primary action buttons (Add to cart, Confirm)impact('heavy')— destructive actions (Delete, Cancel order)notify('success')— payment complete, form submittednotify('error')— validation failure, network errorselection()— scrollable pickers, tab switches
Avoid overuse. If every scroll tick triggers selectionChanged(), it becomes annoying fast. Reserve haptics for intentional, meaningful user actions.
Theme Adaptation with themeParams
Telegram automatically injects CSS custom properties into document.documentElement when your Mini App launches:
/* These work out of the box — no JavaScript needed */
.card {
background: var(--tg-theme-bg-color);
color: var(--tg-theme-text-color);
}
.button {
background: var(--tg-theme-button-color);
color: var(--tg-theme-button-text-color);
}
.hint {
color: var(--tg-theme-hint-color);
}
.secondary-panel {
background: var(--tg-theme-secondary-bg-color);
}
For most use cases, CSS variables are all you need. They update automatically when the user switches themes.
Reactive Theme Hook for JS-based Styling
When you need theme values in JavaScript — for canvas, SVG, or third-party chart libraries — use the themeChanged event:
// src/hooks/useTheme.ts
import { useState, useEffect } from 'react';
export function useTheme() {
const tg = window.Telegram?.WebApp;
const [isDark, setIsDark] = useState(tg?.colorScheme === 'dark');
const [theme, setTheme] = useState(
(tg?.themeParams as Record<string, string>) ?? {}
);
useEffect(() => {
if (!tg) return;
const handler = () => {
setIsDark(tg.colorScheme === 'dark');
setTheme(tg.themeParams as Record<string, string>);
};
tg.onEvent('themeChanged', handler);
return () => tg.offEvent('themeChanged', handler);
}, [tg]);
return { theme, isDark };
}
Practical example with a chart library:
import { useTheme } from '../hooks/useTheme';
export function SalesChart({ data }: { data: number[] }) {
const { theme, isDark } = useTheme();
// Pass Telegram theme colors to the chart config
const lineColor = theme.button_color ?? '#2678b6';
const textColor = theme.text_color ?? '#000000';
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
// ... chart configuration using these values
return <div>/* chart here */</div>;
}
Header and Background Colors
When using tg.expand() or fullscreen mode, the Telegram UI chrome becomes visible. Match it to your app:
// src/main.tsx
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
tg.expand();
// Match Telegram header to your app background
tg.setHeaderColor('bg_color');
// Prevent white flash on rubber-band scroll
tg.setBackgroundColor(tg.themeParams.bg_color ?? '#ffffff');
}
Always update setBackgroundColor inside the themeChanged handler too — otherwise you’ll get a mismatched background when the user switches themes with the app open.
A Unified TelegramProvider
For larger apps, centralise everything in one context:
// src/providers/TelegramProvider.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
type Ctx = {
isDark: boolean;
theme: Record<string, string>;
haptic: {
impact: (s?: 'light' | 'medium' | 'heavy') => void;
notify: (t: 'success' | 'error' | 'warning') => void;
};
};
const Ctx = createContext<Ctx | null>(null);
export function TelegramProvider({ children }: { children: React.ReactNode }) {
const tg = window.Telegram?.WebApp ?? null;
const [isDark, setIsDark] = useState(tg?.colorScheme === 'dark');
const [theme, setTheme] = useState<Record<string, string>>(
(tg?.themeParams as Record<string, string>) ?? {}
);
useEffect(() => {
if (!tg) return;
tg.ready();
tg.expand();
tg.setHeaderColor('bg_color');
tg.setBackgroundColor(tg.themeParams.bg_color ?? '#ffffff');
const sync = () => {
setIsDark(tg.colorScheme === 'dark');
setTheme(tg.themeParams as Record<string, string>);
tg.setBackgroundColor(tg.themeParams.bg_color ?? '#ffffff');
};
tg.onEvent('themeChanged', sync);
return () => tg.offEvent('themeChanged', sync);
}, [tg]);
const haptic = {
impact: (s: 'light' | 'medium' | 'heavy' = 'medium') =>
tg?.HapticFeedback?.impactOccurred(s),
notify: (t: 'success' | 'error' | 'warning') =>
tg?.HapticFeedback?.notificationOccurred(t),
};
return <Ctx.Provider value={{ isDark, theme, haptic }}>{children}</Ctx.Provider>;
}
export const useTelegram = () => {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('useTelegram outside TelegramProvider');
return ctx;
};
Add TypeScript types for the SDK:
npm install -D @types/telegram-web-app
Then in tsconfig.json:
{ "compilerOptions": { "types": ["telegram-web-app"] } }
FAQ
Haptic feedback isn’t working on iOS. What’s wrong?
Make sure tg.ready() is called before any SDK interactions. Also verify the user’s Telegram client supports Bot API 6.1 or later — older versions have the methods but they’re no-ops.
Do I need JavaScript to apply Telegram theme colors?
No. For standard UI elements, CSS variables (var(--tg-theme-bg-color) etc.) are injected automatically and update on theme change. Use JavaScript only when you need theme values in non-CSS contexts like canvas or chart configs.
How do I develop locally without Telegram?
window.Telegram will be undefined. Use optional chaining (tg?.HapticFeedback?.impactOccurred(...)) throughout. For a better dev experience, check out @telegram-apps/sdk which includes a mock environment for local development.
Should I use tg.themeParams or CSS variables?
Prefer CSS variables for UI components — they’re simpler and automatically reactive. Use tg.themeParams via the useTheme hook when you need colors in JavaScript logic, event handlers, or third-party integrations.
Wrapping Up
Haptic feedback and theme adaptation are low-effort, high-impact improvements. A Mini App that vibrates correctly on key actions and looks right in both light and dark themes signals craftsmanship to users.
Core takeaways:
- Keep haptics intentional — major actions only
- CSS variables handle 95% of theming needs
- Always sync
setBackgroundColorwith the theme - Wrap everything in a
TelegramProviderfor clean, scalable architecture
The next step in native-feeling Mini Apps: biometric authentication via tg.BiometricManager — fingerprint and Face ID support built directly into the SDK.