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
useNuiEventfor updates - Create feature definition with
defineFeature() - Register in module's
hudsorpages - Add client-side event sending
- Test in development mode