Skip to main content

Adding UI Features

This guide covers adding HUDs and pages to existing modules.

Adding a HUD

1. Create Directory Structure

mkdir -p src/modules/my-module/huds/my-hud

2. Create Service with webOwned Observable

// src/modules/my-module/my.service.ts
import { webOwned } from "@core/observable";
 
interface MyHudState {
	visible: boolean;
	data: string;
}
 
export class MyService {
	public hudState = webOwned<MyHudState>({
		id: "my-module:hud",
		initialValue: { visible: true, data: "" },
	});
 
	@ClientOnly
	updateHud(data: string): void {
		this.hudState.set({ ...this.hudState.value, data });
	}
 
	@ClientOnly
	setVisible(visible: boolean): void {
		this.hudState.set({ ...this.hudState.value, visible });
	}
}

3. Create the Panel Component

// src/modules/my-module/huds/my-hud/index.tsx
"use web";
 
import { useService, useObservable } from "@core/web/react";
import { MyService } from "@modules/my-module/my.service";
import { motion, AnimatePresence } from "motion/react";
 
export default function MyHudPanel() {
	const myService = useService(MyService);
	const state = useObservable(myService.hudState);
 
	return (
		<AnimatePresence>
			{state.visible && (
				<motion.div
					initial={{ opacity: 0, y: 20 }}
					animate={{ opacity: 1, y: 0 }}
					exit={{ opacity: 0, y: 20 }}
					className="rounded-lg bg-gray-900/90 p-4"
				>
					<h3 className="text-sm font-medium text-gray-400">My HUD</h3>
					<p className="mt-1 text-lg font-bold text-white">{state.data}</p>
				</motion.div>
			)}
		</AnimatePresence>
	);
}
No backdrop filters in HUDs

Do not use backdrop-blur, backdrop-saturate, or other backdrop-* filter classes in HUD components. These break the FiveM runtime when rendering over the game client.

What works fine in HUDs:

  • Transparency: bg-black/50, opacity-80
  • Blur: blur, blur-sm (on the element itself)
  • Shadows: shadow-lg, drop-shadow-md

What's broken in HUDs:

  • All backdrop-* filter classes

4. Register in Module

// src/modules/my-module/module.ts
import { registerModule } from "@core/module";
import { MyService } from "@modules/my-module/my.service";
import MyHudPanel from "@modules/my-module/huds/my-hud/index";
 
export default registerModule({
	name: "my-module",
	services: [MyService],
	huds: [
		{
			name: "my-hud",
			panel: MyHudPanel,
			hudPosition: "bottom-left",
			hudPriority: 0,
			hudGroup: "default",
		},
	],
});

Adding a Page

Pages follow the same pattern but are full-screen and can use backdrop-* filters (they work fine in fullscreen).

1. Create Page Directory Structure

mkdir -p src/modules/my-module/pages/my-page

2. Add Page State to Service (Optional)

// src/modules/my-module/my.service.ts
import { webOwned } from "@core/observable";
 
interface MyPageState {
	loading: boolean;
	data: Record<string, unknown> | null;
}
 
export class MyService {
	// ... existing HUD state ...
 
	public pageState = webOwned<MyPageState>({
		id: "my-module:page",
		initialValue: { loading: false, data: null },
	});
}
tip

Pages may use local React state instead of observables if they don't need persistence across renders or cross-runtime updates.

3. Create the Page Component

// src/modules/my-module/pages/my-page/index.tsx
"use web";
 
import { Button } from "@ui/components/ui/button";
 
export default function MyPage() {
	const handleClose = async () => {
		// This callback must be registered on the client
		await fetch("https://true_life/my-page:close", { method: "POST" });
	};
 
	return (
		<div className="flex h-full w-full items-center justify-center bg-black/80 backdrop-blur-sm">
			<div className="max-w-md rounded-xl bg-gray-900 p-8 shadow-2xl">
				<h1 className="text-2xl font-bold text-white">My Page</h1>
				<p className="mt-4 text-gray-400">This is a full-screen page interface.</p>
				<div className="mt-6 flex justify-end">
					<Button onClick={handleClose}>Close</Button>
				</div>
			</div>
		</div>
	);
}
backdrop filters allowed in Pages

Pages render fullscreen and can safely use backdrop-blur and other backdrop-* filter effects. These only break when used in HUDs that render over the game client.

4. Register Page in Module

import MyPage from "@modules/my-module/pages/my-page/index";
 
export default registerModule({
	name: "my-module",
	pages: {
		"my-page": {
			panel: MyPage,
		},
	},
});

5. Open/Close from Client

// Open page
sendHudEvent("page:open", { page: "my-page" });
 
// Close page
sendHudEvent("page:close", {});

Using shadcn/ui Components

The project includes shadcn/ui components in src/ui/components/ui/:

import { Button } from "@ui/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/components/ui/card";
import { Input } from "@ui/components/ui/input";
import { Progress } from "@ui/components/ui/progress";
 
function MyComponent() {
	return (
		<Card>
			<CardHeader>
				<CardTitle>Title</CardTitle>
			</CardHeader>
			<CardContent>
				<Input placeholder="Enter value" />
				<Progress value={75} className="mt-4" />
				<Button className="mt-4">Submit</Button>
			</CardContent>
		</Card>
	);
}

Adding Animations

Use Motion (Framer Motion) for animations:

import { motion, AnimatePresence } from "motion/react";
 
function AnimatedList({ items }) {
	return (
		<AnimatePresence>
			{items.map((item) => (
				<motion.div
					key={item.id}
					initial={{ opacity: 0, x: -20 }}
					animate={{ opacity: 1, x: 0 }}
					exit={{ opacity: 0, x: 20 }}
					transition={{ duration: 0.2 }}
				>
					{item.content}
				</motion.div>
			))}
		</AnimatePresence>
	);
}

Styling Best Practices

Use Tailwind Utilities

// Good
<div className="flex items-center gap-4 rounded-lg bg-gray-900 p-4">
 
// Avoid inline styles
<div style={{ display: 'flex', padding: '16px' }}>

HUD vs Page Backgrounds

// HUD - No blur, solid semi-transparent background
<div className="rounded-lg bg-gray-900/90 p-4">
 
// Page - Blur allowed for glass-morphism
<div className="bg-black/50 backdrop-blur-sm">

Checklist

When adding a new UI feature:

  • Create directory structure
  • Add "use web" directive to component files
  • Create or update service with webOwned observable (if needed)
  • Create component with useService and useObservable
  • Register in module's huds or pages
  • Add client-side event sending (for pages)
  • Verify no backdrop-* filters in HUDs (transparency/blur/shadows are fine)
  • Test in development mode