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/state

2. Create the Redux Slice

// src/modules/my-module/huds/my-hud/state/slice.ts
"use web";

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface MyHudState {
visible: boolean;
data: string;
}

const initialState: MyHudState = {
visible: true,
data: "",
};

export const myHudSlice = createSlice({
name: "myHud",
initialState,
reducers: {
setVisible: (state, action: PayloadAction<boolean>) => {
state.visible = action.payload;
},
setData: (state, action: PayloadAction<string>) => {
state.data = action.payload;
},
},
});

export const { setVisible, setData } = myHudSlice.actions;
export const selectVisible = (state: { myHud: MyHudState }) => state.myHud.visible;
export const selectData = (state: { myHud: MyHudState }) => state.myHud.data;

export default myHudSlice.reducer;

3. Create the Panel Component

// src/modules/my-module/huds/my-hud/Panel.tsx
"use web";

import { useAppSelector, useNuiEvent, useAppDispatch } from "@ui/hooks";
import { selectVisible, selectData, setData } from "@modules/my-module/huds/my-hud/state/slice";
import { motion, AnimatePresence } from "motion/react";

export default function MyHudPanel() {
const dispatch = useAppDispatch();
const visible = useAppSelector(selectVisible);
const data = useAppSelector(selectData);

useNuiEvent<{ data: string }>("my-hud:update", ({ data }) => {
dispatch(setData(data));
});

return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="rounded-lg bg-gradient-to-br from-gray-900/90 to-gray-800/90 p-4 backdrop-blur-sm"
>
<h3 className="text-sm font-medium text-gray-400">My HUD</h3>
<p className="mt-1 text-lg font-bold text-white">{data}</p>
</motion.div>
)}
</AnimatePresence>
);
}

4. Create the Feature Definition

// src/modules/my-module/huds/my-hud/feature.tsx
"use web";

import { defineFeature } from "@ui/feature";
import Panel from "@modules/my-module/huds/my-hud/Panel";
import reducer, { myHudSlice } from "@modules/my-module/huds/my-hud/state/slice";

export default defineFeature({
panel: Panel,
slices: [{ name: myHudSlice.name, reducer }],
hudPosition: "bottom-left",
hudPriority: 0,
});

5. Register in Module

// src/modules/my-module/module.ts
import { registerModule } from "@core/module";

export default registerModule({
name: "my-module",
huds: {
"my-hud": () => import("@modules/my-module/huds/my-hud/feature"),
},
});

Adding a Page

Pages follow the same pattern but are full-screen.

1. Create Page Directory Structure

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

2. Create the Redux Slice (Optional)

If your page needs state management, create a Redux slice:

// src/modules/my-module/pages/my-page/state/slice.ts
"use web";

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface MyPageState {
loading: boolean;
data: Record<string, unknown> | null;
}

const initialState: MyPageState = {
loading: false,
data: null,
};

export const myPageSlice = createSlice({
name: "myPage",
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setData: (state, action: PayloadAction<Record<string, unknown> | null>) => {
state.data = action.payload;
},
},
});

export const { setLoading, setData } = myPageSlice.actions;
export const selectLoading = (state: { myPage: MyPageState }) => state.myPage.loading;
export const selectData = (state: { myPage: MyPageState }) => state.myPage.data;

export default myPageSlice.reducer;
tip

Pages may omit Redux state entirely if they only need local React state or don't require persistence across renders. In that case, skip this step and remove the slices entry from defineFeature().

3. Create the Page Component

// src/modules/my-module/pages/my-page/Page.tsx
"use web";

import { useNuiEvent } from "@ui/hooks";
import { Button } from "@ui/components/ui/button";

export default function MyPage() {
const handleClose = async () => {
// Note: This callback must be registered on the client
// See: Client/UI Communication > Sending Events from UI to 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>
);
}

4. Create Feature Definition (No hudPosition)

// src/modules/my-module/pages/my-page/feature.tsx
"use web";

import { defineFeature } from "@ui/feature";
import Page from "@modules/my-module/pages/my-page/Page";
import reducer, { myPageSlice } from "@modules/my-module/pages/my-page/state/slice";

export default defineFeature({
panel: Page,
slices: [{ name: myPageSlice.name, reducer }],
// No hudPosition = page feature
});

5. Register Page in Module

export default registerModule({
name: "my-module",
pages: {
"my-page": () => import("@modules/my-module/pages/my-page/feature"),
},
});

6. 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 ui/src/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' }}>

Create Consistent Themes

// Define reusable styles
const cardStyles = "rounded-lg bg-gray-900/90 backdrop-blur-sm border border-gray-800";
const headingStyles = "text-lg font-semibold text-white";
const subtextStyles = "text-sm text-gray-400";

function MyCard() {
return (
<div className={cardStyles}>
<h2 className={headingStyles}>Title</h2>
<p className={subtextStyles}>Subtitle</p>
</div>
);
}

Responsive Design

// Responsive classes
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Items */}
</div>

Checklist

When adding a new UI feature:

  • Create directory structure
  • Add "use web" directive to all files
  • Create Redux slice with actions and selectors
  • Create component with useNuiEvent for updates
  • Create feature definition with defineFeature()
  • Register in module's huds or pages
  • Add client-side event sending
  • Test in development mode