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-hud2. 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>
);
}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-page2. 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 },
});
}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>
);
}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
webOwnedobservable (if needed) - Create component with
useServiceanduseObservable - Register in module's
hudsorpages - Add client-side event sending (for pages)
- Verify no
backdrop-*filters in HUDs (transparency/blur/shadows are fine) - Test in development mode